mirror of
https://github.com/simonw/datasette.git
synced 2026-06-12 20:16:56 +02:00
frozenset({...}) was immutability ceremony for class attributes that
nothing mutates. scopes = {ExtraScope.TABLE} reads cleaner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1049 lines
34 KiB
Python
1049 lines
34 KiB
Python
import itertools
|
|
from dataclasses import dataclass
|
|
|
|
from datasette.database import QueryInterrupted
|
|
from datasette.extras import Extra, ExtraExample, 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_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
|
|
query_name: str | None = None
|
|
scope: ExtraScope = ExtraScope.TABLE
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RowExtraContext:
|
|
datasette: object
|
|
request: object
|
|
db: object
|
|
database_name: str
|
|
table_name: str
|
|
private: bool
|
|
rows: list
|
|
columns: list
|
|
pks: list
|
|
pk_values: list
|
|
sql: str
|
|
params: dict
|
|
extras: set
|
|
extra_registry: ExtraRegistry
|
|
foreign_key_tables: object
|
|
is_view: bool = False
|
|
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
|
|
metadata: dict
|
|
extras: set
|
|
extra_registry: ExtraRegistry
|
|
table_name: str | None = None
|
|
is_view: bool = False
|
|
pks: list | None = None
|
|
scope: ExtraScope = ExtraScope.QUERY
|
|
|
|
|
|
class CountSqlExtra(Extra):
|
|
description = "SQL query used to calculate the total count"
|
|
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context):
|
|
return context.count_sql
|
|
|
|
|
|
class CountExtra(Extra):
|
|
description = "Total count of rows matching these filters"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=count")
|
|
scopes = {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 = {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"
|
|
example = ExtraExample(
|
|
value={
|
|
"results": {
|
|
"state": {
|
|
"name": "state",
|
|
"type": "column",
|
|
"results": [
|
|
{"value": "CA", "label": "CA", "count": 10},
|
|
{"value": "MI", "label": "MI", "count": 4},
|
|
],
|
|
}
|
|
},
|
|
"timed_out": [],
|
|
},
|
|
note="Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.",
|
|
)
|
|
scopes = {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):
|
|
description = "Facet calculations that timed out"
|
|
example = ExtraExample(
|
|
"/fixtures/facetable.json?_facet=state&_extra=facets_timed_out"
|
|
)
|
|
scopes = {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"
|
|
example = ExtraExample(
|
|
value=[
|
|
{
|
|
"name": "state",
|
|
"toggle_url": "http://localhost/fixtures/facetable.json?_extra=suggested_facets&_facet=state",
|
|
}
|
|
],
|
|
note="Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.",
|
|
)
|
|
scopes = {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"
|
|
example = ExtraExample(
|
|
"/fixtures/facetable.json?state=CA&_sort=pk&_extra=human_description_en"
|
|
)
|
|
scopes = {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"
|
|
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=next_url")
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context):
|
|
return context.next_url
|
|
|
|
|
|
class ColumnsExtra(Extra):
|
|
description = "Column names returned by this query"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
|
|
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 = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
async def resolve(self, context):
|
|
return context.columns
|
|
|
|
|
|
class AllColumnsExtra(Extra):
|
|
description = "All columns in the table, regardless of _col/_nocol filtering"
|
|
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context):
|
|
return list(context.table_columns)
|
|
|
|
|
|
class PrimaryKeysExtra(Extra):
|
|
description = "Primary keys for this table"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
|
|
examples = {
|
|
ExtraScope.ROW: ExtraExample(
|
|
"/fixtures/simple_primary_key/1.json?_extra=primary_keys"
|
|
)
|
|
}
|
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
|
|
|
async def resolve(self, context):
|
|
return context.pks
|
|
|
|
|
|
class ActionsExtra(Extra):
|
|
description = "Table or view actions made available by plugin hooks"
|
|
scopes = {ExtraScope.TABLE}
|
|
# Returns an async function for the HTML templates - not JSON serializable
|
|
public = False
|
|
|
|
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):
|
|
description = "Whether this resource is a view instead of a table"
|
|
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context):
|
|
return context.is_view
|
|
|
|
|
|
class DebugExtra(Extra):
|
|
description = "Extra debug information"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=debug")
|
|
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 = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
async def resolve(self, context):
|
|
debug = {
|
|
"url_vars": context.request.url_vars,
|
|
}
|
|
if context.scope == ExtraScope.TABLE:
|
|
debug["resolved"] = repr(context.resolved)
|
|
debug["nofacet"] = context.nofacet
|
|
debug["nosuggest"] = context.nosuggest
|
|
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,
|
|
}
|
|
return debug
|
|
|
|
|
|
class RequestExtra(Extra):
|
|
description = "Full information about the request"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=request")
|
|
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 = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
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 = {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):
|
|
description = "Column metadata used by the HTML table display"
|
|
example = ExtraExample(
|
|
value=[
|
|
{
|
|
"name": "pk",
|
|
"sortable": True,
|
|
"is_pk": True,
|
|
"type": "INTEGER",
|
|
"notnull": 0,
|
|
},
|
|
{
|
|
"name": "created",
|
|
"sortable": True,
|
|
"is_pk": False,
|
|
"type": "TEXT",
|
|
"notnull": 0,
|
|
"description": None,
|
|
"column_type": None,
|
|
"column_type_config": None,
|
|
},
|
|
],
|
|
note="Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.",
|
|
)
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context, display_columns_and_rows):
|
|
return display_columns_and_rows["columns"]
|
|
|
|
|
|
class DisplayRowsExtra(Extra):
|
|
description = "Row data formatted for the HTML table display"
|
|
scopes = {ExtraScope.TABLE}
|
|
# Contains markupsafe/sqlite3.Row values - not JSON serializable
|
|
public = False
|
|
|
|
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"
|
|
example = ExtraExample(
|
|
value={
|
|
"rows": [
|
|
{"id": 1, "content": "hello"},
|
|
{"id": 4, "content": "RENDER_CELL_DEMO"},
|
|
],
|
|
"render_cell": [
|
|
{},
|
|
{"content": "<strong>Custom rendered HTML</strong>"},
|
|
],
|
|
},
|
|
note=(
|
|
"The ``render_cell`` array has one item per 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."
|
|
),
|
|
)
|
|
examples = {
|
|
ExtraScope.ROW: ExtraExample(
|
|
value={
|
|
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
|
|
"render_cell": [{"content": "<strong>Custom rendered HTML</strong>"}],
|
|
},
|
|
note=(
|
|
"The ``render_cell`` array has one item for the requested row. "
|
|
"The object is keyed by column name. Only columns whose rendered "
|
|
"value differs from the default are included."
|
|
),
|
|
),
|
|
ExtraScope.QUERY: ExtraExample(
|
|
value={
|
|
"rows": [{"content": "RENDER_CELL_DEMO"}],
|
|
"render_cell": [{"content": "<strong>Custom rendered HTML</strong>"}],
|
|
},
|
|
note=(
|
|
"The ``render_cell`` array has one item per query result row, in "
|
|
"the same order as the ``rows`` array. Each object is keyed by "
|
|
"column name. Only columns whose rendered value differs from the "
|
|
"default are included."
|
|
),
|
|
),
|
|
}
|
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
async def resolve(self, context):
|
|
table_name = context.table_name
|
|
pks_for_display = context.pks or (
|
|
["rowid"] if table_name and not context.is_view else []
|
|
)
|
|
ct_map = (
|
|
await context.datasette.get_column_types(context.database_name, table_name)
|
|
if table_name
|
|
else {}
|
|
)
|
|
rendered_rows = []
|
|
for row in context.rows:
|
|
rendered_row = {}
|
|
for value, column in zip(row, context.columns):
|
|
ct = ct_map.get(column)
|
|
plugin_display_value = None
|
|
if ct:
|
|
candidate = await ct.render_cell(
|
|
value=value,
|
|
column=column,
|
|
table=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=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"
|
|
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
|
|
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 = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
async def resolve(self, context):
|
|
return {
|
|
"sql": context.sql,
|
|
"params": context.params,
|
|
}
|
|
|
|
|
|
class ColumnTypesExtra(Extra):
|
|
description = "Column type assignments for this table"
|
|
example = ExtraExample(value={})
|
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
|
|
|
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 = {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, database or stored query"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=metadata")
|
|
examples = {
|
|
ExtraScope.ROW: ExtraExample(
|
|
"/fixtures/simple_primary_key/1.json?_extra=metadata"
|
|
),
|
|
ExtraScope.QUERY: ExtraExample(
|
|
"/fixtures/neighborhood_search.json?text=town&_extra=metadata"
|
|
),
|
|
}
|
|
scopes = {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
|
|
)
|
|
|
|
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):
|
|
description = "Database name"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=database")
|
|
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 = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
async def resolve(self, context):
|
|
return context.database_name
|
|
|
|
|
|
class TableExtra(Extra):
|
|
description = "Table name"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=table")
|
|
examples = {
|
|
ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table")
|
|
}
|
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
|
|
|
async def resolve(self, context):
|
|
return context.table_name
|
|
|
|
|
|
class DatabaseColorExtra(Extra):
|
|
description = "Color assigned to the database"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
|
|
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 = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
async def resolve(self, context):
|
|
return context.db.color
|
|
|
|
|
|
class FormHiddenArgsExtra(Extra):
|
|
description = "Hidden form arguments used by the HTML table interface"
|
|
example = ExtraExample(
|
|
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
|
|
)
|
|
scopes = {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):
|
|
description = "Filters object used by the HTML table interface"
|
|
scopes = {ExtraScope.TABLE}
|
|
# Returns a Filters instance for the HTML templates - not JSON serializable
|
|
public = False
|
|
|
|
async def resolve(self, context):
|
|
return context.filters
|
|
|
|
|
|
class CustomTableTemplatesExtra(Extra):
|
|
description = "Custom template names considered for this table"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates")
|
|
scopes = {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):
|
|
description = "Facet results sorted for display"
|
|
example = ExtraExample(
|
|
"/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results"
|
|
)
|
|
scopes = {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):
|
|
description = "SQL definition for this table"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=table_definition")
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context):
|
|
return await context.db.get_table_definition(context.table_name)
|
|
|
|
|
|
class ViewDefinitionExtra(Extra):
|
|
description = "SQL definition for this view"
|
|
example = ExtraExample("/fixtures/simple_view.json?_extra=view_definition")
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context):
|
|
return await context.db.get_view_definition(context.table_name)
|
|
|
|
|
|
class RenderersExtra(Extra):
|
|
description = "Alternative output renderers available for this table"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=renderers")
|
|
scopes = {ExtraScope.TABLE}
|
|
|
|
async def resolve(self, context, expandable_columns, query):
|
|
renderers = {}
|
|
url_labels_extra = {}
|
|
if expandable_columns:
|
|
url_labels_extra = {"_labels": "on"}
|
|
table_name = context.table_name
|
|
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,
|
|
datasette=context.datasette,
|
|
columns=context.columns or [],
|
|
rows=context.rows or [],
|
|
sql=query.get("sql", None),
|
|
query_name=context.query_name,
|
|
database=context.database_name,
|
|
table=table_name,
|
|
request=context.request,
|
|
view_name=view_name,
|
|
)
|
|
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):
|
|
description = "Whether this resource is private to the current actor"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=private")
|
|
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 = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
|
|
|
async def resolve(self, context):
|
|
return context.private
|
|
|
|
|
|
class ExpandableColumnsExtra(Extra):
|
|
description = "Foreign key columns that can be expanded with labels"
|
|
example = ExtraExample("/fixtures/facetable.json?_extra=expandable_columns")
|
|
scopes = {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 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 = {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 = {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(context.scope)
|
|
]
|
|
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,
|
|
ForeignKeyTablesExtra,
|
|
FormHiddenArgsExtra,
|
|
]
|
|
|
|
|
|
table_extra_registry = ExtraRegistry(TABLE_EXTRA_CLASSES)
|
|
|
|
|
|
async def resolve_table_extras(extras, context, include_internal=False):
|
|
return await table_extra_registry.resolve(
|
|
extras, context, ExtraScope.TABLE, include_internal=include_internal
|
|
)
|
|
|
|
|
|
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)
|