Refactor table JSON extras into classes

This commit is contained in:
Simon Willison 2026-06-08 20:52:10 -07:00
commit 17bbe6855c
3 changed files with 884 additions and 540 deletions

View file

@ -1,6 +1,100 @@
import re
from enum import Enum
from typing import ClassVar
from asyncinject import Registry
def extra_names_from_request(request):
extra_bits = request.args.getlist("_extra")
extras = set()
for bit in extra_bits:
extras.update(part for part in bit.split(",") if part)
return extras
class ExtraScope(Enum):
TABLE = "table"
class Provider:
name: ClassVar[str | None] = None
scopes: ClassVar[frozenset[ExtraScope]] = frozenset()
public: ClassVar[bool] = False
@classmethod
def key(cls):
return cls.name or _camel_to_snake(cls.__name__)
@classmethod
def available_for(cls, scope):
return scope in cls.scopes
async def resolve(self, context):
raise NotImplementedError
class Extra(Provider):
description: ClassVar[str | None] = None
public: ClassVar[bool] = True
stable: ClassVar[bool] = True
expensive: ClassVar[bool] = False
docs_note: ClassVar[str | None] = None
@classmethod
def documentation(cls):
return {
"name": cls.key(),
"description": cls.description,
"scopes": [
scope.value for scope in sorted(cls.scopes, key=lambda s: s.value)
],
"stable": cls.stable,
"expensive": cls.expensive,
"docs_note": cls.docs_note,
}
class ExtraRegistry:
def __init__(self, classes):
self.classes = list(classes)
self.classes_by_name = {cls.key(): cls for cls in self.classes}
def classes_for_scope(self, scope, include_internal=True):
classes = [
cls
for cls in self.classes
if cls.available_for(scope) and (include_internal or cls.public)
]
return classes
def public_classes_for_scope(self, scope):
return self.classes_for_scope(scope, include_internal=False)
async def resolve(self, requested, context, scope):
registry = Registry()
async def context_provider():
return context
registry.register(context_provider, name="context")
for cls in self.classes_for_scope(scope):
registry.register(cls().resolve, name=cls.key())
public_names = {cls.key() for cls in self.public_classes_for_scope(scope)}
requested_public_names = [
name
for name in requested
if name in public_names and name in registry._registry
]
resolved = await registry.resolve_multi(requested_public_names)
return {
name: resolved[name] for name in requested_public_names if name in resolved
}
def _camel_to_snake(name):
name = re.sub(r"(Extra|Provider)$", "", name)
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()

View file

