Compare commits

...

2 commits

5 changed files with 255 additions and 60 deletions

View file

@ -108,7 +108,11 @@ def json_renderer(request, args, data, error, truncated=None):
# Don't include "columns" in output # Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136 # https://github.com/simonw/datasette/issues/2136
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"): # Parse comma-separated _extra values
extras = set()
for bit in request.args.getlist("_extra"):
extras.update(bit.split(","))
if isinstance(data, dict) and "columns" not in extras:
data.pop("columns", None) data.pop("columns", None)
# Handle _nl option for _shape=array # Handle _nl option for _shape=array

185
datasette/views/extras.py Normal file
View file

@ -0,0 +1,185 @@
"""
Shared extras functionality for table and row views.
"""
from datasette.plugins import pm
from datasette.resources import TableResource
from datasette.utils import await_me_maybe
def _get_extras(request):
"""Parse ?_extra= parameters from request into a set of extra names."""
extra_bits = request.args.getlist("_extra")
extras = set()
for bit in extra_bits:
extras.update(bit.split(","))
return extras
async def render_cells_for_rows(
datasette, database_name, table_name, rows, columns, request
):
"""
Call render_cell plugin hook for each cell.
Returns a list of dicts, one per row, containing only cells modified by plugins.
"""
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
plugin_display_value = None
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table_name,
database=database_name,
datasette=datasette,
request=request,
):
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 SharedExtras:
"""
Extras that are shared between table and row views.
Initialize with context, then call get_extras() to process requested extras.
Subclass to add view-specific extras.
"""
# Extras that this class can provide
available_extras = {
"columns",
"primary_keys",
"database",
"table",
"database_color",
"query",
"render_cell",
"table_definition",
"view_definition",
"is_view",
"private",
"metadata",
}
def __init__(
self,
datasette,
db,
database_name,
table_name,
request,
rows,
columns,
pks,
sql=None,
params=None,
):
self.datasette = datasette
self.db = db
self.database_name = database_name
self.table_name = table_name
self.request = request
self.rows = rows
self.columns = columns
self.pks = pks
self.sql = sql
self.params = params
async def get_extras(self, extras):
"""
Process a set of extra names and return a dict of results.
Only processes extras that this class knows how to handle.
"""
results = {}
for extra in extras:
method = getattr(self, f"extra_{extra}", None)
if method:
results[extra] = await method()
return results
async def extra_columns(self):
"""Column names returned by this query"""
return self.columns
async def extra_primary_keys(self):
"""Primary keys for this table"""
return self.pks
async def extra_database(self):
"""Database name"""
return self.database_name
async def extra_table(self):
"""Table name"""
return self.table_name
async def extra_database_color(self):
"""Database color"""
return self.db.color
async def extra_query(self):
"""Details of the underlying SQL query"""
return {
"sql": self.sql,
"params": self.params,
}
async def extra_render_cell(self):
"""Rendered HTML for each cell using the render_cell plugin hook"""
return await render_cells_for_rows(
self.datasette,
self.database_name,
self.table_name,
self.rows,
self.columns,
self.request,
)
async def extra_table_definition(self):
"""SQL schema for this table"""
return await self.db.get_table_definition(self.table_name)
async def extra_view_definition(self):
"""SQL schema for this view (if it is a view)"""
return await self.db.get_view_definition(self.table_name)
async def extra_is_view(self):
"""Is this a view rather than a table?"""
return await self.db.view_exists(self.table_name)
async def extra_private(self):
"""Is this table private?"""
visible, _ = await self.datasette.check_visibility(
self.request.actor,
action="view-table",
resource=TableResource(database=self.database_name, table=self.table_name),
)
return not visible
async def extra_metadata(self):
"""Metadata about the table and database"""
tablemetadata = await self.datasette.get_resource_metadata(
self.database_name, self.table_name
)
rows = await self.datasette.get_internal_database().execute(
"""
SELECT column_name, value
FROM metadata_columns
WHERE database_name = ?
AND resource_name = ?
AND key = 'description'
""",
[self.database_name, self.table_name],
)
tablemetadata["columns"] = dict(rows)
return tablemetadata

View file

