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")