Add row and query JSON extras

This commit is contained in:
Simon Willison 2026-06-09 02:56:27 -07:00
commit 4d6daa175a
9 changed files with 861 additions and 129 deletions

View file

@ -16,6 +16,8 @@ def extra_names_from_request(request):
class ExtraScope(Enum):
TABLE = "table"
ROW = "row"
QUERY = "query"
@dataclass(frozen=True)
@ -46,11 +48,16 @@ class Provider:
class Extra(Provider):
description: ClassVar[str | None] = None
example: ClassVar[ExtraExample | None] = None
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {}
public: ClassVar[bool] = True
stable: ClassVar[bool] = True
expensive: ClassVar[bool] = False
docs_note: ClassVar[str | None] = None
@classmethod
def example_for_scope(cls, scope):
return cls.examples.get(scope, cls.example)
@classmethod
def documentation(cls):
return {

View file

@ -11,6 +11,7 @@ import sqlite_utils
import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
@ -38,6 +39,11 @@ from datasette.plugins import pm
from .base import BaseView, DatasetteError, View, _error, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_extras import (
QueryExtraContext,
resolve_query_extras,
table_extra_registry,
)
from . import Context
@ -692,6 +698,34 @@ class QueryView(View):
except DatasetteError:
raise
extras = extra_names_from_request(request)
metadata = None
data = {"ok": True, "rows": rows, "columns": columns}
if extras:
metadata = await datasette.get_database_metadata(database)
if stored_query:
metadata = stored_query_to_dict(stored_query)
metadata.pop("source", None)
query_extra_context = QueryExtraContext(
datasette=datasette,
request=request,
db=db,
database_name=database,
private=private,
rows=rows,
columns=columns,
sql=sql,
params=params_for_query,
query_name=stored_query.name if stored_query else None,
stored_query=stored_query,
stored_query_write=stored_query_write,
error=query_error,
metadata=metadata,
extras=extras,
extra_registry=table_extra_registry,
)
data.update(await resolve_query_extras(extras, query_extra_context))
# Handle formats from plugins
if format_ == "csv":
if not sql:
@ -721,7 +755,7 @@ class QueryView(View):
error=query_error,
# These will be deprecated in Datasette 1.0:
args=request.args,
data={"ok": True, "rows": rows, "columns": columns},
data=data,
)
if asyncio.iscoroutine(result):
result = await result
@ -770,11 +804,11 @@ class QueryView(View):
)
}
)
metadata = await datasette.get_database_metadata(database)
if stored_query:
metadata = stored_query_to_dict(stored_query)
metadata.pop("source", None)
if metadata is None:
metadata = await datasette.get_database_metadata(database)
if stored_query:
metadata = stored_query_to_dict(stored_query)
metadata.pop("source", None)
renderers = {}
for key, (_, can_render) in datasette.renderers.items():
it_can_render = call_with_supported_arguments(

View file

@ -15,6 +15,7 @@ import json
import markupsafe
import sqlite_utils
from .table import display_columns_and_rows, _get_extras
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
class RowView(DataView):
@ -172,52 +173,26 @@ class RowView(DataView):
extras.add("foreign_key_tables")
# Process extras
if "foreign_key_tables" in extras:
data["foreign_key_tables"] = await self.foreign_key_tables(
database, table, pk_values
)
if "render_cell" in extras:
# Call render_cell plugin hook for each cell
ct_map = await self.ds.get_column_types(database, table)
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
ct = ct_map.get(column)
plugin_display_value = None
# Try column type render_cell first
if ct:
candidate = await ct.render_cell(
value=value,
column=column,
table=table,
database=database,
datasette=self.ds,
request=request,
)
if candidate is not None:
plugin_display_value = candidate
if plugin_display_value is None:
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table,
pks=resolved.pks,
database=database,
datasette=self.ds,
request=request,
column_type=ct,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
plugin_display_value = candidate
break
if plugin_display_value:
rendered_row[column] = str(plugin_display_value)
rendered_rows.append(rendered_row)
data["render_cell"] = rendered_rows
row_extra_context = RowExtraContext(
datasette=self.ds,
request=request,
resolved=resolved,
db=db,
database_name=database,
table_name=table,
private=private,
rows=rows,
columns=columns,
results_description=results.description,
pks=pks,
pk_values=pk_values,
sql=resolved.sql,
params=resolved.params,
extras=extras,
extra_registry=table_extra_registry,
foreign_key_tables=self.foreign_key_tables,
)
data.update(await resolve_row_extras(extras, row_extra_context))
return (
data,

View file

@ -8,6 +8,7 @@ from datasette.resources import TableResource
from datasette.utils import (
await_me_maybe,
call_with_supported_arguments,
named_parameters as derive_named_parameters,
path_with_added_args,
path_with_format,
path_with_removed_args,
@ -52,6 +53,50 @@ class TableExtraContext:
extra_registry: ExtraRegistry
display_columns_and_rows: object
run_sequential: object
scope: ExtraScope = ExtraScope.TABLE
@dataclass(frozen=True)
class RowExtraContext:
datasette: object
request: object
resolved: object
db: object
database_name: str
table_name: str
private: bool
rows: list
columns: list
results_description: list
pks: list
pk_values: list
sql: str
params: dict
extras: set
extra_registry: ExtraRegistry
foreign_key_tables: object
scope: ExtraScope = ExtraScope.ROW
@dataclass(frozen=True)
class QueryExtraContext:
datasette: object
request: object
db: object
database_name: str
private: bool
rows: list
columns: list
sql: str | None
params: dict
query_name: str | None
stored_query: object
stored_query_write: bool
error: str | None
metadata: dict
extras: set
extra_registry: ExtraRegistry
scope: ExtraScope = ExtraScope.QUERY
class CountSqlExtra(Extra):
@ -245,7 +290,15 @@ class NextUrlExtra(Extra):
class ColumnsExtra(Extra):
description = "Column names returned by this query"
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=columns"
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/-/query.json?sql=select+1+as+one&_extra=columns"
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
return context.columns
@ -263,7 +316,12 @@ class AllColumnsExtra(Extra):
class PrimaryKeysExtra(Extra):
description = "Primary keys for this table"
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=primary_keys"
)
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW})
async def resolve(self, context):
return context.pks
@ -309,21 +367,49 @@ class IsViewExtra(Extra):
class DebugExtra(Extra):
description = "Extra debug information"
example = ExtraExample("/fixtures/facetable.json?_extra=debug")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=debug"
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/-/query.json?sql=select+1+as+one&_extra=debug"
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
return {
"resolved": repr(context.resolved),
debug = {
"url_vars": context.request.url_vars,
"nofacet": context.nofacet,
"nosuggest": context.nosuggest,
}
if context.scope == ExtraScope.TABLE:
debug["resolved"] = repr(context.resolved)
elif context.scope == ExtraScope.ROW:
debug["resolved"] = {
"table": context.table_name,
"sql": context.sql,
"params": context.params,
"pks": context.pks,
"pk_values": context.pk_values,
}
if hasattr(context, "nofacet"):
debug["nofacet"] = context.nofacet
if hasattr(context, "nosuggest"):
debug["nosuggest"] = context.nosuggest
return debug
class RequestExtra(Extra):
description = "Full information about the request"
example = ExtraExample("/fixtures/facetable.json?_extra=request")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=request"
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/-/query.json?sql=select+1+as+one&_extra=request"
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
return {
@ -413,15 +499,48 @@ class RenderCellExtra(Extra):
"whose rendered value differs from the default are included."
),
)
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
value={
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
"render_cell": [{"content": "<strong>Custom rendered HTML</strong>"}],
},
note=(
"The ``render_cell`` array has one item for the requested row. "
"The object is keyed by column name. Only columns whose rendered "
"value differs from the default are included."
),
),
ExtraScope.QUERY: ExtraExample(
value={
"rows": [{"content": "RENDER_CELL_DEMO"}],
"render_cell": [{"content": "<strong>Custom rendered HTML</strong>"}],
},
note=(
"The ``render_cell`` array has one item per query result row, in "
"the same order as the ``rows`` array. Each object is keyed by "
"column name. Only columns whose rendered value differs from the "
"default are included."
),
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
table_name = getattr(context, "table_name", None)
is_view = getattr(context, "is_view", False)
pks = getattr(context, "pks", [])
pks_for_display = (
context.pks if context.pks else (["rowid"] if not context.is_view else [])
pks if pks else (["rowid"] if table_name and not is_view else [])
)
col_names = [col[0] for col in context.results_description]
ct_map = await context.datasette.get_column_types(
context.database_name, context.table_name
if hasattr(context, "results_description"):
col_names = [col[0] for col in context.results_description]
else:
col_names = context.columns
ct_map = (
await context.datasette.get_column_types(context.database_name, table_name)
if table_name
else {}
)
rendered_rows = []
for row in context.rows:
@ -433,7 +552,7 @@ class RenderCellExtra(Extra):
candidate = await ct.render_cell(
value=value,
column=column,
table=context.table_name,
table=table_name,
database=context.database_name,
datasette=context.datasette,
request=context.request,
@ -445,7 +564,7 @@ class RenderCellExtra(Extra):
row=row,
value=value,
column=column,
table=context.table_name,
table=table_name,
pks=pks_for_display,
database=context.database_name,
datasette=context.datasette,
@ -465,19 +584,36 @@ class RenderCellExtra(Extra):
class QueryExtra(Extra):
description = "Details of the underlying SQL query"
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=query"
),
ExtraScope.QUERY: [
ExtraExample("/fixtures/-/query.json?sql=select+1+as+one&_extra=query"),
ExtraExample("/fixtures/neighborhood_search.json?text=town&_extra=query"),
],
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
params = context.params
if context.scope == ExtraScope.QUERY and context.sql:
parameter_names = set(derive_named_parameters(context.sql))
params = {
key: value
for key, value in dict(context.params).items()
if key in parameter_names
}
return {
"sql": context.sql,
"params": context.params,
"params": params,
}
class ColumnTypesExtra(Extra):
description = "Column type assignments for this table"
example = ExtraExample(value={})
scopes = frozenset({ExtraScope.TABLE})
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW})
async def resolve(self, context):
ct_map = await context.datasette.get_column_types(
@ -544,11 +680,22 @@ class SetColumnTypeUiExtra(Extra):
class MetadataExtra(Extra):
description = "Metadata about the table and database"
description = "Metadata about the table, database or stored query"
example = ExtraExample("/fixtures/facetable.json?_extra=metadata")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=metadata"
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/neighborhood_search.json?text=town&_extra=metadata"
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
if context.scope == ExtraScope.QUERY:
return context.metadata
tablemetadata = await context.datasette.get_resource_metadata(
context.database_name, context.table_name
)
@ -572,7 +719,15 @@ class MetadataExtra(Extra):
class DatabaseExtra(Extra):
description = "Database name"
example = ExtraExample("/fixtures/facetable.json?_extra=database")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=database"
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database"
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
return context.database_name
@ -581,7 +736,10 @@ class DatabaseExtra(Extra):
class TableExtra(Extra):
description = "Table name"
example = ExtraExample("/fixtures/facetable.json?_extra=table")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table")
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW})
async def resolve(self, context):
return context.table_name
@ -590,7 +748,15 @@ class TableExtra(Extra):
class DatabaseColorExtra(Extra):
description = "Color assigned to the database"
example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=database_color"
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database_color"
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
return context.db.color
@ -703,6 +869,8 @@ class RenderersExtra(Extra):
url_labels_extra = {}
if expandable_columns:
url_labels_extra = {"_labels": "on"}
table_name = getattr(context, "table_name", None)
view_name = "table" if context.scope == ExtraScope.TABLE else "database"
for key, (_, can_render) in context.datasette.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
@ -710,11 +878,11 @@ class RenderersExtra(Extra):
columns=context.columns or [],
rows=context.rows or [],
sql=query.get("sql", None),
query_name=None,
query_name=getattr(context, "query_name", None),
database=context.database_name,
table=context.table_name,
table=table_name,
request=context.request,
view_name="table",
view_name=view_name,
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
@ -730,9 +898,17 @@ class RenderersExtra(Extra):
class PrivateExtra(Extra):
description = "Whether this table is private to the current actor"
description = "Whether this resource is private to the current actor"
example = ExtraExample("/fixtures/facetable.json?_extra=private")
scopes = frozenset({ExtraScope.TABLE})
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=private"
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private"
),
}
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
return context.private
@ -752,14 +928,27 @@ class ExpandableColumnsExtra(Extra):
return expandables
class ForeignKeyTablesExtra(Extra):
description = "Tables that link to this row using foreign keys"
example = ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables"
)
scopes = frozenset({ExtraScope.ROW})
async def resolve(self, context):
return await context.foreign_key_tables(
context.database_name, context.table_name, context.pk_values
)
class ExtrasExtra(Extra):
description = "Available ?_extra= blocks"
scopes = frozenset({ExtraScope.TABLE})
scopes = frozenset({ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY})
async def resolve(self, context):
all_extras = [
(cls.key(), cls.description)
for cls in context.extra_registry.public_classes_for_scope(ExtraScope.TABLE)
for cls in context.extra_registry.public_classes_for_scope(context.scope)
]
return [
{
@ -850,6 +1039,7 @@ TABLE_EXTRA_CLASSES = [
IsViewExtra,
PrivateExtra,
ExpandableColumnsExtra,
ForeignKeyTablesExtra,
FormHiddenArgsExtra,
]
@ -859,3 +1049,11 @@ table_extra_registry = ExtraRegistry(TABLE_EXTRA_CLASSES)
async def resolve_table_extras(extras, context):
return await table_extra_registry.resolve(extras, context, ExtraScope.TABLE)
async def resolve_row_extras(extras, context):
return await table_extra_registry.resolve(extras, context, ExtraScope.ROW)
async def resolve_query_extras(extras, context):
return await table_extra_registry.resolve(extras, context, ExtraScope.QUERY)

View file

@ -237,23 +237,26 @@ query string arguments:
.. _json_api_extra:
Expanding table JSON responses
------------------------------
Expanding JSON responses
------------------------
Table JSON responses can be expanded with one or more ``?_extra=`` parameters.
Table, row and query JSON responses can be expanded with one or more ``?_extra=`` parameters.
These can be repeated or comma-separated:
::
?_extra=columns&_extra=count,next_url
The available table extras are listed below.
.. [[[cog
from json_api_doc import table_extras
table_extras(cog)
.. ]]]
Table JSON responses
~~~~~~~~~~~~~~~~~~~~
The available table extras are listed below.
``count``
Total count of rows matching these filters (May execute additional queries.)
@ -459,12 +462,12 @@ The available table extras are listed below.
.. code-block:: json
{
"resolved": "ResolvedTable(db=<Database: fixtures (mutable, size=249856)>, table='facetable', is_view=False)",
"url_vars": {
"database": "fixtures",
"table": "facetable",
"format": "json"
},
"resolved": "ResolvedTable(db=<Database: fixtures (mutable, size=249856)>, table='facetable', is_view=False)",
"nofacet": null,
"nosuggest": null
}
@ -511,7 +514,7 @@ The available table extras are listed below.
Column type UI metadata for this table
``metadata``
Metadata about the table and database
Metadata about the table, database or stored query
``GET /fixtures/facetable.json?_extra=metadata``
@ -649,7 +652,7 @@ The available table extras are listed below.
true
``private``
Whether this table is private to the current actor
Whether this resource is private to the current actor
``GET /fixtures/facetable.json?_extra=private``
@ -697,6 +700,373 @@ The available table extras are listed below.
]
]
Row JSON responses
~~~~~~~~~~~~~~~~~~
The following extras are available for row JSON responses.
``columns``
Column names returned by this query
``GET /fixtures/simple_primary_key/1.json?_extra=columns``
.. code-block:: json
[
"id",
"content"
]
``primary_keys``
Primary keys for this table
``GET /fixtures/simple_primary_key/1.json?_extra=primary_keys``
.. code-block:: json
[
"id"
]
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook
The ``render_cell`` array has one item for the requested row. The object is keyed by column name. Only columns whose rendered value differs from the default are included.
.. code-block:: json
{
"rows": [
{
"id": 4,
"content": "RENDER_CELL_DEMO"
}
],
"render_cell": [
{
"content": "<strong>Custom rendered HTML</strong>"
}
]
}
``debug``
Extra debug information
``GET /fixtures/simple_primary_key/1.json?_extra=debug``
.. code-block:: json
{
"url_vars": {
"database": "fixtures",
"table": "simple_primary_key",
"pks": "1",
"format": "json"
},
"resolved": {
"table": "simple_primary_key",
"sql": "select * from simple_primary_key where \"id\"=:p0",
"params": {
"p0": "1"
},
"pks": [
"id"
],
"pk_values": [
"1"
]
}
}
``request``
Full information about the request
``GET /fixtures/simple_primary_key/1.json?_extra=request``
.. code-block:: json
{
"url": "http://localhost/fixtures/simple_primary_key/1.json?_extra=request",
"path": "/fixtures/simple_primary_key/1.json",
"full_path": "/fixtures/simple_primary_key/1.json?_extra=request",
"host": "localhost",
"args": {
"_extra": [
"request"
]
}
}
``query``
Details of the underlying SQL query
``GET /fixtures/simple_primary_key/1.json?_extra=query``
.. code-block:: json
{
"sql": "select * from simple_primary_key where \"id\"=:p0",
"params": {
"p0": "1"
}
}
``column_types``
Column type assignments for this table
.. code-block:: json
{}
``metadata``
Metadata about the table, database or stored query
``GET /fixtures/simple_primary_key/1.json?_extra=metadata``
.. code-block:: json
{
"columns": {}
}
``extras``
Available ?_extra= blocks
``database``
Database name
``GET /fixtures/simple_primary_key/1.json?_extra=database``
.. code-block:: json
"fixtures"
``table``
Table name
``GET /fixtures/simple_primary_key/1.json?_extra=table``
.. code-block:: json
"simple_primary_key"
``database_color``
Color assigned to the database
``GET /fixtures/simple_primary_key/1.json?_extra=database_color``
.. code-block:: json
"9403e5"
``private``
Whether this resource is private to the current actor
``GET /fixtures/simple_primary_key/1.json?_extra=private``
.. code-block:: json
false
``foreign_key_tables``
Tables that link to this row using foreign keys
``GET /fixtures/simple_primary_key/1.json?_extra=foreign_key_tables``
.. code-block:: json
[
{
"other_table": "complex_foreign_keys",
"column": "id",
"other_column": "f1",
"count": 1,
"link": "/fixtures/complex_foreign_keys?f1=1"
},
{
"other_table": "complex_foreign_keys",
"column": "id",
"other_column": "f2",
"count": 0,
"link": "/fixtures/complex_foreign_keys?f2=1"
},
{
"other_table": "complex_foreign_keys",
"column": "id",
"other_column": "f3",
"count": 1,
"link": "/fixtures/complex_foreign_keys?f3=1"
},
{
"other_table": "foreign_key_references",
"column": "id",
"other_column": "foreign_key_with_blank_label",
"count": 0,
"link": "/fixtures/foreign_key_references?foreign_key_with_blank_label=1"
},
{
"other_table": "foreign_key_references",
"column": "id",
"other_column": "foreign_key_with_label",
"count": 1,
"link": "/fixtures/foreign_key_references?foreign_key_with_label=1"
}
]
Query JSON responses
~~~~~~~~~~~~~~~~~~~~
The following extras are available for arbitrary SQL query responses and stored, named query responses.
``columns``
Column names returned by this query
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=columns``
.. code-block:: json
[
"one"
]
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook
The ``render_cell`` array has one item per query result row, in the same order as the ``rows`` array. Each object is keyed by column name. Only columns whose rendered value differs from the default are included.
.. code-block:: json
{
"rows": [
{
"content": "RENDER_CELL_DEMO"
}
],
"render_cell": [
{
"content": "<strong>Custom rendered HTML</strong>"
}
]
}
``debug``
Extra debug information
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=debug``
.. code-block:: json
{
"url_vars": {
"database": "fixtures",
"format": "json"
}
}
``request``
Full information about the request
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=request``
.. code-block:: json
{
"url": "http://localhost/fixtures/-/query.json?sql=select+1+as+one&_extra=request",
"path": "/fixtures/-/query.json",
"full_path": "/fixtures/-/query.json?sql=select+1+as+one&_extra=request",
"host": "localhost",
"args": {
"sql": [
"select 1 as one"
],
"_extra": [
"request"
]
}
}
``query``
Details of the underlying SQL query
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=query``
.. code-block:: json
{
"sql": "select 1 as one",
"params": {}
}
``GET /fixtures/neighborhood_search.json?text=town&_extra=query``
.. code-block:: json
{
"sql": "\nselect _neighborhood, facet_cities.name, state\nfrom facetable\n join facet_cities\n on facetable._city_id = facet_cities.id\nwhere _neighborhood like '%' || :text || '%'\norder by _neighborhood;\n",
"params": {
"text": "town"
}
}
``metadata``
Metadata about the table, database or stored query
``GET /fixtures/neighborhood_search.json?text=town&_extra=metadata``
.. code-block:: json
{
"database": "fixtures",
"name": "neighborhood_search",
"sql": "\nselect _neighborhood, facet_cities.name, state\nfrom facetable\n join facet_cities\n on facetable._city_id = facet_cities.id\nwhere _neighborhood like '%' || :text || '%'\norder by _neighborhood;\n",
"title": "Search neighborhoods",
"description": null,
"description_html": null,
"hide_sql": false,
"fragment": null,
"params": [],
"parameters": [],
"is_write": false,
"is_private": false,
"is_trusted": true,
"owner_id": null,
"on_success_message": null,
"on_success_message_sql": null,
"on_success_redirect": null,
"on_error_message": null,
"on_error_redirect": null
}
``extras``
Available ?_extra= blocks
``database``
Database name
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=database``
.. code-block:: json
"fixtures"
``database_color``
Color assigned to the database
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=database_color``
.. code-block:: json
"9403e5"
``private``
Whether this resource is private to the current actor
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=private``
.. code-block:: json
false
.. [[[end]]]
.. _table_arguments:

View file

@ -9,39 +9,80 @@ def table_extras(cog):
from datasette.extras import ExtraScope
from datasette.views.table_extras import table_extra_registry
classes = table_extra_registry.public_classes_for_scope(ExtraScope.TABLE)
scopes = [
(
ExtraScope.TABLE,
"Table JSON responses",
"The available table extras are listed below.",
),
(
ExtraScope.ROW,
"Row JSON responses",
"The following extras are available for row JSON responses.",
),
(
ExtraScope.QUERY,
"Query JSON responses",
(
"The following extras are available for arbitrary SQL query "
"responses and stored, named query responses."
),
),
]
classes_by_scope = [
(scope, heading, intro, table_extra_registry.public_classes_for_scope(scope))
for scope, heading, intro in scopes
]
live_examples = asyncio.run(_fetch_live_examples(classes))
live_examples = asyncio.run(
_fetch_live_examples(
[
(scope, cls)
for scope, _, _, classes in classes_by_scope
for cls in classes
]
)
)
cog.out("\n")
for cls in classes:
example = cls.example
description = cls.description or ""
notes = []
if cls.expensive:
notes.append("May execute additional queries.")
if cls.docs_note:
notes.append(cls.docs_note)
if notes:
description = "{} ({})".format(description, " ".join(notes)).strip()
for scope, heading, intro, classes in classes_by_scope:
cog.out("{}\n{}\n\n".format(heading, "~" * len(heading)))
cog.out("{}\n\n".format(intro))
for cls in classes:
examples = _examples_for_scope(cls, scope)
description = cls.description or ""
notes = []
if cls.expensive:
notes.append("May execute additional queries.")
if cls.docs_note:
notes.append(cls.docs_note)
if notes:
description = "{} ({})".format(description, " ".join(notes)).strip()
cog.out("``{}``\n".format(cls.key()))
cog.out(" {}\n\n".format(description))
if example is None:
continue
if example.path:
value = live_examples[(example.path, example.key or cls.key())]
cog.out(" ``GET {}``\n\n".format(example.path))
else:
value = example.value
if example.note:
cog.out(" {}\n\n".format(example.note))
cog.out(" .. code-block:: json\n\n")
cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
cog.out("\n\n")
cog.out("``{}``\n".format(cls.key()))
cog.out(" {}\n\n".format(description))
for example in examples:
if example.path:
value = live_examples[(example.path, example.key or cls.key())]
cog.out(" ``GET {}``\n\n".format(example.path))
else:
value = example.value
if example.note:
cog.out(" {}\n\n".format(example.note))
cog.out(" .. code-block:: json\n\n")
cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
cog.out("\n\n")
async def _fetch_live_examples(classes):
def _examples_for_scope(cls, scope):
examples = cls.example_for_scope(scope)
if examples is None:
return []
if isinstance(examples, list):
return examples
return [examples]
async def _fetch_live_examples(scoped_classes):
from datasette.app import Datasette
from datasette.fixtures import write_fixture_database
@ -49,18 +90,40 @@ async def _fetch_live_examples(classes):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = pathlib.Path(tmpdir) / "fixtures.db"
write_fixture_database(db_path)
datasette = Datasette([str(db_path)], settings={"num_sql_threads": 1})
datasette = Datasette(
[str(db_path)],
settings={"num_sql_threads": 1},
config={
"databases": {
"fixtures": {
"queries": {
"neighborhood_search": {
"sql": textwrap.dedent("""
select _neighborhood, facet_cities.name, state
from facetable
join facet_cities
on facetable._city_id = facet_cities.id
where _neighborhood like '%' || :text || '%'
order by _neighborhood;
"""),
"title": "Search neighborhoods",
}
}
}
}
},
)
try:
for cls in classes:
example = cls.example
if example is None or not example.path:
continue
key = example.key or cls.key()
response = await datasette.client.get(example.path)
assert response.status_code == 200, example.path
data = response.json()
assert key in data, "{} missing from {}".format(key, example.path)
examples[(example.path, key)] = data[key]
for scope, cls in scoped_classes:
for example in _examples_for_scope(cls, scope):
if not example.path:
continue
key = example.key or cls.key()
response = await datasette.client.get(example.path)
assert response.status_code == 200, example.path
data = response.json()
assert key in data, "{} missing from {}".format(key, example.path)
examples[(example.path, key)] = data[key]
finally:
for db in datasette.databases.values():
if not db.is_memory:

View file

@ -426,6 +426,28 @@ async def test_row_foreign_key_tables(ds_client):
]
@pytest.mark.asyncio
async def test_row_extras(ds_client):
response = await ds_client.get(
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables"
)
assert response.status_code == 200
data = response.json()
assert data["database"] == "fixtures"
assert data["table"] == "simple_primary_key"
assert data["primary_keys"] == ["id"]
assert data["query"]["sql"] == 'select * from simple_primary_key where "id"=:p0'
assert data["query"]["params"] == {"p0": "1"}
assert data["request"]["path"] == "/fixtures/simple_primary_key/1.json"
assert data["debug"]["url_vars"] == {
"database": "fixtures",
"table": "simple_primary_key",
"pks": "1",
"format": "json",
}
assert len(data["foreign_key_tables"]) == 5
@pytest.mark.asyncio
async def test_row_extra_render_cell():
"""Test that _extra=render_cell returns rendered HTML from render_cell plugin hook on row pages"""

View file

@ -132,7 +132,7 @@ def test_render_cell_extra_example_explains_row_and_column_mapping():
def test_debug_and_request_extra_examples_are_documented():
content = (docs_path / "json_api.rst").read_text()
section = content.split(".. _json_api_extra:")[-1].split(".. _table_arguments:")[0]
section = content.split("Table JSON responses")[-1].split("Row JSON responses")[0]
debug_section = section.split("``debug``")[-1].split("``request``")[0]
assert "GET /fixtures/facetable.json?_extra=debug" in debug_section
@ -143,6 +143,20 @@ def test_debug_and_request_extra_examples_are_documented():
assert '"full_path":' in request_section
def test_row_and_query_extra_sections_are_documented():
content = (docs_path / "json_api.rst").read_text()
assert "Row JSON responses" in content
assert (
"``GET /fixtures/simple_primary_key/1.json?_extra=foreign_key_tables``"
in content
)
assert "Query JSON responses" in content
assert "``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=query``" in content
assert (
"``GET /fixtures/neighborhood_search.json?text=town&_extra=query``" in content
)
@pytest.fixture(scope="session")
def documented_labels():
labels = set()

