From e8c7b469fabf5b10259ece7de77da2ebed5ba2f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:44:50 +0000 Subject: [PATCH 1/3] Fix _shape=arrays and _shape=arrayfirst for rows containing infinity remove_infinites() returns a plain list for sqlite3.Row rows that contain infinity, which caused a 500 error with these two shapes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaqG8H8ajwxizKXdbGrtve --- datasette/renderer.py | 23 +++++++++-------------- tests/test_table_api.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index 0e01f52f..bb53f13a 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -6,7 +6,6 @@ from datasette.utils import ( error_body, path_from_row_pks, remove_infinites, - sqlite3, value_as_boolean, ) from datasette.utils.asgi import Response @@ -59,13 +58,11 @@ def json_renderer(request, args, data, error, truncated=None): if truncated is not None: data["truncated"] = truncated if shape == "arrayfirst": - if not data["rows"]: - data = [] - elif isinstance(data["rows"][0], sqlite3.Row): - data = [row[0] for row in data["rows"]] - else: - assert isinstance(data["rows"][0], dict) - data = [next(iter(row.values())) for row in data["rows"]] + # Rows can be dicts, sqlite3.Row or lists (from remove_infinites) + data = [ + next(iter(row.values())) if isinstance(row, dict) else row[0] + for row in data["rows"] + ] elif shape in ("objects", "object", "array"): columns = data.get("columns") rows = data.get("rows") @@ -94,12 +91,10 @@ def json_renderer(request, args, data, error, truncated=None): data = data["rows"] elif shape == "arrays": - if not data["rows"]: - pass - elif isinstance(data["rows"][0], sqlite3.Row): - data["rows"] = [list(row) for row in data["rows"]] - else: - data["rows"] = [list(row.values()) for row in data["rows"]] + data["rows"] = [ + list(row.values()) if isinstance(row, dict) else list(row) + for row in data["rows"] + ] else: status_code = 400 data = error_body(f"Invalid _shape: {shape}", status_code) diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 58a5ddc3..73cad460 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1302,6 +1302,24 @@ async def test_infinity_returned_as_invalid_json_if_requested(ds_client): ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "shape,expected", + ( + ("arrays", {"ok": True, "rows": [[None], [None], [1.5]], "truncated": False}), + ("arrayfirst", [None, None, 1.5]), + ), +) +async def test_infinity_with_array_shapes(ds_client, shape, expected): + # Rows containing infinity are lists rather than sqlite3.Row objects + response = await ds_client.get( + "/fixtures/-/query.json?sql=select+value+from+infinity+order+by+rowid" + "&_shape=" + shape + ) + assert response.status_code == 200 + assert response.json() == expected + + @pytest.mark.asyncio async def test_custom_query_with_unicode_characters(ds_client): # /fixtures/𝐜𝐢𝐭𝐢𝐞𝐬.json From 28184edc52845fe0797f973628268a4dfb18f300 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 21:54:33 +0000 Subject: [PATCH 2/3] 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") From cb0159e6251141d1454728a351dce89ca42de386 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 22:05:39 +0000 Subject: [PATCH 3/3] Row JSON: drop truncated, render_cell is a single object The default row shape no longer includes "truncated", since a single row lookup cannot be truncated, and ?_extra=render_cell returns one object to match "row". With a ?_shape= both behave as they do for tables. Refs #2612 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GaqG8H8ajwxizKXdbGrtve --- datasette/renderer.py | 7 ++++++- datasette/views/table_extras.py | 9 +++++---- docs/json_api.rst | 10 ++++------ tests/test_api.py | 19 ++++++++++++------- tests/test_column_types.py | 2 +- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/datasette/renderer.py b/datasette/renderer.py index 1d957b8d..757ba594 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -58,7 +58,8 @@ def json_renderer(request, args, data, error, truncated=None, view_name=None): status_code = 400 data.update(error_body(error, status_code)) - if truncated is not None: + # A single row lookup cannot be truncated + if truncated is not None and not single_row: data["truncated"] = truncated if shape == "arrayfirst": # Rows can be dicts, sqlite3.Row or lists (from remove_infinites) @@ -78,6 +79,10 @@ def json_renderer(request, args, data, error, truncated=None, view_name=None): ("row", row) if key == "rows" else (key, value) for key, value in data.items() ) + # Likewise the render_cell extra becomes a single object + if "render_cell" in data: + cells = data["render_cell"] + data["render_cell"] = cells[0] if cells else {} if shape == "object": shape_error = None if "primary_keys" not in data: diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index 48eaa35f..bd56857b 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -648,12 +648,13 @@ class RenderCellExtra(Extra): ExtraScope.ROW: ExtraExample( value={ "row": {"id": 4, "content": "RENDER_CELL_DEMO"}, - "render_cell": [{"content": "Custom rendered HTML"}], + "render_cell": {"content": "Custom rendered HTML"}, }, note=( - "The ``render_cell`` array has one item for the requested row. " - "The object is keyed by column name. Only columns whose rendered " - "value differs from the default are included." + "The ``render_cell`` object is keyed by column name. Only columns " + "whose rendered value differs from the default are included. If a " + "``?_shape=`` is specified ``render_cell`` is instead a list with " + "one item, matching the ``rows`` list." ), ), ExtraScope.QUERY: ExtraExample( diff --git a/docs/json_api.rst b/docs/json_api.rst index 02bd6d9e..8627a974 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -960,7 +960,7 @@ The following extras are available for row JSON responses. ``render_cell`` Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook ` documentation.) - The ``render_cell`` array has one item for the requested row. The object is keyed by column name. Only columns whose rendered value differs from the default are included. + The ``render_cell`` object is keyed by column name. Only columns whose rendered value differs from the default are included. If a ``?_shape=`` is specified ``render_cell`` is instead a list with one item, matching the ``rows`` list. .. code-block:: json @@ -969,11 +969,9 @@ The following extras are available for row JSON responses. "id": 4, "content": "RENDER_CELL_DEMO" }, - "render_cell": [ - { - "content": "Custom rendered HTML" - } - ] + "render_cell": { + "content": "Custom rendered HTML" + } } ``debug`` diff --git a/tests/test_api.py b/tests/test_api.py index dd2365b6..8b02aeb9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -369,6 +369,7 @@ async def test_row(ds_client): assert data["ok"] is True assert data["row"] == {"id": 1, "content": "hello"} assert "rows" not in data + assert "truncated" not in data @pytest.mark.asyncio @@ -391,6 +392,7 @@ async def test_row_shape(ds_client, shape, expected): data = response.json() if shape in ("objects", "arrays"): assert "row" not in data + assert data["truncated"] is False data = data["rows"] assert data == expected @@ -650,20 +652,23 @@ async def test_row_extra_render_cell(): assert "render_cell" in data assert "row" in data - # render_cell should be a list with one row (since this is a row page) + # render_cell is a single object to match "row" # Only columns modified by plugins are included (sparse output) - render_cell = data["render_cell"] - assert len(render_cell) == 1 - - # The row: id=1, name='Alice' # The 'name' column should be rendered by our plugin as Alice - assert render_cell[0]["name"] == "Alice" # The 'id' column is not included since no plugin modified it - assert "id" not in render_cell[0] + assert data["render_cell"] == {"name": "Alice"} # The regular row should still contain raw values assert data["row"] == {"id": 1, "name": "Alice"} + # With a ?_shape= render_cell is a list, matching "rows" + response = await ds.client.get( + "/test_row_render/test_render/1.json?_extra=render_cell&_shape=objects" + ) + data = response.json() + assert data["render_cell"] == [{"name": "Alice"}] + assert data["rows"] == [{"id": 1, "name": "Alice"}] + finally: ds.pm.unregister(name="TestRenderCellPlugin") diff --git a/tests/test_column_types.py b/tests/test_column_types.py index d8dfc627..df20db00 100644 --- a/tests/test_column_types.py +++ b/tests/test_column_types.py @@ -748,7 +748,7 @@ async def test_row_endpoint_render_cell_with_column_types(ds_ct): response = await ds_ct.client.get("/data/posts/1.json?_extra=render_cell") assert response.status_code == 200 data = response.json() - rendered = data["render_cell"][0] + rendered = data["render_cell"] assert "mailto:" in rendered["author_email"] assert "href" in rendered["website"]