diff --git a/datasette/extras.py b/datasette/extras.py
index 01a9fb4b..786ec4f4 100644
--- a/datasette/extras.py
+++ b/datasette/extras.py
@@ -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()
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 9ba249f4..c2d520f8 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -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 = (
'{label} {id}'
@@ -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)
diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py
new file mode 100644
index 00000000..2ec2adf0
--- /dev/null
+++ b/datasette/views/table_extras.py
@@ -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)