Use dataclasses for database table context

This commit is contained in:
Simon Willison 2026-06-23 12:11:26 -07:00
commit 34d9a3bf33
6 changed files with 95 additions and 27 deletions

View file

@ -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)

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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)