mirror of
https://github.com/simonw/datasette.git
synced 2026-06-24 01:34:41 +02:00
Use dataclasses for database table context
This commit is contained in:
parent
59ab0c0ca0
commit
34d9a3bf33
6 changed files with 95 additions and 27 deletions
|
|
@ -331,6 +331,15 @@ def _to_string(value):
|
|||
return json.dumps(value, default=str)
|
||||
|
||||
|
||||
def _template_context_json_default(value):
|
||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||
return {
|
||||
field.name: getattr(value, field.name)
|
||||
for field in dataclasses.fields(value)
|
||||
}
|
||||
return repr(value)
|
||||
|
||||
|
||||
@pass_context
|
||||
def _legacy_template_csrftoken(context):
|
||||
request = context.get("request")
|
||||
|
|
@ -2390,7 +2399,13 @@ class Datasette:
|
|||
}
|
||||
if request and request.args.get("_context") and self.setting("template_debug"):
|
||||
return "<pre>{}</pre>".format(
|
||||
escape(json.dumps(template_context, default=repr, indent=4))
|
||||
escape(
|
||||
json.dumps(
|
||||
template_context,
|
||||
default=_template_context_json_default,
|
||||
indent=4,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return await template.render_async(template_context)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from dataclasses import dataclass
|
||||
import dataclasses
|
||||
import types
|
||||
import typing
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -10,6 +12,19 @@ class ContextField:
|
|||
from_extra: bool = False
|
||||
|
||||
|
||||
def _type_name(type_):
|
||||
if type_ is type(None):
|
||||
return "None"
|
||||
origin = typing.get_origin(type_)
|
||||
args = typing.get_args(type_)
|
||||
if origin in (typing.Union, types.UnionType):
|
||||
return " | ".join(_type_name(arg) for arg in args)
|
||||
if origin is not None:
|
||||
name = getattr(origin, "__name__", str(origin).removeprefix("typing."))
|
||||
return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args))
|
||||
return getattr(type_, "__name__", str(type_).removeprefix("typing."))
|
||||
|
||||
|
||||
def from_extra():
|
||||
"""
|
||||
Declare a Context dataclass field whose value comes from a registered
|
||||
|
|
@ -42,7 +57,7 @@ class Context:
|
|||
documented.append(
|
||||
ContextField(
|
||||
name=f.name,
|
||||
type_name=getattr(f.type, "__name__", str(f.type)),
|
||||
type_name=_type_name(f.type),
|
||||
help=help_text,
|
||||
from_extra=is_from_extra,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
|
@ -45,6 +45,21 @@ from .table_create_alter import _create_table_ui_context
|
|||
from . import Context
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseTable:
|
||||
"Summary of a table or view shown on database and query pages."
|
||||
|
||||
name: str
|
||||
columns: list[str]
|
||||
primary_keys: list[str]
|
||||
count: int | None
|
||||
count_truncated: bool
|
||||
hidden: bool
|
||||
fts_table: str | None
|
||||
foreign_keys: dict[str, list[dict[str, str]]]
|
||||
private: bool
|
||||
|
||||
|
||||
class DatabaseView(View):
|
||||
async def get(self, request, datasette):
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
|
@ -169,8 +184,8 @@ class DatabaseView(View):
|
|||
"private": private,
|
||||
"path": datasette.urls.database(database),
|
||||
"size": db.size,
|
||||
"tables": tables,
|
||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
||||
"tables": [asdict(table) for table in tables],
|
||||
"hidden_count": len([table for table in tables if table.hidden]),
|
||||
"views": sql_views,
|
||||
"queries": [stored_query_to_dict(query) for query in stored_queries],
|
||||
"queries_more": queries_more,
|
||||
|
|
@ -211,7 +226,7 @@ class DatabaseView(View):
|
|||
path=datasette.urls.database(database),
|
||||
size=db.size,
|
||||
tables=tables,
|
||||
hidden_count=len([t for t in tables if t["hidden"]]),
|
||||
hidden_count=len([table for table in tables if table.hidden]),
|
||||
views=sql_views,
|
||||
queries=stored_queries,
|
||||
queries_more=queries_more,
|
||||
|
|
@ -266,9 +281,9 @@ class DatabaseContext(Context):
|
|||
)
|
||||
path: str = field(metadata={"help": "The URL path to this database"})
|
||||
size: int = field(metadata={"help": "The size of the database in bytes"})
|
||||
tables: list = field(
|
||||
tables: list[DatabaseTable] = field(
|
||||
metadata={
|
||||
"help": "List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
}
|
||||
)
|
||||
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
||||
|
|
@ -391,9 +406,9 @@ class QueryContext(Context):
|
|||
save_query_url: str = field(
|
||||
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
||||
)
|
||||
tables: list = field(
|
||||
tables: list[DatabaseTable] = field(
|
||||
metadata={
|
||||
"help": "List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
}
|
||||
)
|
||||
named_parameter_values: dict = field(
|
||||
|
|
@ -456,7 +471,7 @@ class QueryContext(Context):
|
|||
)
|
||||
|
||||
|
||||
async def get_tables(datasette, request, db, allowed_dict):
|
||||
async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable]:
|
||||
"""
|
||||
Get list of tables with metadata for the database view.
|
||||
|
||||
|
|
@ -477,21 +492,21 @@ async def get_tables(datasette, request, db, allowed_dict):
|
|||
|
||||
table_columns = await db.table_columns(table)
|
||||
tables.append(
|
||||
{
|
||||
"name": table,
|
||||
"columns": table_columns,
|
||||
"primary_keys": await db.primary_keys(table),
|
||||
"count": table_counts[table],
|
||||
"count_truncated": _table_count_truncated(
|
||||
DatabaseTable(
|
||||
name=table,
|
||||
columns=table_columns,
|
||||
primary_keys=await db.primary_keys(table),
|
||||
count=table_counts[table],
|
||||
count_truncated=_table_count_truncated(
|
||||
datasette, db, table, table_counts[table]
|
||||
),
|
||||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"foreign_keys": all_foreign_keys[table],
|
||||
"private": allowed_dict[table].private,
|
||||
}
|
||||
hidden=table in hidden_table_names,
|
||||
fts_table=await db.fts_table(table),
|
||||
foreign_keys=all_foreign_keys[table],
|
||||
private=allowed_dict[table].private,
|
||||
)
|
||||
)
|
||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
||||
tables.sort(key=lambda table: (table.hidden, table.name))
|
||||
return tables
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -309,6 +309,19 @@ To update these pages, run the following command::
|
|||
|
||||
uv run cog -r docs/*.rst
|
||||
|
||||
.. _contributing_template_contexts:
|
||||
|
||||
Documented template contexts
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Datasette's documented template contexts are part of the public API for custom templates. They are defined as dataclasses next to the view code that renders them, for example ``DatabaseContext`` and ``QueryContext`` in ``datasette/views/database.py``.
|
||||
|
||||
Every documented context class inherits from ``datasette.views.Context``. Fields that are added directly by view code should be declared as dataclass fields with ``help`` metadata, which is used to generate :ref:`template_context`. Fields resolved through the page extras system should use ``from_extra()`` so their documentation comes from the matching ``Extra`` class.
|
||||
|
||||
Use ``documented_template`` on each context class to record the canonical template named in the generated documentation. This should be a string such as ``"database.html"``. Runtime template selection still happens in the view code, since most pages consider more specific template names before falling back to the canonical one.
|
||||
|
||||
When a context field contains repeated structured data, prefer a small nested dataclass over an anonymous dictionary. For example, a field containing table summaries should be annotated as ``list[DatabaseTable]`` where ``DatabaseTable`` is a dataclass describing the keys and value types. This keeps the Python contract and generated documentation clear. JSON responses and ``?_context=1`` debug output will convert nested dataclasses back to JSON objects at the response boundary.
|
||||
|
||||
.. _contributing_continuous_deployment:
|
||||
|
||||
Continuously deployed demo instances
|
||||
|
|
|
|||
|
|
@ -145,8 +145,8 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re
|
|||
``table_columns`` - ``dict``
|
||||
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
|
||||
|
||||
``tables`` - ``list``
|
||||
List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
``tables`` - ``list[DatabaseTable]``
|
||||
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
|
||||
``top_database`` - ``callable``
|
||||
Async callable that renders the ``top_database`` plugin slot for this database and returns HTML.
|
||||
|
|
@ -234,8 +234,8 @@ The page for arbitrary SQL queries (/database/-/query?sql=...) and stored querie
|
|||
``table_columns`` - ``dict``
|
||||
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
|
||||
|
||||
``tables`` - ``list``
|
||||
List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
``tables`` - ``list[DatabaseTable]``
|
||||
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
|
||||
``top_query`` - ``callable``
|
||||
Async callable that renders the ``top_query`` plugin slot for this query and returns HTML.
|
||||
|
|
|
|||
|
|
@ -18,16 +18,22 @@ from datasette.views import Context
|
|||
|
||||
|
||||
def test_documented_fields():
|
||||
@dataclass
|
||||
class DemoNested:
|
||||
name: str
|
||||
|
||||
@dataclass
|
||||
class DemoContext(Context):
|
||||
name: str = field(metadata={"help": "The name"})
|
||||
_internal: str = field()
|
||||
count: int = field(metadata={"help": "How many there are"})
|
||||
items: list[DemoNested] = field(metadata={"help": "Nested items"})
|
||||
|
||||
fields = DemoContext.documented_fields()
|
||||
assert [(f.name, f.type_name, f.help) for f in fields] == [
|
||||
("name", "str", "The name"),
|
||||
("count", "int", "How many there are"),
|
||||
("items", "list[DemoNested]", "Nested items"),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -230,3 +236,7 @@ def test_template_context_docs_cover_every_documented_key():
|
|||
assert "``{}``".format(context_field.name) in docs, "{} ({} page)".format(
|
||||
context_field.name, page_name
|
||||
)
|
||||
assert (
|
||||
"``{}`` - ``{}``".format(context_field.name, context_field.type_name)
|
||||
in docs
|
||||
), "{} type ({} page)".format(context_field.name, page_name)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue