INSERT ... RETURNING shows rows in /-/execute-write

Screenshot: https://github.com/simonw/datasette/issues/2762#issuecomment-4588111545
This commit is contained in:
Simon Willison 2026-05-31 14:11:42 -07:00
commit c9e5115044
6 changed files with 138 additions and 37 deletions

View file

@ -0,0 +1,20 @@
{% if display_rows %}
<div class="table-wrapper"><table class="rows-and-columns">
<thead>
<tr>
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for column, td in zip(columns, row) %}
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% elif show_zero_results %}
<p class="zero-results">0 results</p>
{% endif %}

View file

@ -81,6 +81,17 @@ form.sql.core input[data-execute-write-submit]:disabled {
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
{% endif %}
{% if execute_write_returns_rows %}
<h2>Returned rows</h2>
{% if execute_write_truncated %}
<p class="message-warning">Only the first {{ "{:,}".format(execute_write_display_rows|length) }} returned rows are shown.</p>
{% endif %}
{% set columns = execute_write_columns %}
{% set display_rows = execute_write_display_rows %}
{% set show_zero_results = true %}
{% include "_query_results.html" %}
{% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
{% if write_template_tables %}
<div class="execute-write-template-menu">

View file

@ -73,27 +73,9 @@
{% if display_rows %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
<div class="table-wrapper"><table class="rows-and-columns">
<thead>
<tr>
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for column, td in zip(columns, row) %}
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
{% if not stored_query_write and not error %}
<p class="zero-results">0 results</p>
{% endif %}
{% endif %}
{% set show_zero_results = not stored_query_write and not error %}
{% include "_query_results.html" %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}

View file

@ -6,6 +6,7 @@ from datasette.utils import sqlite3
from datasette.utils.asgi import Response
from .base import BaseView, _error
from .database import display_rows as display_query_rows
from .query_helpers import (
QueryValidationError,
_analysis_is_write,
@ -221,10 +222,16 @@ class ExecuteWriteView(BaseView):
execution_message=None,
execution_links=None,
execution_ok=None,
execute_write_returns_rows=False,
execute_write_columns=None,
execute_write_display_rows=None,
execute_write_truncated=False,
status=200,
):
parameter_values = parameter_values or {}
execution_links = execution_links or []
execute_write_columns = execute_write_columns or []
execute_write_display_rows = execute_write_display_rows or []
parameter_names = []
analysis_rows = []
table_columns = await _table_columns(self.ds, db.name)
@ -284,6 +291,10 @@ class ExecuteWriteView(BaseView):
"execution_message": execution_message,
"execution_links": execution_links,
"execution_ok": execution_ok,
"execute_write_returns_rows": execute_write_returns_rows,
"execute_write_columns": execute_write_columns,
"execute_write_display_rows": execute_write_display_rows,
"execute_write_truncated": execute_write_truncated,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
"table_columns": table_columns,
@ -358,8 +369,6 @@ class ExecuteWriteView(BaseView):
wants_json = _wants_json(request, is_json, data)
try:
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)
@ -402,6 +411,20 @@ class ExecuteWriteView(BaseView):
if inserted_row_url
else []
)
execute_write_returns_rows = cursor.description is not None
execute_write_columns = []
execute_write_display_rows = []
if execute_write_returns_rows:
execute_write_columns = [
description[0] for description in cursor.description
]
execute_write_display_rows = await display_query_rows(
self.ds,
db.name,
request,
cursor.fetchall(),
execute_write_columns,
)
return await self._render_form(
request,
db,
@ -411,6 +434,10 @@ class ExecuteWriteView(BaseView):
execution_message=message,
execution_links=execution_links,
execution_ok=True,
execute_write_returns_rows=execute_write_returns_rows,
execute_write_columns=execute_write_columns,
execute_write_display_rows=execute_write_display_rows,
execute_write_truncated=cursor.truncated,
)

View file

@ -584,7 +584,7 @@ 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:
the execute-write returning row limit, which defaults to 10:
.. code-block:: json

View file

@ -1922,18 +1922,14 @@ async def test_execute_write_json_includes_returning_rows():
@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 = Datasette(memory=True, default_deny=True)
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')"
)
for index in range(1, 12):
await db.execute_write(
"insert into dogs (name) values (?)", ["Dog {}".format(index)]
)
await ds.invoke_startup()
response = await ds.client.post(
@ -1945,16 +1941,81 @@ async def test_execute_write_json_returning_rows_can_be_truncated():
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["message"] == "Query executed"
assert data["rowcount"] == -1
assert data["message"] == "Query executed, 11 rows affected"
assert data["rowcount"] == 11
assert data["rows"] == [
{"id": 1, "name": "Cleo!"},
{"id": 2, "name": "Pancakes!"},
{"id": index, "name": "Dog {}!".format(index)} for index in range(1, 11)
]
assert data["truncated"] is True
assert (await db.execute("select count(*) from dogs where name like '%!'")).first()[
0
] == 4
] == 11
@pytest.mark.asyncio
async def test_execute_write_html_displays_returning_rows():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_returning_html", 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"},
data={
"sql": "insert into dogs (name) values (:name) returning id, name",
"name": "Cleo",
},
)
non_returning_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
data={"sql": "insert into dogs (name) values ('Pancakes')"},
)
assert response.status_code == 200
assert "Query executed, 1 row affected" in response.text
assert "<h2>Returned rows</h2>" in response.text
assert '<table class="rows-and-columns">' in response.text
assert '<th class="col-id" scope="col">id</th>' in response.text
assert '<th class="col-name" scope="col">name</th>' in response.text
assert '<td class="col-id">1</td>' in response.text
assert '<td class="col-name">Cleo</td>' in response.text
assert non_returning_response.status_code == 200
assert "Query executed, 1 row affected" in non_returning_response.text
assert "<h2>Returned rows</h2>" not in non_returning_response.text
assert '<p class="zero-results">0 results</p>' not in non_returning_response.text
@pytest.mark.asyncio
async def test_execute_write_html_returning_rows_can_be_truncated():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_returning_html_truncated", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
for index in range(1, 12):
await db.execute_write(
"insert into dogs (name) values (?)", ["Dog {}".format(index)]
)
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
data={"sql": "update dogs set name = name || '!' returning id, name"},
)
assert response.status_code == 200
assert "<h2>Returned rows</h2>" in response.text
assert "Only the first 10 returned rows are shown." in response.text
assert '<td class="col-id">1</td>' in response.text
assert '<td class="col-name">Dog 1!</td>' in response.text
assert '<td class="col-id">10</td>' in response.text
assert '<td class="col-name">Dog 10!</td>' in response.text
assert '<td class="col-id">11</td>' not in response.text
assert '<td class="col-name">Dog 11!</td>' not in response.text
@pytest.mark.parametrize(