@ -12,7 +12,8 @@ from datasette.utils import (
from datasette.plugins import pm from datasette.plugins import pm
import json import json
import sqlite_utils import sqlite_utils
from .table import display_columns_and_rows, _get_extras from .table import display_columns_and_rows
from .extras import _get_extras, SharedExtras
class RowView(DataView): class RowView(DataView):
@ -111,38 +112,28 @@ class RowView(DataView):
if "foreign_key_tables" in (request.args.get("_extras") or "").split(","): if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
extras.add("foreign_key_tables") extras.add("foreign_key_tables")
# Process extras # Process shared extras using SharedExtras class
shared_extras = SharedExtras(
datasette=self.ds,
db=db,
database_name=database,
table_name=table,
request=request,
rows=rows,
columns=columns,
pks=resolved.pks,
sql=resolved.sql,
params=resolved.params,
)
extra_results = await shared_extras.get_extras(extras)
data.update(extra_results)
# Handle row-specific extras
if "foreign_key_tables" in extras: if "foreign_key_tables" in extras:
data["foreign_key_tables"] = await self.foreign_key_tables( data["foreign_key_tables"] = await self.foreign_key_tables(
database, table, pk_values database, table, pk_values
) )
if "render_cell" in extras:
# Call render_cell plugin hook for each cell
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
# Call render_cell plugin hook
plugin_display_value = None
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table,
database=database,
datasette=self.ds,
request=request,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
plugin_display_value = candidate
break
if plugin_display_value:
rendered_row[column] = str(plugin_display_value)
rendered_rows.append(rendered_row)
data["render_cell"] = rendered_rows
return ( return (
data, data,
template_data, template_data,

View file

@ -46,6 +46,7 @@ from datasette.filters import Filters
import sqlite_utils import sqlite_utils
from .base import BaseView, DatasetteError, _error, stream_csv from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView from .database import QueryView
from .extras import _get_extras, render_cells_for_rows
LINK_WITH_LABEL = ( LINK_WITH_LABEL = (
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a>&nbsp;<em>{id}</em>' '<a href="{base_url}{database}/{table}/{link_id}">{label}</a>&nbsp;<em>{id}</em>'
@ -679,14 +680,6 @@ class TableDropView(BaseView):
return Response.json({"ok": True}, status=200) return Response.json({"ok": True}, status=200)
def _get_extras(request):
extra_bits = request.args.getlist("_extra")
extras = set()
for bit in extra_bits:
extras.update(bit.split(","))
return extras
async def _columns_to_select(table_columns, pks, request): async def _columns_to_select(table_columns, pks, request):
columns = list(table_columns) columns = list(table_columns)
if "_col" in request.args: if "_col" in request.args:
@ -1495,29 +1488,9 @@ async def table_view_data(
async def extra_render_cell(): async def extra_render_cell():
"Rendered HTML for each cell using the render_cell plugin hook" "Rendered HTML for each cell using the render_cell plugin hook"
columns = [col[0] for col in results.description] columns = [col[0] for col in results.description]
rendered_rows = [] return await render_cells_for_rows(
for row in rows: datasette, database_name, table_name, rows, columns, request
rendered_row = {} )
for value, column in zip(row, columns):
# Call render_cell plugin hook
plugin_display_value = None
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table_name,
database=database_name,
datasette=datasette,
request=request,
):
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(): async def extra_query():
"Details of the underlying SQL query" "Details of the underlying SQL query"

View file

@ -809,6 +809,48 @@ async def test_row_extra_render_cell():
ds.pm.unregister(name="TestRenderCellPlugin") ds.pm.unregister(name="TestRenderCellPlugin")
@pytest.mark.asyncio
async def test_row_extras_shared():
"""Test that shared extras (columns, query, metadata, etc.) work on row pages"""
from datasette.app import Datasette
ds = Datasette(memory=True)
await ds.invoke_startup()
db = ds.add_memory_database("test_row_extras")
await db.execute_write(
"create table test_extras (id integer primary key, name text, age integer)"
)
await db.execute_write("insert into test_extras values (1, 'Alice', 30)")
# Test multiple shared extras
response = await ds.client.get(
"/test_row_extras/test_extras/1.json"
"?_extra=columns,primary_keys,query,database,table,database_color"
",table_definition,is_view,private,metadata"
)
assert response.status_code == 200
data = response.json()
# Verify shared extras are present and correct
assert data["columns"] == ["id", "name", "age"]
assert data["primary_keys"] == ["id"]
assert data["database"] == "test_row_extras"
assert data["table"] == "test_extras"
assert data["database_color"] is not None # Has some color value
assert data["is_view"] is False
assert data["private"] is False
assert "columns" in data["metadata"] # metadata has columns dict
# query extra should have sql and params
assert "sql" in data["query"]
assert "params" in data["query"]
assert "test_extras" in data["query"]["sql"]
# table_definition should be the CREATE TABLE statement
assert "CREATE TABLE" in data["table_definition"]
assert "test_extras" in data["table_definition"]
def test_databases_json(app_client_two_attached_databases_one_immutable): def test_databases_json(app_client_two_attached_databases_one_immutable):
response = app_client_two_attached_databases_one_immutable.get("/-/databases.json") response = app_client_two_attached_databases_one_immutable.get("/-/databases.json")
databases = response.json databases = response.json