diff --git a/datasette/renderer.py b/datasette/renderer.py
index 0e01f52f..757ba594 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
@@ -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)
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..bd56857b 100644
--- a/datasette/views/table_extras.py
+++ b/datasette/views/table_extras.py
@@ -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": "Custom rendered HTML"}],
+ "row": {"id": 4, "content": "RENDER_CELL_DEMO"},
+ "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 73212e70..8627a974 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
@@ -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 ` 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": "Custom rendered HTML"
- }
- ]
+ "row": {
+ "id": 4,
+ "content": "RENDER_CELL_DEMO"
+ },
+ "render_cell": {
+ "content": "Custom rendered HTML"
+ }
}
``debug``
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..8b02aeb9 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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 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 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": "Alice"}]
assert data["rows"] == [{"id": 1, "name": "Alice"}]
finally:
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"]
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