@ -3,12 +3,10 @@ import itertools
import json
import urllib
from asyncinject import Registry
import markupsafe
from datasette.extras import extra_names_from_request
from datasette.plugins import pm
from datasette.database import QueryInterrupted
from datasette.events import (
AlterTableEvent,
DropTableEvent,
@ -47,6 +45,12 @@ from datasette.filters import Filters
import sqlite_utils
from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView
from .table_extras import (
TABLE_EXTRA_BUNDLES,
TableExtraContext,
resolve_table_extras,
table_extra_registry,
)
LINK_WITH_LABEL = (
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a>&nbsp;<em>{id}</em>'
@ -1465,560 +1469,60 @@ async def table_view_data(
if extra_extras:
extras.update(extra_extras)
async def extra_count_sql():
return count_sql
async def extra_count():
"Total count of rows matching these filters"
# Calculate the total count for this query
count = None
if (
not db.is_mutable
and datasette.inspect_data
and count_sql == f"select count(*) from {table_name} "
):
# We can use a previously cached table row count
try:
count = datasette.inspect_data[database_name]["tables"][table_name][
"count"
]
except KeyError:
pass
# Otherwise run a select count(*) ...
if count_sql and count is None and not nocount:
count_sql_limited = (
f"select count(*) from (select * {from_sql} limit 10001)"
)
try:
count_rows = list(await db.execute(count_sql_limited, from_sql_params))
count = count_rows[0][0]
except QueryInterrupted:
pass
return count
async def facet_instances(extra_count):
facet_instances = []
facet_classes = list(
itertools.chain.from_iterable(pm.hook.register_facet_classes())
)
for facet_class in facet_classes:
facet_instances.append(
facet_class(
datasette,
request,
database_name,
sql=sql_no_order_no_limit,
params=params,
table=table_name,
table_config=table_metadata,
row_count=extra_count,
)
)
return facet_instances
async def extra_facet_results(facet_instances):
"Results of facets calculated against this data"
facet_results = {}
facets_timed_out = []
if not nofacet:
# Run them in parallel
facet_awaitables = [facet.facet_results() for facet in facet_instances]
facet_awaitable_results = await run_sequential(*facet_awaitables)
for (
instance_facet_results,
instance_facets_timed_out,
) in facet_awaitable_results:
for facet_info in instance_facet_results:
base_key = facet_info["name"]
key = base_key
i = 1
while key in facet_results:
i += 1
key = f"{base_key}_{i}"
facet_results[key] = facet_info
facets_timed_out.extend(instance_facets_timed_out)
return {
"results": facet_results,
"timed_out": facets_timed_out,
}
async def extra_suggested_facets(facet_instances):
"Suggestions for facets that might return interesting results"
suggested_facets = []
# Calculate suggested facets
if (
datasette.setting("suggest_facets")
and datasette.setting("allow_facet")
and not _next
and not nofacet
and not nosuggest
):
# Run them in parallel
facet_suggest_awaitables = [facet.suggest() for facet in facet_instances]
for suggest_result in await run_sequential(*facet_suggest_awaitables):
suggested_facets.extend(suggest_result)
return suggested_facets
# Faceting
if not datasette.setting("allow_facet") and any(
arg.startswith("_facet") for arg in request.args
):
raise BadRequest("_facet= is not allowed")
# human_description_en combines filters AND search, if provided
async def extra_human_description_en():
"Human-readable description of the filters"
human_description_en = filters.human_description_en(
extra=extra_human_descriptions
)
if sort or sort_desc:
human_description_en = " ".join(
[b for b in [human_description_en, sorted_by] if b]
)
return human_description_en
if sort or sort_desc:
sorted_by = "sorted by {}{}".format(
(sort or sort_desc), " descending" if sort_desc else ""
)
async def extra_next_url():
"Full URL for the next page of results"
return next_url
async def extra_columns():
"Column names returned by this query"
return columns
async def extra_all_columns():
"All columns in the table, regardless of _col/_nocol filtering"
return list(table_columns)
async def extra_primary_keys():
"Primary keys for this table"
return pks
async def extra_actions():
async def actions():
links = []
kwargs = {
"datasette": datasette,
"database": database_name,
"actor": request.actor,
"request": request,
}
if is_view:
kwargs["view"] = table_name
method = pm.hook.view_actions
else:
kwargs["table"] = table_name
method = pm.hook.table_actions
for hook in method(**kwargs):
extra_links = await await_me_maybe(hook)
if extra_links:
links.extend(extra_links)
return links
return actions
async def extra_is_view():
return is_view
async def extra_debug():
"Extra debug information"
return {
"resolved": repr(resolved),
"url_vars": request.url_vars,
"nofacet": nofacet,
"nosuggest": nosuggest,
}
async def extra_request():
"Full information about the request"
return {
"url": request.url,
"path": request.path,
"full_path": request.full_path,
"host": request.host,
"args": request.args._data,
}
async def run_display_columns_and_rows():
display_columns, display_rows = await display_columns_and_rows(
datasette,
database_name,
table_name,
results.description,
rows,
link_column=not is_view,
truncate_cells=datasette.setting("truncate_cells_html"),
sortable_columns=sortable_columns,
request=request,
)
return {
"columns": display_columns,
"rows": display_rows,
}
async def extra_display_columns(run_display_columns_and_rows):
return run_display_columns_and_rows["columns"]
async def extra_display_rows(run_display_columns_and_rows):
return run_display_columns_and_rows["rows"]
async def extra_render_cell():
"Rendered HTML for each cell using the render_cell plugin hook"
pks_for_display = pks if pks else (["rowid"] if not is_view else [])
col_names = [col[0] for col in results.description]
ct_map = await datasette.get_column_types(database_name, table_name)
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, col_names):
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_name,
database=database_name,
datasette=datasette,
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_name,
pks=pks_for_display,
database=database_name,
datasette=datasette,
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)
return rendered_rows
async def extra_query():
"Details of the underlying SQL query"
return {
"sql": sql,
"params": params,
}
async def extra_column_types():
"Column type assignments for this table"
ct_map = await datasette.get_column_types(database_name, table_name)
return {
col_name: {
"type": ct.name,
"config": ct.config,
}
for col_name, ct in ct_map.items()
}
async def extra_set_column_type_ui():
"Column type UI metadata for this table"
if is_view:
return None
if not await datasette.allowed(
action="set-column-type",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return None
column_details = await datasette._get_resource_column_details(
database_name, table_name
)
ct_map = await datasette.get_column_types(database_name, table_name)
columns = {}
for column_name, column_detail in column_details.items():
current = ct_map.get(column_name)
columns[column_name] = {
"current": (
{"type": current.name, "config": current.config}
if current is not None
else None
),
"options": [
{
"name": name,
"description": ct_cls.description,
}
for name, ct_cls in sorted(datasette._column_types.items())
if datasette._column_type_is_applicable(ct_cls, column_detail)
],
}
return {
"path": "{}/-/set-column-type".format(
datasette.urls.table(database_name, table_name)
),
"columns": columns,
}
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(
"""
SELECT
column_name,
value
FROM metadata_columns
WHERE database_name = ?
AND resource_name = ?
AND key = 'description'
""",
[database_name, table_name],
)
tablemetadata["columns"] = dict(rows)
return tablemetadata
async def extra_database():
return database_name
async def extra_table():
return table_name
async def extra_database_color():
return db.color
async def extra_form_hidden_args():
form_hidden_args = []
for key in request.args:
if (
key.startswith("_")
and key not in ("_sort", "_sort_desc", "_search", "_next")
and "__" not in key
):
for value in request.args.getlist(key):
form_hidden_args.append((key, value))
return form_hidden_args
async def extra_filters():
return filters
async def extra_custom_table_templates():
return [
f"_table-{to_css_class(database_name)}-{to_css_class(table_name)}.html",
f"_table-table-{to_css_class(database_name)}-{to_css_class(table_name)}.html",
"_table.html",
]
async def extra_sorted_facet_results(extra_facet_results):
facet_configs = table_metadata.get("facets", [])
if facet_configs:
# Build ordered list of facet names from metadata config
metadata_facet_names = []
for fc in facet_configs:
if isinstance(fc, str):
metadata_facet_names.append(fc)
elif isinstance(fc, dict):
metadata_facet_names.append(list(fc.values())[0])
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
metadata_facets = []
request_facets = []
for f in extra_facet_results["results"].values():
if f["name"] in metadata_order:
metadata_facets.append(f)
else:
request_facets.append(f)
metadata_facets.sort(key=lambda f: metadata_order[f["name"]])
request_facets.sort(
key=lambda f: (len(f["results"]), f["name"]),
reverse=True,
)
return metadata_facets + request_facets
else:
return sorted(
extra_facet_results["results"].values(),
key=lambda f: (len(f["results"]), f["name"]),
reverse=True,
)
async def extra_table_definition():
return await db.get_table_definition(table_name)
async def extra_view_definition():
return await db.get_view_definition(table_name)
async def extra_renderers(extra_expandable_columns, extra_query):
renderers = {}
url_labels_extra = {}
if extra_expandable_columns:
url_labels_extra = {"_labels": "on"}
for key, (_, can_render) in datasette.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=datasette,
columns=columns or [],
rows=rows or [],
sql=extra_query.get("sql", None),
query_name=None,
database=database_name,
table=table_name,
request=request,
view_name="table",
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = datasette.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
)
)
return renderers
async def extra_private():
return private
async def extra_expandable_columns():
expandables = []
db = datasette.databases[database_name]
for fk in await db.foreign_keys_for_table(table_name):
label_column = await db.label_column_for_table(fk["other_table"])
expandables.append((fk, label_column))
return expandables
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})
),
),
"selected": name in extras,
}
for name, doc in all_extras
]
async def extra_facets_timed_out(extra_facet_results):
return extra_facet_results["timed_out"]
bundles = {
"html": [
"suggested_facets",
"facet_results",
"facets_timed_out",
"count",
"count_sql",
"human_description_en",
"next_url",
"metadata",
"query",
"columns",
"display_columns",
"display_rows",
"database",
"table",
"database_color",
"actions",
"filters",
"renderers",
"custom_table_templates",
"sorted_facet_results",
"table_definition",
"view_definition",
"is_view",
"private",
"primary_keys",
"all_columns",
"expandable_columns",
"form_hidden_args",
"set_column_type_ui",
]
}
for key, values in bundles.items():
for key, values in TABLE_EXTRA_BUNDLES.items():
if f"_{key}" in extras:
extras.update(values)
extras.discard(f"_{key}")
registry = Registry(
extra_count,
extra_count_sql,
extra_facet_results,
extra_facets_timed_out,
extra_suggested_facets,
facet_instances,
extra_human_description_en,
extra_next_url,
extra_columns,
extra_all_columns,
extra_primary_keys,
run_display_columns_and_rows,
extra_display_columns,
extra_display_rows,
extra_render_cell,
extra_debug,
extra_request,
extra_query,
extra_column_types,
extra_set_column_type_ui,
extra_metadata,
extra_extras,
extra_database,
extra_table,
extra_database_color,
extra_actions,
extra_filters,
extra_renderers,
extra_custom_table_templates,
extra_sorted_facet_results,
extra_table_definition,
extra_view_definition,
extra_is_view,
extra_private,
extra_expandable_columns,
extra_form_hidden_args,
table_extra_context = TableExtraContext(
datasette=datasette,
request=request,
resolved=resolved,
db=db,
database_name=database_name,
table_name=table_name,
is_view=is_view,
private=private,
rows=rows,
columns=columns,
results_description=results.description,
table_columns=table_columns,
pks=pks,
count_sql=count_sql,
from_sql=from_sql,
from_sql_params=from_sql_params,
nocount=nocount,
nofacet=nofacet,
nosuggest=nosuggest,
next_arg=request.args.get("_next"),
next_value=next_value,
next_url=next_url,
sql=sql,
sql_no_order_no_limit=sql_no_order_no_limit,
params=params,
table_metadata=table_metadata,
filters=filters,
extra_human_descriptions=extra_human_descriptions,
sort=sort,
sort_desc=sort_desc,
sortable_columns=sortable_columns,
extras=extras,
extra_registry=table_extra_registry,
display_columns_and_rows=display_columns_and_rows,
run_sequential=run_sequential,
)
results = await registry.resolve_multi(
["extra_{}".format(extra) for extra in extras]
)
data = {
"ok": True,
"next": next_value and str(next_value) or None,
}
data.update(
{
key.replace("extra_", ""): value
for key, value in results.items()
if key.startswith("extra_") and key.replace("extra_", "") in extras
}
)
data.update(await resolve_table_extras(extras, table_extra_context))
raw_sqlite_rows = rows[:page_size]
# Apply transform_value for columns with assigned types
ct_map = await datasette.get_column_types(database_name, table_name)

View file

@ -0,0 +1,746 @@
import itertools
from dataclasses import dataclass
from datasette.database import QueryInterrupted
from datasette.extras import Extra, ExtraRegistry, ExtraScope, Provider
from datasette.plugins import pm
from datasette.resources import TableResource
from datasette.utils import (
await_me_maybe,
call_with_supported_arguments,
path_with_added_args,
path_with_format,
path_with_removed_args,
to_css_class,
)
@dataclass(frozen=True)
class TableExtraContext:
datasette: object
request: object
resolved: object
db: object
database_name: str
table_name: str
is_view: bool
private: bool
rows: list
columns: list
results_description: list
table_columns: list
pks: list
count_sql: str
from_sql: str
from_sql_params: dict
nocount: object
nofacet: object
nosuggest: object
next_arg: object
next_value: object
next_url: str | None
sql: str
sql_no_order_no_limit: str
params: dict
table_metadata: dict
filters: object
extra_human_descriptions: list
sort: str | None
sort_desc: str | None
sortable_columns: set
extras: set
extra_registry: ExtraRegistry
display_columns_and_rows: object
run_sequential: object
class CountSqlExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.count_sql
class CountExtra(Extra):
description = "Total count of rows matching these filters"
scopes = frozenset({ExtraScope.TABLE})
expensive = True
async def resolve(self, context):
count = None
if (
not context.db.is_mutable
and context.datasette.inspect_data
and context.count_sql == f"select count(*) from {context.table_name} "
):
try:
count = context.datasette.inspect_data[context.database_name]["tables"][
context.table_name
]["count"]
except KeyError:
pass
if context.count_sql and count is None and not context.nocount:
count_sql_limited = (
f"select count(*) from (select * {context.from_sql} limit 10001)"
)
try:
count_rows = list(
await context.db.execute(count_sql_limited, context.from_sql_params)
)
count = count_rows[0][0]
except QueryInterrupted:
pass
return count
class FacetInstancesProvider(Provider):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context, count):
facet_instances = []
facet_classes = list(
itertools.chain.from_iterable(pm.hook.register_facet_classes())
)
for facet_class in facet_classes:
facet_instances.append(
facet_class(
context.datasette,
context.request,
context.database_name,
sql=context.sql_no_order_no_limit,
params=context.params,
table=context.table_name,
table_config=context.table_metadata,
row_count=count,
)
)
return facet_instances
class FacetResultsExtra(Extra):
description = "Results of facets calculated against this data"
scopes = frozenset({ExtraScope.TABLE})
expensive = True
async def resolve(self, context, facet_instances):
facet_results = {}
facets_timed_out = []
if not context.nofacet:
facet_awaitables = [facet.facet_results() for facet in facet_instances]
facet_awaitable_results = await context.run_sequential(*facet_awaitables)
for (
instance_facet_results,
instance_facets_timed_out,
) in facet_awaitable_results:
for facet_info in instance_facet_results:
base_key = facet_info["name"]
key = base_key
i = 1
while key in facet_results:
i += 1
key = f"{base_key}_{i}"
facet_results[key] = facet_info
facets_timed_out.extend(instance_facets_timed_out)
return {
"results": facet_results,
"timed_out": facets_timed_out,
}
class FacetsTimedOutExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context, facet_results):
return facet_results["timed_out"]
class SuggestedFacetsExtra(Extra):
description = "Suggestions for facets that might return interesting results"
scopes = frozenset({ExtraScope.TABLE})
expensive = True
async def resolve(self, context, facet_instances):
suggested_facets = []
if (
context.datasette.setting("suggest_facets")
and context.datasette.setting("allow_facet")
and not context.next_arg
and not context.nofacet
and not context.nosuggest
):
facet_suggest_awaitables = [facet.suggest() for facet in facet_instances]
for suggest_result in await context.run_sequential(
*facet_suggest_awaitables
):
suggested_facets.extend(suggest_result)
return suggested_facets
class HumanDescriptionEnExtra(Extra):
description = "Human-readable description of the filters"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
human_description_en = context.filters.human_description_en(
extra=context.extra_human_descriptions
)
if context.sort or context.sort_desc:
sorted_by = "sorted by {}{}".format(
(context.sort or context.sort_desc),
" descending" if context.sort_desc else "",
)
human_description_en = " ".join(
[b for b in [human_description_en, sorted_by] if b]
)
return human_description_en
class NextUrlExtra(Extra):
description = "Full URL for the next page of results"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.next_url
class ColumnsExtra(Extra):
description = "Column names returned by this query"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.columns
class AllColumnsExtra(Extra):
description = "All columns in the table, regardless of _col/_nocol filtering"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return list(context.table_columns)
class PrimaryKeysExtra(Extra):
description = "Primary keys for this table"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.pks
class ActionsExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
async def actions():
links = []
kwargs = {
"datasette": context.datasette,
"database": context.database_name,
"actor": context.request.actor,
"request": context.request,
}
if context.is_view:
kwargs["view"] = context.table_name
method = pm.hook.view_actions
else:
kwargs["table"] = context.table_name
method = pm.hook.table_actions
for hook in method(**kwargs):
extra_links = await await_me_maybe(hook)
if extra_links:
links.extend(extra_links)
return links
return actions
class IsViewExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.is_view
class DebugExtra(Extra):
description = "Extra debug information"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return {
"resolved": repr(context.resolved),
"url_vars": context.request.url_vars,
"nofacet": context.nofacet,
"nosuggest": context.nosuggest,
}
class RequestExtra(Extra):
description = "Full information about the request"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return {
"url": context.request.url,
"path": context.request.path,
"full_path": context.request.full_path,
"host": context.request.host,
"args": context.request.args._data,
}
class DisplayColumnsAndRowsProvider(Provider):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
display_columns, display_rows = await context.display_columns_and_rows(
context.datasette,
context.database_name,
context.table_name,
context.results_description,
context.rows,
link_column=not context.is_view,
truncate_cells=context.datasette.setting("truncate_cells_html"),
sortable_columns=context.sortable_columns,
request=context.request,
)
return {
"columns": display_columns,
"rows": display_rows,
}
class DisplayColumnsExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context, display_columns_and_rows):
return display_columns_and_rows["columns"]
class DisplayRowsExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context, display_columns_and_rows):
return display_columns_and_rows["rows"]
class RenderCellExtra(Extra):
description = "Rendered HTML for each cell using the render_cell plugin hook"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
pks_for_display = (
context.pks if context.pks else (["rowid"] if not context.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
)
rendered_rows = []
for row in context.rows:
rendered_row = {}
for value, column in zip(row, col_names):
ct = ct_map.get(column)
plugin_display_value = None
if ct:
candidate = await ct.render_cell(
value=value,
column=column,
table=context.table_name,
database=context.database_name,
datasette=context.datasette,
request=context.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=context.table_name,
pks=pks_for_display,
database=context.database_name,
datasette=context.datasette,
request=context.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)
return rendered_rows
class QueryExtra(Extra):
description = "Details of the underlying SQL query"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return {
"sql": context.sql,
"params": context.params,
}
class ColumnTypesExtra(Extra):
description = "Column type assignments for this table"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
ct_map = await context.datasette.get_column_types(
context.database_name, context.table_name
)
return {
col_name: {
"type": ct.name,
"config": ct.config,
}
for col_name, ct in ct_map.items()
}
class SetColumnTypeUiExtra(Extra):
description = "Column type UI metadata for this table"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
if context.is_view:
return None
if not await context.datasette.allowed(
action="set-column-type",
resource=TableResource(
database=context.database_name, table=context.table_name
),
actor=context.request.actor,
):
return None
column_details = await context.datasette._get_resource_column_details(
context.database_name, context.table_name
)
ct_map = await context.datasette.get_column_types(
context.database_name, context.table_name
)
columns = {}
for column_name, column_detail in column_details.items():
current = ct_map.get(column_name)
columns[column_name] = {
"current": (
{"type": current.name, "config": current.config}
if current is not None
else None
),
"options": [
{
"name": name,
"description": ct_cls.description,
}
for name, ct_cls in sorted(context.datasette._column_types.items())
if context.datasette._column_type_is_applicable(
ct_cls, column_detail
)
],
}
return {
"path": "{}/-/set-column-type".format(
context.datasette.urls.table(context.database_name, context.table_name)
),
"columns": columns,
}
class MetadataExtra(Extra):
description = "Metadata about the table and database"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
tablemetadata = await context.datasette.get_resource_metadata(
context.database_name, context.table_name
)
rows = await context.datasette.get_internal_database().execute(
"""
SELECT
column_name,
value
FROM metadata_columns
WHERE database_name = ?
AND resource_name = ?
AND key = 'description'
""",
[context.database_name, context.table_name],
)
tablemetadata["columns"] = dict(rows)
return tablemetadata
class DatabaseExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.database_name
class TableExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.table_name
class DatabaseColorExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.db.color
class FormHiddenArgsExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
form_hidden_args = []
for key in context.request.args:
if (
key.startswith("_")
and key not in ("_sort", "_sort_desc", "_search", "_next")
and "__" not in key
):
for value in context.request.args.getlist(key):
form_hidden_args.append((key, value))
return form_hidden_args
class FiltersExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.filters
class CustomTableTemplatesExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return [
f"_table-{to_css_class(context.database_name)}-{to_css_class(context.table_name)}.html",
f"_table-table-{to_css_class(context.database_name)}-{to_css_class(context.table_name)}.html",
"_table.html",
]
class SortedFacetResultsExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context, facet_results):
facet_configs = context.table_metadata.get("facets", [])
if facet_configs:
metadata_facet_names = []
for fc in facet_configs:
if isinstance(fc, str):
metadata_facet_names.append(fc)
elif isinstance(fc, dict):
metadata_facet_names.append(list(fc.values())[0])
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
metadata_facets = []
request_facets = []
for f in facet_results["results"].values():
if f["name"] in metadata_order:
metadata_facets.append(f)
else:
request_facets.append(f)
metadata_facets.sort(key=lambda f: metadata_order[f["name"]])
request_facets.sort(
key=lambda f: (len(f["results"]), f["name"]),
reverse=True,
)
return metadata_facets + request_facets
else:
return sorted(
facet_results["results"].values(),
key=lambda f: (len(f["results"]), f["name"]),
reverse=True,
)
class TableDefinitionExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return await context.db.get_table_definition(context.table_name)
class ViewDefinitionExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return await context.db.get_view_definition(context.table_name)
class RenderersExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context, expandable_columns, query):
renderers = {}
url_labels_extra = {}
if expandable_columns:
url_labels_extra = {"_labels": "on"}
for key, (_, can_render) in context.datasette.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=context.datasette,
columns=context.columns or [],
rows=context.rows or [],
sql=query.get("sql", None),
query_name=None,
database=context.database_name,
table=context.table_name,
request=context.request,
view_name="table",
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = context.datasette.urls.path(
path_with_format(
request=context.request,
path=context.request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
)
)
return renderers
class PrivateExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
return context.private
class ExpandableColumnsExtra(Extra):
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
expandables = []
db = context.datasette.databases[context.database_name]
for fk in await db.foreign_keys_for_table(context.table_name):
label_column = await db.label_column_for_table(fk["other_table"])
expandables.append((fk, label_column))
return expandables
class ExtrasExtra(Extra):
description = "Available ?_extra= blocks"
scopes = frozenset({ExtraScope.TABLE})
async def resolve(self, context):
all_extras = [
(cls.key(), cls.description)
for cls in context.extra_registry.public_classes_for_scope(ExtraScope.TABLE)
]
return [
{
"name": name,
"description": description,
"toggle_url": context.datasette.absolute_url(
context.request,
context.datasette.urls.path(
path_with_added_args(context.request, {"_extra": name})
if name not in context.extras
else path_with_removed_args(context.request, {"_extra": name})
),
),
"selected": name in context.extras,
}
for name, description in all_extras
]
TABLE_EXTRA_BUNDLES = {
"html": [
"suggested_facets",
"facet_results",
"facets_timed_out",
"count",
"count_sql",
"human_description_en",
"next_url",
"metadata",
"query",
"columns",
"display_columns",
"display_rows",
"database",
"table",
"database_color",
"actions",
"filters",
"renderers",
"custom_table_templates",
"sorted_facet_results",
"table_definition",
"view_definition",
"is_view",
"private",
"primary_keys",
"all_columns",
"expandable_columns",
"form_hidden_args",
"set_column_type_ui",
]
}
TABLE_EXTRA_CLASSES = [
CountExtra,
CountSqlExtra,
FacetResultsExtra,
FacetsTimedOutExtra,
SuggestedFacetsExtra,
FacetInstancesProvider,
HumanDescriptionEnExtra,
NextUrlExtra,
ColumnsExtra,
AllColumnsExtra,
PrimaryKeysExtra,
DisplayColumnsAndRowsProvider,
DisplayColumnsExtra,
DisplayRowsExtra,
RenderCellExtra,
DebugExtra,
RequestExtra,
QueryExtra,
ColumnTypesExtra,
SetColumnTypeUiExtra,
MetadataExtra,
ExtrasExtra,
DatabaseExtra,
TableExtra,
DatabaseColorExtra,
ActionsExtra,
FiltersExtra,
RenderersExtra,
CustomTableTemplatesExtra,
SortedFacetResultsExtra,
TableDefinitionExtra,
ViewDefinitionExtra,
IsViewExtra,
PrivateExtra,
ExpandableColumnsExtra,
FormHiddenArgsExtra,
]
table_extra_registry = ExtraRegistry(TABLE_EXTRA_CLASSES)
async def resolve_table_extras(extras, context):
return await table_extra_registry.resolve(extras, context, ExtraScope.TABLE)