diff --git a/datasette/renderer.py b/datasette/renderer.py
index acf23e59..d250e1f0 100644
--- a/datasette/renderer.py
+++ b/datasette/renderer.py
@@ -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
diff --git a/datasette/views/extras.py b/datasette/views/extras.py
new file mode 100644
index 00000000..05c10f14
--- /dev/null
+++ b/datasette/views/extras.py
@@ -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
diff --git a/datasette/views/row.py b/datasette/views/row.py
index 718ee00c..4449a3c0 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -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,
diff --git a/datasette/views/table.py b/datasette/views/table.py
index b07b62ae..11336169 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -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 = (
'{label} {id}'
@@ -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"
diff --git a/tests/test_api.py b/tests/test_api.py
index 41bad84e..c593fff1 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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