View file

@ -68,6 +68,55 @@ async def test_table_shape_arrayfirst(ds_client):
]
@pytest.mark.asyncio
async def test_query_extras_for_arbitrary_sql(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?"
+ urllib.parse.urlencode(
{
"sql": "select 1 as one",
"_extra": "columns,database,query,request,debug",
}
)
)
assert response.status_code == 200
data = response.json()
assert data["rows"] == [{"one": 1}]
assert data["columns"] == ["one"]
assert data["database"] == "fixtures"
assert data["query"]["sql"] == "select 1 as one"
assert data["request"]["path"] == "/fixtures/-/query.json"
assert data["debug"]["url_vars"] == {
"database": "fixtures",
"format": "json",
}
@pytest.mark.asyncio
async def test_query_extras_for_stored_query(ds_client):
response = await ds_client.get(
"/fixtures/neighborhood_search.json?"
+ urllib.parse.urlencode(
{
"text": "town",
"_extra": "columns,database,query,request,debug",
}
)
)
assert response.status_code == 200
data = response.json()
assert data["columns"] == ["_neighborhood", "name", "state"]
assert data["database"] == "fixtures"
assert data["query"]["sql"].strip().startswith("select _neighborhood")
assert data["query"]["params"]["text"] == "town"
assert data["request"]["path"] == "/fixtures/neighborhood_search.json"
assert data["debug"]["url_vars"] == {
"database": "fixtures",
"table": "neighborhood_search",
"format": "json",
}
@pytest.mark.asyncio
async def test_table_shape_objects(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key.json?_shape=objects")