mirror of
https://github.com/simonw/datasette.git
synced 2026-06-01 14:47:00 +02:00
Fixes for SQL write with RETURNING (#2763)
* Fix for execute write returning, closes #2762 * Fix stored write returning rowcount message * Add configurable execute_write returning limit * Return rows/truncated from execute query if it used RETURNING * INSERT ... RETURNING shows rows in /-/execute-write * Skip RETURNING tests if SQLite version does not support it Screenshot: https://github.com/simonw/datasette/issues/2762#issuecomment-4588111545
This commit is contained in:
parent
1558ab7989
commit
b1f3e4368c
12 changed files with 622 additions and 47 deletions
|
|
@ -31,6 +31,8 @@ from .inspect import inspect_hash
|
|||
|
||||
connections = threading.local()
|
||||
|
||||
EXECUTE_WRITE_RETURNING_LIMIT = 10
|
||||
|
||||
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
|
||||
|
||||
|
||||
|
|
@ -236,11 +238,24 @@ class Database:
|
|||
except OSError:
|
||||
pass
|
||||
|
||||
async def execute_write(self, sql, params=None, block=True, request=None):
|
||||
async def execute_write(
|
||||
self,
|
||||
sql,
|
||||
params=None,
|
||||
block=True,
|
||||
request=None,
|
||||
return_all=False,
|
||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||
):
|
||||
self._check_not_closed()
|
||||
if returning_limit < 0:
|
||||
raise ValueError("returning_limit must be >= 0")
|
||||
|
||||
def _inner(conn):
|
||||
return conn.execute(sql, params or [])
|
||||
cursor = conn.execute(sql, params or [])
|
||||
return ExecuteWriteResult.from_cursor(
|
||||
cursor, return_all=return_all, returning_limit=returning_limit
|
||||
)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||
|
|
@ -877,6 +892,44 @@ class MultipleValues(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class ExecuteWriteResult:
|
||||
def __init__(self, rowcount, lastrowid, description, rows, truncated):
|
||||
self.rowcount = rowcount
|
||||
self.lastrowid = lastrowid
|
||||
self.description = description
|
||||
self.truncated = truncated
|
||||
self._rows = rows
|
||||
|
||||
@classmethod
|
||||
def from_cursor(
|
||||
cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT
|
||||
):
|
||||
rows = []
|
||||
truncated = False
|
||||
description = cursor.description
|
||||
lastrowid = cursor.lastrowid
|
||||
try:
|
||||
if description is not None:
|
||||
if return_all:
|
||||
rows = cursor.fetchall()
|
||||
else:
|
||||
rows = cursor.fetchmany(returning_limit + 1)
|
||||
if len(rows) > returning_limit:
|
||||
rows = rows[:returning_limit]
|
||||
truncated = True
|
||||
rowcount = cursor.rowcount
|
||||
finally:
|
||||
cursor.close()
|
||||
if description is not None and not return_all and truncated:
|
||||
rowcount = -1
|
||||
return cls(rowcount, lastrowid, description, rows, truncated)
|
||||
|
||||
def fetchall(self):
|
||||
rows = self._rows
|
||||
self._rows = []
|
||||
return rows
|
||||
|
||||
|
||||
class Results:
|
||||
def __init__(self, rows, truncated, description):
|
||||
self.rows = rows
|
||||
|
|
|
|||
20
datasette/templates/_query_results.html
Normal file
20
datasette/templates/_query_results.html
Normal 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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
|
|||
sqlite3.enable_callback_tracebacks(True)
|
||||
|
||||
_cached_sqlite_version = None
|
||||
_cached_supports_returning = None
|
||||
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
|
||||
_VIRTUAL_TABLE_MODULE_RE = re.compile(
|
||||
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
|
||||
|
|
@ -59,6 +60,21 @@ def supports_generated_columns():
|
|||
return sqlite_version() >= (3, 31, 0)
|
||||
|
||||
|
||||
def supports_returning():
|
||||
global _cached_supports_returning
|
||||
if _cached_supports_returning is None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute("create table t (id integer primary key)")
|
||||
conn.execute("insert into t default values returning id").fetchone()
|
||||
_cached_supports_returning = True
|
||||
except sqlite3.DatabaseError:
|
||||
_cached_supports_returning = False
|
||||
finally:
|
||||
conn.close()
|
||||
return _cached_supports_returning
|
||||
|
||||
|
||||
def sqlite_table_type(
|
||||
conn,
|
||||
table: str,
|
||||
|
|
|
|||
|
|
@ -528,12 +528,14 @@ class QueryView(View):
|
|||
message = "Error running on_success_message_sql: {}".format(ex)
|
||||
message_type = datasette.ERROR
|
||||
if not message:
|
||||
message = (
|
||||
stored_query.on_success_message
|
||||
or "Query executed, {} row{} affected".format(
|
||||
if stored_query.on_success_message:
|
||||
message = stored_query.on_success_message
|
||||
elif cursor.rowcount == -1:
|
||||
message = "Query executed"
|
||||
else:
|
||||
message = "Query executed, {} row{} affected".format(
|
||||
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
||||
)
|
||||
)
|
||||
|
||||
redirect_url = stored_query.on_success_redirect
|
||||
ok = True
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -355,11 +366,13 @@ 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}
|
||||
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 +391,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 = (
|
||||
|
|
@ -396,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,
|
||||
|
|
@ -405,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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1928,8 +1928,8 @@ Example usage:
|
|||
|
||||
.. _database_execute_write:
|
||||
|
||||
await db.execute_write(sql, params=None, block=True)
|
||||
----------------------------------------------------
|
||||
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10)
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
|
||||
|
||||
|
|
@ -1937,7 +1937,30 @@ This method can be used to queue up a non-SELECT SQL query to be executed agains
|
|||
|
||||
You can pass additional SQL parameters as a tuple or dictionary.
|
||||
|
||||
The method will block until the operation is completed, and the return value will be the return from calling ``conn.execute(...)`` using the underlying ``sqlite3`` Python library.
|
||||
The optional ``request=`` argument is used internally by Datasette to pass request context to :ref:`write_wrapper plugin hooks <plugin_hook_write_wrapper>`.
|
||||
|
||||
The method will block until the operation is completed, and the return value will be an ``ExecuteWriteResult`` object. This imitates a subset of the ``sqlite3.Cursor`` object:
|
||||
|
||||
``.rowcount``
|
||||
The number of rows modified by the statement, or ``-1`` if that number is unavailable.
|
||||
|
||||
``.lastrowid``
|
||||
The row ID of the last modified row, as returned by ``sqlite3.Cursor.lastrowid``.
|
||||
|
||||
``.description``
|
||||
The same column metadata exposed by Python's `sqlite3.Cursor.description <https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.description>`__: one tuple per returned column, or ``None`` if the statement does not return rows.
|
||||
|
||||
``.truncated``
|
||||
``True`` if the statement returned more rows than ``returning_limit``.
|
||||
|
||||
``.fetchall()``
|
||||
Returns any rows buffered by Datasette from the statement, such as rows from SQLite's ``RETURNING`` clause. This may be limited by ``returning_limit`` unless ``return_all=True`` was used. This method empties the buffer, so calling it again will return an empty list.
|
||||
|
||||
SQLite statements using ``RETURNING`` must have their rows consumed before the transaction can commit. Datasette will fetch up to ``returning_limit + 1`` rows before committing, store up to ``returning_limit`` rows on the result object and set ``.truncated`` if there were more. The default ``returning_limit`` is ``10``.
|
||||
|
||||
When ``.truncated`` is ``True``, ``.rowcount`` will be ``-1``. SQLite only reports the final row count for a ``RETURNING`` statement after every returned row has been fetched, and Datasette has deliberately stopped fetching rows after ``returning_limit`` to avoid buffering a potentially large result in memory.
|
||||
|
||||
If you need to retrieve every row returned by a statement, pass ``return_all=True``. This will buffer all returned rows in memory before committing.
|
||||
|
||||
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 execute-write returning row limit, which defaults to 10:
|
||||
|
||||
.. 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
|
||||
|
|
|
|||
|
|
@ -5,15 +5,19 @@ Tests for the datasette.database.Database class
|
|||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from datasette.app import Datasette
|
||||
from datasette.database import Database, Results, MultipleValues
|
||||
from datasette.database import Database, ExecuteWriteResult, Results, MultipleValues
|
||||
from datasette.database import DatasetteClosedError
|
||||
from datasette.database import _deliver_write_result
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils.sqlite import sqlite3, supports_returning
|
||||
from datasette.utils import Column
|
||||
import pytest
|
||||
import time
|
||||
import uuid
|
||||
|
||||
requires_sqlite_returning = pytest.mark.skipif(
|
||||
not supports_returning(), reason="SQLite does not support RETURNING"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(app_client):
|
||||
|
|
@ -469,13 +473,142 @@ async def test_view_names(db):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_block_true(db):
|
||||
await db.execute_write(
|
||||
result = await db.execute_write(
|
||||
"update roadside_attractions set name = ? where pk = ?", ["Mystery!", 1]
|
||||
)
|
||||
rows = await db.execute("select name from roadside_attractions where pk = 1")
|
||||
assert result.rowcount == 1
|
||||
assert result.description is None
|
||||
assert result.truncated is False
|
||||
assert result.fetchall() == []
|
||||
assert "Mystery!" == rows.rows[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_with_returning(db):
|
||||
await db.execute_write(
|
||||
"create table write_returning (id integer primary key, name text)"
|
||||
)
|
||||
result = await db.execute_write(
|
||||
"insert into write_returning (name) values (?) returning id, name",
|
||||
["Cleo"],
|
||||
)
|
||||
|
||||
assert result.rowcount == 1
|
||||
assert result.lastrowid == 1
|
||||
assert [column[0] for column in result.description] == ["id", "name"]
|
||||
assert result.truncated is False
|
||||
assert [dict(row) for row in result.fetchall()] == [{"id": 1, "name": "Cleo"}]
|
||||
assert result.fetchall() == []
|
||||
assert (await db.execute("select id, name from write_returning")).dicts() == [
|
||||
{"id": 1, "name": "Cleo"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_with_returning_default_limit(db):
|
||||
await db.execute_write(
|
||||
"create table write_returning_limit (id integer primary key)"
|
||||
)
|
||||
await db.execute_write_many(
|
||||
"insert into write_returning_limit (id) values (?)",
|
||||
[(i,) for i in range(1, 21)],
|
||||
)
|
||||
|
||||
result = await db.execute_write(
|
||||
"update write_returning_limit set id = id returning id"
|
||||
)
|
||||
|
||||
assert result.rowcount == -1
|
||||
assert result.truncated is True
|
||||
assert len(result.fetchall()) == 10
|
||||
assert (
|
||||
await db.execute("select count(*) from write_returning_limit")
|
||||
).single_value() == 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_with_returning_custom_limit(db):
|
||||
await db.execute_write(
|
||||
"create table write_returning_custom (id integer primary key)"
|
||||
)
|
||||
await db.execute_write_many(
|
||||
"insert into write_returning_custom (id) values (?)",
|
||||
[(i,) for i in range(1, 6)],
|
||||
)
|
||||
|
||||
result = await db.execute_write(
|
||||
"update write_returning_custom set id = id returning id",
|
||||
returning_limit=2,
|
||||
)
|
||||
|
||||
assert result.rowcount == -1
|
||||
assert result.truncated is True
|
||||
assert [row["id"] for row in result.fetchall()] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_with_returning_exact_default_limit(db):
|
||||
await db.execute_write(
|
||||
"create table write_returning_exact_limit (id integer primary key)"
|
||||
)
|
||||
await db.execute_write_many(
|
||||
"insert into write_returning_exact_limit (id) values (?)",
|
||||
[(i,) for i in range(1, 11)],
|
||||
)
|
||||
|
||||
result = await db.execute_write(
|
||||
"update write_returning_exact_limit set id = id returning id"
|
||||
)
|
||||
|
||||
assert result.rowcount == 10
|
||||
assert result.truncated is False
|
||||
assert len(result.fetchall()) == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_with_returning_one_more_than_default_limit(db):
|
||||
await db.execute_write(
|
||||
"create table write_returning_one_more (id integer primary key)"
|
||||
)
|
||||
await db.execute_write_many(
|
||||
"insert into write_returning_one_more (id) values (?)",
|
||||
[(i,) for i in range(1, 12)],
|
||||
)
|
||||
|
||||
result = await db.execute_write(
|
||||
"update write_returning_one_more set id = id returning id"
|
||||
)
|
||||
|
||||
assert result.rowcount == -1
|
||||
assert result.truncated is True
|
||||
assert len(result.fetchall()) == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_with_returning_return_all(db):
|
||||
await db.execute_write("create table write_returning_all (id integer primary key)")
|
||||
await db.execute_write_many(
|
||||
"insert into write_returning_all (id) values (?)",
|
||||
[(i,) for i in range(1, 21)],
|
||||
)
|
||||
|
||||
result = await db.execute_write(
|
||||
"update write_returning_all set id = id returning id",
|
||||
return_all=True,
|
||||
)
|
||||
|
||||
assert result.rowcount == 20
|
||||
assert result.truncated is False
|
||||
assert [row["id"] for row in result.fetchall()] == list(range(1, 21))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_block_false(db):
|
||||
await db.execute_write(
|
||||
|
|
@ -487,6 +620,48 @@ async def test_execute_write_block_false(db):
|
|||
assert "Mystery!" == rows.rows[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_with_returning_block_false(db):
|
||||
await db.execute_write(
|
||||
"create table write_returning_block_false (id integer primary key, name text)"
|
||||
)
|
||||
task_id = await db.execute_write(
|
||||
"insert into write_returning_block_false (name) values (?) returning id",
|
||||
["Cleo"],
|
||||
block=False,
|
||||
)
|
||||
|
||||
assert isinstance(task_id, uuid.UUID)
|
||||
time.sleep(0.1)
|
||||
assert (
|
||||
await db.execute("select name from write_returning_block_false")
|
||||
).single_value() == "Cleo"
|
||||
|
||||
|
||||
def test_execute_write_result_closes_cursor_on_fetch_error():
|
||||
class Cursor:
|
||||
description = (("id", None, None, None, None, None, None),)
|
||||
lastrowid = 1
|
||||
rowcount = 0
|
||||
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def fetchmany(self, size):
|
||||
raise sqlite3.DatabaseError("fetch failed")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
cursor = Cursor()
|
||||
|
||||
with pytest.raises(sqlite3.DatabaseError):
|
||||
ExecuteWriteResult.from_cursor(cursor)
|
||||
|
||||
assert cursor.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_write_script(db):
|
||||
await db.execute_write_script(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ from datasette.app import Datasette
|
|||
from datasette.resources import DatabaseResource, QueryResource
|
||||
from datasette.stored_queries import StoredQuery, StoredQueryPage
|
||||
from datasette.utils.asgi import Forbidden
|
||||
from datasette.utils.sqlite import supports_returning
|
||||
|
||||
requires_sqlite_returning = pytest.mark.skipif(
|
||||
not supports_returning(), reason="SQLite does not support RETURNING"
|
||||
)
|
||||
|
||||
|
||||
def _template_option_attributes(html, table):
|
||||
|
|
@ -1884,10 +1889,144 @@ 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
|
||||
@requires_sqlite_returning
|
||||
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
|
||||
@requires_sqlite_returning
|
||||
async def test_execute_write_json_returning_rows_can_be_truncated():
|
||||
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)")
|
||||
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"},
|
||||
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": 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
|
||||
] == 11
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
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
|
||||
@requires_sqlite_returning
|
||||
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(
|
||||
"database_name, sql",
|
||||
(
|
||||
|
|
@ -3002,3 +3141,65 @@ async def test_user_writable_query_execution_rechecks_table_permissions():
|
|||
assert denied_response.status_code == 403
|
||||
rows = (await db.execute("select name from dogs")).dicts()
|
||||
assert rows == [{"name": "Cleo"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_stored_write_query_with_returning():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_write_returning", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
await ds.add_query(
|
||||
"data",
|
||||
"insert_dog",
|
||||
"insert into dogs (name) values (:name) returning id, name",
|
||||
is_write=True,
|
||||
source="user",
|
||||
owner_id="root",
|
||||
)
|
||||
|
||||
response = await ds.client.post(
|
||||
"/data/insert_dog?_json=1",
|
||||
actor={"id": "root"},
|
||||
data={"name": "Cleo"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert (await db.execute("select id, name from dogs")).dicts() == [
|
||||
{"id": 1, "name": "Cleo"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@requires_sqlite_returning
|
||||
async def test_stored_write_query_with_truncated_returning_message():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_write_truncated_returning", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await db.execute_write_many(
|
||||
"insert into dogs (name) values (?)",
|
||||
[("Cleo",) for _ in range(20)],
|
||||
)
|
||||
await ds.invoke_startup()
|
||||
await ds.add_query(
|
||||
"data",
|
||||
"update_dogs",
|
||||
"update dogs set name = name returning id",
|
||||
is_write=True,
|
||||
source="user",
|
||||
owner_id="root",
|
||||
)
|
||||
|
||||
response = await ds.client.post(
|
||||
"/data/update_dogs?_json=1",
|
||||
actor={"id": "root"},
|
||||
data={},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert response.json()["message"] == "Query executed"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ Tests for various datasette helper functions.
|
|||
from datasette.app import Datasette
|
||||
from datasette import utils
|
||||
from datasette.utils.asgi import Request
|
||||
from datasette.utils.sqlite import sqlite3, sqlite_hidden_table_names, sqlite_table_type
|
||||
from datasette.utils.sqlite import (
|
||||
sqlite3,
|
||||
sqlite_hidden_table_names,
|
||||
sqlite_table_type,
|
||||
supports_returning,
|
||||
)
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
|
|
@ -226,6 +231,20 @@ def test_detect_fts_different_table_names(table):
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_supports_returning():
|
||||
conn = utils.sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute("create table t (id integer primary key)")
|
||||
conn.execute("insert into t default values returning id").fetchone()
|
||||
expected = True
|
||||
except sqlite3.DatabaseError:
|
||||
expected = False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert supports_returning() is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_fallback", (False, True))
|
||||
def test_sqlite_table_type_detects_virtual_and_shadow_tables(monkeypatch, use_fallback):
|
||||
if use_fallback:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue