From 28184edc52845fe0797f973628268a4dfb18f300 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:54:33 +0000 Subject: [PATCH] Row JSON now returns {"row": {...}}, closes #2612 /db/table/pk.json returns the row as an object in a "row" key instead of a single-item "rows" list. Passing any ?_shape= returns the same shapes as table JSON, so ?_shape=objects gets the previous "rows" list back. This happens in the JSON renderer, so other output renderers still get the "rows" list for row pages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaqG8H8ajwxizKXdbGrtve --- datasette/renderer.py | 12 ++++++++- datasette/static/edit-tools.js | 2 +- datasette/views/table_extras.py | 2 +- docs/json_api.rst | 35 ++++++++++++++++++++----- docs/upgrade_guide.md | 17 +++++++++++++ tests/test_api.py | 45 ++++++++++++++++++++++++++++----- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index bb53f13a..1d957b8d 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -29,7 +29,7 @@ def convert_specific_columns_to_json(rows, columns, json_cols): return new_rows -def json_renderer(request, args, data, error, truncated=None): +def json_renderer(request, args, data, error, truncated=None, view_name=None): """Render a response as JSON""" status_code = 200 @@ -48,10 +48,13 @@ def json_renderer(request, args, data, error, truncated=None): # Deal with the _shape option shape = args.get("_shape", "objects") + # Row pages return {"row": {...}} unless a _shape is requested + single_row = view_name == "row" and "_shape" not in args # if there's an error, ignore the shape entirely data["ok"] = True if error: shape = "objects" + single_row = False status_code = 400 data.update(error_body(error, status_code)) @@ -68,6 +71,13 @@ def json_renderer(request, args, data, error, truncated=None): rows = data.get("rows") if rows and columns and not isinstance(rows[0], dict): data["rows"] = [dict(zip(columns, row)) for row in rows] + if single_row and "rows" in data: + # Swap "rows" for "row", keeping its position in the output + row = data["rows"][0] if data["rows"] else None + data = dict( + ("row", row) if key == "rows" else (key, value) + for key, value in data.items() + ) if shape == "object": shape_error = None if "primary_keys" not in data: diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 0f61ebd6..a131d1ad 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -6838,7 +6838,7 @@ async function saveRowEditDialog(state) { } function renderRowEditFields(state, data) { - var row = data.rows && data.rows.length ? data.rows[0] : null; + var row = data.row || null; var columns = data.columns || (row ? Object.keys(row) : []); var primaryKeys = data.primary_keys || []; var columnTypes = data.column_types || {}; diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index 6f3437d5..48eaa35f 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -647,7 +647,7 @@ class RenderCellExtra(Extra): examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { ExtraScope.ROW: ExtraExample( value={ - "rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}], + "row": {"id": 4, "content": "RENDER_CELL_DEMO"}, "render_cell": [{"content": "Custom rendered HTML"}], }, note=( diff --git a/docs/json_api.rst b/docs/json_api.rst index 73212e70..02bd6d9e 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -153,6 +153,29 @@ Values for named SQL parameters can be provided as additional query string param The response uses the same default representation described above. +.. _json_api_row: + +Rows +---- + +Every row in a table has its own JSON URL, see :ref:`RowView`. The row is returned as an object in the ``"row"`` key: + +:: + + GET /fixtures/simple_primary_key/1.json + +.. code-block:: json + + { + "ok": true, + "row": { + "id": 1, + "content": "hello" + } + } + +Pass any :ref:`?_shape= option ` to return the row using the same shapes as table JSON instead. ``?_shape=objects`` returns the table default representation, with the row as the only item in the ``"rows"`` list. + .. _json_api_shapes: Different shapes @@ -162,7 +185,7 @@ The ``_shape`` parameter can be used to access alternative formats for the ``rows`` key which may be more convenient for your application. There are three options: -* ``?_shape=objects`` - ``"rows"`` is a list of JSON key/value objects - the default +* ``?_shape=objects`` - ``"rows"`` is a list of JSON key/value objects - the default for tables and queries * ``?_shape=arrays`` - ``"rows"`` is a list of lists, where the order of values in each list matches the order of the columns * ``?_shape=array`` - a JSON array of objects - effectively just the ``"rows"`` key from the default representation * ``?_shape=array&_nl=on`` - a newline-separated list of JSON objects @@ -942,12 +965,10 @@ The following extras are available for row JSON responses. .. code-block:: json { - "rows": [ - { - "id": 4, - "content": "RENDER_CELL_DEMO" - } - ], + "row": { + "id": 4, + "content": "RENDER_CELL_DEMO" + }, "render_cell": [ { "content": "Custom rendered HTML" diff --git a/docs/upgrade_guide.md b/docs/upgrade_guide.md index 33a8343b..d396e55c 100644 --- a/docs/upgrade_guide.md +++ b/docs/upgrade_guide.md @@ -29,6 +29,23 @@ The URL for executing a SQL query now looks like this: **This isn't a breaking change.** API calls to the older ``/databasename?sql=...`` endpoint will redirect to the new ``databasename/-/query?sql=...`` endpoint. Upgrading to the new URL is recommended to avoid the overhead of the additional redirect. +(upgrade_guide_v1_row_json)= +### Row JSON returns a single ``"row"`` object + +The JSON for an individual row, such as ``/databasename/tablename/1.json``, previously returned that row as the only item in a ``"rows"`` list. It now returns it as an object in a ``"row"`` key: + +```json +{ + "ok": true, + "row": { + "id": 1, + "content": "hello" + } +} +``` + +Add ``?_shape=objects`` to the URL to get a ``"rows"`` list containing that row instead, or use any other ``?_shape=`` option to get the same shapes as table JSON. See {ref}`json_api_row`. + (upgrade_guide_v1_metadata)= ### Metadata changes diff --git a/tests/test_api.py b/tests/test_api.py index 690cb080..dd2365b6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -363,10 +363,43 @@ async def test_invalid_custom_sql(ds_client): @pytest.mark.asyncio async def test_row(ds_client): - response = await ds_client.get("/fixtures/simple_primary_key/1.json?_shape=objects") + response = await ds_client.get("/fixtures/simple_primary_key/1.json") assert response.status_code == 200 - assert response.json()["ok"] is True - assert response.json()["rows"] == [{"id": 1, "content": "hello"}] + data = response.json() + assert data["ok"] is True + assert data["row"] == {"id": 1, "content": "hello"} + assert "rows" not in data + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "shape,expected", + ( + ("objects", [{"id": 1, "content": "hello"}]), + ("arrays", [[1, "hello"]]), + ("array", [{"id": 1, "content": "hello"}]), + ("arrayfirst", [1]), + ("object", {"1": {"id": 1, "content": "hello"}}), + ), +) +async def test_row_shape(ds_client, shape, expected): + # Any ?_shape= returns the same shapes as the table JSON + response = await ds_client.get( + f"/fixtures/simple_primary_key/1.json?_shape={shape}" + ) + assert response.status_code == 200 + data = response.json() + if shape in ("objects", "arrays"): + assert "row" not in data + data = data["rows"] + assert data == expected + + +@pytest.mark.asyncio +async def test_row_json_cols(ds_client): + response = await ds_client.get("/fixtures/facetable/1.json?_json=tags") + assert response.status_code == 200 + assert response.json()["row"]["tags"] == ["tag1", "tag2"] @pytest.mark.asyncio @@ -615,7 +648,7 @@ async def test_row_extra_render_cell(): # Verify the response structure assert "render_cell" in data - assert "rows" in data + assert "row" in data # render_cell should be a list with one row (since this is a row page) # Only columns modified by plugins are included (sparse output) @@ -628,8 +661,8 @@ async def test_row_extra_render_cell(): # The 'id' column is not included since no plugin modified it assert "id" not in render_cell[0] - # The regular rows should still contain raw values - assert data["rows"] == [{"id": 1, "name": "Alice"}] + # The regular row should still contain raw values + assert data["row"] == {"id": 1, "name": "Alice"} finally: ds.pm.unregister(name="TestRenderCellPlugin")