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
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
import json
|
||||
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):
|
||||
|
|
@ -111,38 +112,28 @@ class RowView(DataView):
|
|||
if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
|
||||
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:
|
||||
data["foreign_key_tables"] = await self.foreign_key_tables(
|
||||
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 (
|
||||
data,
|
||||
template_data,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ from datasette.filters import Filters
|
|||
import sqlite_utils
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
from .database import QueryView
|
||||
from .extras import _get_extras, render_cells_for_rows
|
||||
|
||||
LINK_WITH_LABEL = (
|
||||
'<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)
|
||||
|
||||
|
||||
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):
|
||||
columns = list(table_columns)
|
||||
if "_col" in request.args:
|
||||
|
|
@ -1495,29 +1488,9 @@ async def table_view_data(
|
|||
async def extra_render_cell():
|
||||
"Rendered HTML for each cell using the render_cell plugin hook"
|
||||
columns = [col[0] for col in results.description]
|
||||
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_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
|
||||
return await render_cells_for_rows(
|
||||
datasette, database_name, table_name, rows, columns, request
|
||||
)
|
||||
|
||||
async def extra_query():
|
||||
"Details of the underlying SQL query"
|
||||
|
|
|
|||
|
|
@ -809,6 +809,48 @@ async def test_row_extra_render_cell():
|
|||
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):
|
||||
response = app_client_two_attached_databases_one_immutable.get("/-/databases.json")
|
||||
databases = response.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue