Return rows/truncated from execute query if it used RETURNING

Refs https://github.com/simonw/datasette/issues/2762#issuecomment-4588066704
This commit is contained in:
Simon Willison 2026-05-31 14:02:27 -07:00
commit 9b6f8ce2a7
3 changed files with 129 additions and 14 deletions

View file

@ -355,11 +355,15 @@ class ExecuteWriteView(BaseView):
status=ex.status,
)
wants_json = _wants_json(request, is_json, data)
try:
cursor = await db.execute_write(sql, params, request=request)
execute_write_kwargs = {"request": request}
if wants_json:
execute_write_kwargs["returning_limit"] = self.ds.max_returned_rows
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
except sqlite3.DatabaseError as ex:
message = str(ex)
if _wants_json(request, is_json, data):
if wants_json:
return _block_framing(_error([message], 400))
return await self._render_form(
request,
@ -378,17 +382,19 @@ class ExecuteWriteView(BaseView):
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
if _wants_json(request, is_json, data):
return _block_framing(
Response.json(
{
"ok": True,
"message": message,
"rowcount": cursor.rowcount,
"analysis": _analysis_rows(analysis),
}
)
)
if wants_json:
data = {
"ok": True,
"message": message,
"rowcount": cursor.rowcount,
"rows": [],
"truncated": False,
"analysis": _analysis_rows(analysis),
}
if cursor.description is not None:
data["rows"] = [dict(row) for row in cursor.fetchall()]
data["truncated"] = cursor.truncated
return _block_framing(Response.json(data))
inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor)
execution_links = (

View file

@ -554,7 +554,8 @@ Datasette analyzes the SQL before executing it. The actor must have ``execute-wr
Unsupported SQL operations are rejected by default. ``VACUUM`` is not allowed in arbitrary write SQL, and writes to SQLite virtual tables or shadow tables are rejected. SQL functions are allowed and are not separately restricted by Datasette permissions.
A successful response includes a message, the SQLite ``rowcount`` and a summary of the operations that were executed:
A successful response includes a message, the SQLite ``rowcount``, a ``"rows"``
list, a ``"truncated"`` flag and a summary of the operations that were executed:
The shape of the ``"analysis"`` block is not yet considered a stable API and may change in future Datasette releases.
@ -564,6 +565,8 @@ The shape of the ``"analysis"`` block is not yet considered a stable API and may
"ok": true,
"message": "Query executed, 1 row affected",
"rowcount": 1,
"rows": [],
"truncated": false,
"analysis": [
{
"operation": "insert",
@ -577,6 +580,43 @@ The shape of the ``"analysis"`` block is not yet considered a stable API and may
If SQLite reports ``-1`` for the row count, the message will be ``"Query executed"``.
For most write statements ``"rows"`` will be an empty list and ``"truncated"``
will be ``false``. If the SQL uses SQLite's ``RETURNING`` clause, ``"rows"``
will contain returned rows using the same default representation as table and
query JSON responses. ``"truncated"`` indicates if more rows were returned than
the configured :ref:`setting_max_returned_rows` limit:
.. code-block:: json
{
"ok": true,
"message": "Query executed, 1 row affected",
"rowcount": 1,
"rows": [
{
"id": 1,
"name": "Cleo"
}
],
"truncated": false,
"analysis": [
{
"operation": "insert",
"database": "data",
"table": "dogs",
"required_permission": "insert-row, update-row, delete-row",
"source": null
},
{
"operation": "read",
"database": "data",
"table": "dogs",
"required_permission": "view-table",
"source": null
}
]
}
Errors use the standard Datasette error format:
.. code-block:: json

View file

@ -1884,10 +1884,79 @@ async def test_execute_write_post_requires_database_and_table_permissions():
assert allowed.status_code == 200
assert allowed.json()["ok"] is True
assert allowed.json()["rowcount"] == 1
assert allowed.json()["rows"] == []
assert allowed.json()["truncated"] is False
assert allowed.json()["analysis"][0]["operation"] == "insert"
assert (await db.execute("select name from dogs")).first()[0] == "Cleo"
@pytest.mark.asyncio
async def test_execute_write_json_includes_returning_rows():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_returning_json", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
json={
"sql": "insert into dogs (name) values (:name) returning id, name",
"params": {"name": "Cleo"},
},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["message"] == "Query executed, 1 row affected"
assert data["rowcount"] == 1
assert data["rows"] == [{"id": 1, "name": "Cleo"}]
assert data["truncated"] is False
assert [row["operation"] for row in data["analysis"]] == ["insert", "read"]
assert (await db.execute("select id, name from dogs")).dicts() == [
{"id": 1, "name": "Cleo"}
]
@pytest.mark.asyncio
async def test_execute_write_json_returning_rows_can_be_truncated():
ds = Datasette(
memory=True,
default_deny=True,
settings={"max_returned_rows": 2},
)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_returning_json_truncated", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await db.execute_write(
"insert into dogs (name) values "
"('Cleo'), ('Pancakes'), ('Nixie'), ('Marnie')"
)
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
json={"sql": "update dogs set name = name || '!' returning id, name"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["message"] == "Query executed"
assert data["rowcount"] == -1
assert data["rows"] == [
{"id": 1, "name": "Cleo!"},
{"id": 2, "name": "Pancakes!"},
]
assert data["truncated"] is True
assert (await db.execute("select count(*) from dogs where name like '%!'")).first()[
0
] == 4
@pytest.mark.parametrize(
"database_name, sql",
(