This commit is contained in:
Simon Willison 2026-10-12 18:18:01 +00:00 • committed by GitHub
commit 3fe43cd22c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 151 additions and 48 deletions

View file

@ -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
@ -30,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
@ -49,28 +48,41 @@ 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))
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":
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")
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()
)
# 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:
@ -94,12 +106,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)

View file

@ -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 || {};

View file

@ -647,13 +647,14 @@ class RenderCellExtra(Extra):
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
ExtraScope.ROW: ExtraExample(
value={
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
"render_cell": [{"content": "<strong>Custom rendered HTML</strong>"}],
"row": {"id": 4, "content": "RENDER_CELL_DEMO"},
"render_cell": {"content": "<strong>Custom rendered HTML</strong>"},
},
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(

View file

@ -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 <json_api_shapes>` 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
@ -937,22 +960,18 @@ 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 <plugin_hook_render_cell>` 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
{
"rows": [
{
"id": 4,
"content": "RENDER_CELL_DEMO"
}
],
"render_cell": [
{
"content": "<strong>Custom rendered HTML</strong>"
}
]
"row": {
"id": 4,
"content": "RENDER_CELL_DEMO"
},
"render_cell": {
"content": "<strong>Custom rendered HTML</strong>"
}
}
``debug``

View file

@ -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

View file

@ -363,10 +363,45 @@ 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
assert "truncated" 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
assert data["truncated"] is False
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,20 +650,23 @@ 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)
# 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 <strong>Alice</strong>
assert render_cell[0]["name"] == "<strong>Alice</strong>"
# The 'id' column is not included since no plugin modified it
assert "id" not in render_cell[0]
assert data["render_cell"] == {"name": "<strong>Alice</strong>"}
# The regular rows should still contain raw values
# 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": "<strong>Alice</strong>"}]
assert data["rows"] == [{"id": 1, "name": "Alice"}]
finally:

View file

@ -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"]

View file

@ -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