diff --git a/datasette/extras.py b/datasette/extras.py
index d867f26c..f655e517 100644
--- a/datasette/extras.py
+++ b/datasette/extras.py
@@ -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 {
diff --git a/datasette/views/database.py b/datasette/views/database.py
index a1647ca9..96a58758 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -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(
diff --git a/datasette/views/row.py b/datasette/views/row.py
index 4eacfe49..3fe213d7 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -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,
diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py
index e888ee9f..ec104be3 100644
--- a/datasette/views/table_extras.py
+++ b/datasette/views/table_extras.py
@@ -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": "Custom rendered HTML"}],
+ },
+ 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": "Custom rendered HTML"}],
+ },
+ 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)
diff --git a/docs/json_api.rst b/docs/json_api.rst
index d418d16c..379d26a0 100644
--- a/docs/json_api.rst
+++ b/docs/json_api.rst
@@ -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=, table='facetable', is_view=False)",
"url_vars": {
"database": "fixtures",
"table": "facetable",
"format": "json"
},
+ "resolved": "ResolvedTable(db=, 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": "Custom rendered HTML"
+ }
+ ]
+ }
+
+``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": "Custom rendered HTML"
+ }
+ ]
+ }
+
+``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:
diff --git a/docs/json_api_doc.py b/docs/json_api_doc.py
index 69ec6e5e..44ef4a42 100644
--- a/docs/json_api_doc.py
+++ b/docs/json_api_doc.py
@@ -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:
diff --git a/tests/test_api.py b/tests/test_api.py
index f6187529..e1385b6f 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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"""
diff --git a/tests/test_docs.py b/tests/test_docs.py
index 3aa67730..13b3a549 100644
--- a/tests/test_docs.py
+++ b/tests/test_docs.py
@@ -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()
diff --git a/tests/test_table_api.py b/tests/test_table_api.py
index eeb3dc8b..388e3979 100644
--- a/tests/test_table_api.py
+++ b/tests/test_table_api.py
@@ -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")