From 9b6f8ce2a789a1fc5b885e584a9728ec627e118d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 31 May 2026 14:02:27 -0700 Subject: [PATCH] Return rows/truncated from execute query if it used RETURNING Refs https://github.com/simonw/datasette/issues/2762#issuecomment-4588066704 --- datasette/views/execute_write.py | 32 +++++++++------ docs/json_api.rst | 42 ++++++++++++++++++- tests/test_queries.py | 69 ++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/datasette/views/execute_write.py b/datasette/views/execute_write.py index cff20847..5d3912dd 100644 --- a/datasette/views/execute_write.py +++ b/datasette/views/execute_write.py @@ -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 = ( diff --git a/docs/json_api.rst b/docs/json_api.rst index 4bd76717..a8384d0b 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -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 diff --git a/tests/test_queries.py b/tests/test_queries.py index 6105b860..cf7e727c 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -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", (