mirror of
https://github.com/simonw/datasette.git
synced 2026-05-29 13:17:02 +02:00
Compare commits
2 commits
main
...
shared-ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab116e31a5 | ||
|
|
8594aeb648 |
5 changed files with 255 additions and 60 deletions
|
|
@ -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
185
datasette/views/extras.py
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> <em>{id}</em>'
|
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a> <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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue