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