mirror of
https://github.com/simonw/datasette.git
synced 2026-10-18 07:17:00 +02:00
Merge cb0159e625 into d0836ddedc
This commit is contained in:
commit
3fe43cd22c
8 changed files with 151 additions and 48 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue