diff --git a/datasette/views/table.py b/datasette/views/table.py
index b07b62ae..afa05096 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -2,6 +2,8 @@ import asyncio
import itertools
import json
import urllib
+from dataclasses import dataclass
+from typing import Any
from asyncinject import Registry
import markupsafe
@@ -53,6 +55,29 @@ LINK_WITH_LABEL = (
LINK_WITH_VALUE = '{id}'
+@dataclass
+class ExtraInfo:
+ """Metadata about a table extra."""
+
+ description: str
+ example: Any = None
+ return_type: str = None
+
+
+def extra(description: str, example: Any = None, return_type: str = None):
+ """Decorator to document an extra function with structured metadata."""
+
+ def decorator(fn):
+ fn._extra_info = ExtraInfo(
+ description=description,
+ example=example,
+ return_type=return_type,
+ )
+ return fn
+
+ return decorator
+
+
class Row:
def __init__(self, cells):
self.cells = cells
@@ -1292,11 +1317,20 @@ async def table_view_data(
if extra_extras:
extras.update(extra_extras)
+ @extra(
+ description="SQL query used to calculate the total count",
+ example="select count(*) from my_table where status = ?",
+ return_type="string",
+ )
async def extra_count_sql():
return count_sql
+ @extra(
+ description="Total count of rows matching these filters",
+ example=1234,
+ return_type="integer or null",
+ )
async def extra_count():
- "Total count of rows matching these filters"
# Calculate the total count for this query
count = None
if (
@@ -1344,8 +1378,12 @@ async def table_view_data(
)
return facet_instances
+ @extra(
+ description="Results of facets calculated against this data",
+ example={"results": {"status": {"name": "status", "results": [{"value": "active", "count": 42}]}}, "timed_out": []},
+ return_type="object with results and timed_out keys",
+ )
async def extra_facet_results(facet_instances):
- "Results of facets calculated against this data"
facet_results = {}
facets_timed_out = []
@@ -1372,8 +1410,12 @@ async def table_view_data(
"timed_out": facets_timed_out,
}
+ @extra(
+ description="Suggestions for facets that might return interesting results",
+ example=[{"name": "status", "toggle_url": "?_facet=status"}],
+ return_type="array of objects",
+ )
async def extra_suggested_facets(facet_instances):
- "Suggestions for facets that might return interesting results"
suggested_facets = []
# Calculate suggested facets
if (
@@ -1396,8 +1438,12 @@ async def table_view_data(
raise BadRequest("_facet= is not allowed")
# human_description_en combines filters AND search, if provided
+ @extra(
+ description="Human-readable English description of the current filters",
+ example="status = active sorted by created descending",
+ return_type="string",
+ )
async def extra_human_description_en():
- "Human-readable description of the filters"
human_description_en = filters.human_description_en(
extra=extra_human_descriptions
)
@@ -1412,18 +1458,35 @@ async def table_view_data(
(sort or sort_desc), " descending" if sort_desc else ""
)
+ @extra(
+ description="Full URL for the next page of results",
+ example="/db/table.json?_next=eyJpZCI6IDQyfQ",
+ return_type="string or null",
+ )
async def extra_next_url():
- "Full URL for the next page of results"
return next_url
+ @extra(
+ description="Column names returned by this query",
+ example=["id", "name", "status"],
+ return_type="array of strings",
+ )
async def extra_columns():
- "Column names returned by this query"
return columns
+ @extra(
+ description="Primary key column names for this table",
+ example=["id"],
+ return_type="array of strings",
+ )
async def extra_primary_keys():
- "Primary keys for this table"
return pks
+ @extra(
+ description="Action links from the table_actions plugin hook",
+ example=[{"href": "/db/-/edit", "label": "Edit"}],
+ return_type="array of objects",
+ )
async def extra_actions():
async def actions():
links = []
@@ -1447,11 +1510,20 @@ async def table_view_data(
return actions
+ @extra(
+ description="Boolean indicating if this is a SQL view rather than a table",
+ example=False,
+ return_type="boolean",
+ )
async def extra_is_view():
return is_view
+ @extra(
+ description="Extra debug information about the request",
+ example={"resolved": "...", "url_vars": {"database": "db", "table": "t"}, "nofacet": False, "nosuggest": False},
+ return_type="object",
+ )
async def extra_debug():
- "Extra debug information"
return {
"resolved": repr(resolved),
"url_vars": request.url_vars,
@@ -1459,8 +1531,12 @@ async def table_view_data(
"nosuggest": nosuggest,
}
+ @extra(
+ description="Full information about the incoming HTTP request",
+ example={"url": "http://localhost/db/table.json", "path": "/db/table.json", "full_path": "/db/table.json?status=active", "host": "localhost", "args": {"status": ["active"]}},
+ return_type="object",
+ )
async def extra_request():
- "Full information about the request"
return {
"url": request.url,
"path": request.path,
@@ -1486,14 +1562,28 @@ async def table_view_data(
"rows": display_rows,
}
+ @extra(
+ description="Column metadata for HTML display, including sortability",
+ example=[{"name": "id", "sortable": True, "is_pk": True}],
+ return_type="array of objects",
+ )
async def extra_display_columns(run_display_columns_and_rows):
return run_display_columns_and_rows["columns"]
+ @extra(
+ description="Row data formatted for HTML display",
+ example=[[{"column": "id", "value": "1", "raw": 1}]],
+ return_type="array of arrays of cell objects",
+ )
async def extra_display_rows(run_display_columns_and_rows):
return run_display_columns_and_rows["rows"]
+ @extra(
+ description="Rendered HTML for each cell using the render_cell plugin hook",
+ example=[{"name": "Example"}],
+ return_type="array of objects mapping column names to HTML strings",
+ )
async def extra_render_cell():
- "Rendered HTML for each cell using the render_cell plugin hook"
columns = [col[0] for col in results.description]
rendered_rows = []
for row in rows:
@@ -1519,15 +1609,23 @@ async def table_view_data(
rendered_rows.append(rendered_row)
return rendered_rows
+ @extra(
+ description="Details of the underlying SQL query",
+ example={"sql": "select * from my_table where status = ?", "params": ["active"]},
+ return_type="object with sql and params keys",
+ )
async def extra_query():
- "Details of the underlying SQL query"
return {
"sql": sql,
"params": params,
}
+ @extra(
+ description="Metadata about the table and database from datasette.yaml",
+ example={"title": "My Table", "description": "A description", "columns": {"name": "Full name"}},
+ return_type="object",
+ )
async def extra_metadata():
- "Metadata about the table and database"
tablemetadata = await datasette.get_resource_metadata(database_name, table_name)
rows = await datasette.get_internal_database().execute(
@@ -1545,15 +1643,35 @@ async def table_view_data(
tablemetadata["columns"] = dict(rows)
return tablemetadata
+ @extra(
+ description="The name of the database",
+ example="my_database",
+ return_type="string",
+ )
async def extra_database():
return database_name
+ @extra(
+ description="The name of the table",
+ example="my_table",
+ return_type="string",
+ )
async def extra_table():
return table_name
+ @extra(
+ description="The computed color for this database",
+ example="#ff5733",
+ return_type="string",
+ )
async def extra_database_color():
return db.color
+ @extra(
+ description="Hidden form arguments for preserving state in HTML forms",
+ example=[["_size", "10"], ["_facet", "status"]],
+ return_type="array of [key, value] pairs",
+ )
async def extra_form_hidden_args():
form_hidden_args = []
for key in request.args:
@@ -1566,9 +1684,19 @@ async def table_view_data(
form_hidden_args.append((key, value))
return form_hidden_args
+ @extra(
+ description="The currently applied filters as a Filters object",
+ example={"status": "active"},
+ return_type="Filters object",
+ )
async def extra_filters():
return filters
+ @extra(
+ description="Custom template names that can be used for this table",
+ example=["_table-mydb-mytable.html", "_table-table-mydb-mytable.html", "_table.html"],
+ return_type="array of strings",
+ )
async def extra_custom_table_templates():
return [
f"_table-{to_css_class(database_name)}-{to_css_class(table_name)}.html",
@@ -1576,6 +1704,11 @@ async def table_view_data(
"_table.html",
]
+ @extra(
+ description="Facet results sorted by count descending",
+ example=[{"name": "status", "results": [{"value": "active", "count": 42}]}],
+ return_type="array of facet result objects",
+ )
async def extra_sorted_facet_results(extra_facet_results):
return sorted(
extra_facet_results["results"].values(),
@@ -1583,12 +1716,27 @@ async def table_view_data(
reverse=True,
)
+ @extra(
+ description="The SQL CREATE TABLE statement for this table",
+ example="CREATE TABLE my_table (id INTEGER PRIMARY KEY, name TEXT)",
+ return_type="string or null",
+ )
async def extra_table_definition():
return await db.get_table_definition(table_name)
+ @extra(
+ description="The SQL CREATE VIEW statement for this view",
+ example="CREATE VIEW my_view AS SELECT * FROM my_table",
+ return_type="string or null",
+ )
async def extra_view_definition():
return await db.get_view_definition(table_name)
+ @extra(
+ description="Available output renderers (JSON, CSV, etc.) with their URLs",
+ example={"json": "/db/table.json", "csv": "/db/table.csv"},
+ return_type="object mapping format names to URLs",
+ )
async def extra_renderers(extra_expandable_columns, extra_query):
renderers = {}
url_labels_extra = {}
@@ -1616,9 +1764,19 @@ async def table_view_data(
)
return renderers
+ @extra(
+ description="Boolean indicating if this table/view is private",
+ example=False,
+ return_type="boolean",
+ )
async def extra_private():
return private
+ @extra(
+ description="Foreign key columns that can be expanded to show labels",
+ example=[{"column": "author_id", "other_table": "authors"}, "name"],
+ return_type="array of [foreign_key_info, label_column] pairs",
+ )
async def extra_expandable_columns():
expandables = []
db = datasette.databases[database_name]
@@ -1627,30 +1785,39 @@ async def table_view_data(
expandables.append((fk, label_column))
return expandables
+ @extra(
+ description="List of available ?_extra= options with their metadata",
+ example=[{"name": "count", "description": "Total count of rows", "example": 1234, "return_type": "integer or null", "toggle_url": "?_extra=count", "selected": False}],
+ return_type="array of extra metadata objects",
+ )
async def extra_extras():
- "Available ?_extra= blocks"
- all_extras = [
- (key[len("extra_") :], fn.__doc__)
- for key, fn in registry._registry.items()
- if key.startswith("extra_")
- ]
- return [
- {
- "name": name,
- "description": doc,
- "toggle_url": datasette.absolute_url(
- request,
- datasette.urls.path(
- path_with_added_args(request, {"_extra": name})
- if name not in extras
- else path_with_removed_args(request, {"_extra": name})
+ all_extras = []
+ for key, fn in registry._registry.items():
+ if key.startswith("extra_"):
+ name = key[len("extra_"):]
+ info = getattr(fn, "_extra_info", None)
+ all_extras.append({
+ "name": name,
+ "description": info.description if info else fn.__doc__,
+ "example": info.example if info else None,
+ "return_type": info.return_type if info else None,
+ "toggle_url": datasette.absolute_url(
+ request,
+ datasette.urls.path(
+ path_with_added_args(request, {"_extra": name})
+ if name not in extras
+ else path_with_removed_args(request, {"_extra": name})
+ ),
),
- ),
- "selected": name in extras,
- }
- for name, doc in all_extras
- ]
+ "selected": name in extras,
+ })
+ return all_extras
+ @extra(
+ description="List of facet names that timed out during calculation",
+ example=["slow_column"],
+ return_type="array of strings",
+ )
async def extra_facets_timed_out(extra_facet_results):
return extra_facet_results["timed_out"]