From e08b11217a344d83e1d1104e2821f015dc2b3c85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:04:25 +0000 Subject: [PATCH 001/203] Bump the python-packages group with 3 updates Bumps the python-packages group with 3 updates: [black](https://github.com/psf/black), [sphinx](https://github.com/sphinx-doc/sphinx) and [furo](https://github.com/pradyunsg/furo). Updates `black` from 25.11.0 to 25.12.0 - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/25.11.0...25.12.0) Updates `sphinx` from 7.4.7 to 9.1.0 - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.4.7...v9.1.0) Updates `furo` from 2025.9.25 to 2025.12.19 - [Release notes](https://github.com/pradyunsg/furo/releases) - [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md) - [Commits](https://github.com/pradyunsg/furo/compare/2025.09.25...2025.12.19) --- updated-dependencies: - dependency-name: black dependency-version: 25.12.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-packages - dependency-name: sphinx dependency-version: 9.1.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: python-packages - dependency-name: furo dependency-version: 2025.12.19 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-packages ... Signed-off-by: dependabot[bot] --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 87884341..f8ddc3fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,14 +61,14 @@ dev = [ "pytest-xdist>=2.2.1", "pytest-asyncio>=1.2.0", "beautifulsoup4>=4.8.1", - "black==25.11.0", + "black==25.12.0", "blacken-docs==1.20.0", "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", # docs - "Sphinx==7.4.7", - "furo==2025.9.25", + "Sphinx==9.1.0", + "furo==2025.12.19", "sphinx-autobuild", "codespell>=2.2.5", "sphinx-copybutton", From b0436faa5e3c35977607da6a653425fc6bf43403 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 22 Jan 2026 07:03:05 -0800 Subject: [PATCH 002/203] Fix test isolation bug in test_startup_error_from_plugin_is_click_exception (#2627) * Fix test isolation bug in test_startup_error_from_plugin_is_click_exception The test creates a plugin that raises StartupError("boom") and registers it in the global plugin manager (pm). Without cleanup, this plugin leaks to subsequent tests, causing test_setting_boolean_validation_false_values to fail with "Error: boom" instead of "Forbidden". Add try/finally block to ensure the plugin is unregistered after the test completes, following the established cleanup pattern used elsewhere in the test suite. * Fix blacken-docs formatting in plugin_hooks.rst Apply blacken-docs formatting to code example that exceeded the 60 character line limit. --------- Co-authored-by: Claude --- docs/plugin_hooks.rst | 5 ++++- tests/test_cli.py | 14 +++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index da49811a..ad4a70f8 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -967,11 +967,14 @@ Here is an example that validates required plugin configuration. The server will from datasette.utils import StartupError + @hookimpl def startup(datasette): config = datasette.plugin_config("my-plugin") or {} if "required-setting" not in config: - raise StartupError("my-plugin requires setting required-setting") + raise StartupError( + "my-plugin requires setting required-setting" + ) You can also return an async function, which will be awaited on startup. Use this option if you need to execute any database queries, for example this function which creates the ``my_table`` database table if it does not yet exist: diff --git a/tests/test_cli.py b/tests/test_cli.py index 36d90e82..6cdfd924 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ from .fixtures import ( EXPECTED_PLUGINS, ) from datasette.app import SETTINGS -from datasette.plugins import DEFAULT_PLUGINS +from datasette.plugins import DEFAULT_PLUGINS, pm from datasette.cli import cli, serve from datasette.version import __version__ from datasette.utils import tilde_encode @@ -326,8 +326,16 @@ def test_startup_error_from_plugin_is_click_exception(tmp_path): "/", ], ) - assert result.exit_code == 1 - assert "Error: boom" in result.output + try: + assert result.exit_code == 1 + assert "Error: boom" in result.output + finally: + # Cleanup: Unregister the plugin to avoid test isolation issues + to_unregister = [ + p for p in pm.get_plugins() if p.__name__ == "startup_error.py" + ] + if to_unregister: + pm.unregister(to_unregister[0]) def test_setting_type_validation(): From 66d2a033f8ad124e08cf4f0b488454c76dfdb63f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 23 Jan 2026 20:43:16 -0800 Subject: [PATCH 003/203] Switch to ruff and fix all lint errors, refs #2630 --- .github/workflows/test.yml | 2 ++ Justfile | 12 +++++++---- datasette/app.py | 4 ++-- datasette/default_permissions/__init__.py | 18 ++++++++-------- datasette/views/base.py | 1 - pyproject.toml | 5 +++++ setup.cfg | 3 --- tests/test_allowed_resources.py | 1 - tests/test_api.py | 26 ++++++----------------- tests/test_config_dir.py | 2 +- tests/test_crossdb.py | 2 +- tests/test_csv.py | 6 ------ tests/test_filters.py | 21 ------------------ tests/test_html.py | 9 +------- tests/test_internals_datasette.py | 2 +- tests/test_permissions.py | 3 +-- tests/test_plugins.py | 6 ++---- tests/test_restriction_sql.py | 4 ++-- tests/test_schema_endpoints.py | 1 - tests/test_table_api.py | 9 +------- tests/test_table_html.py | 8 ++----- 21 files changed, 44 insertions(+), 101 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3790c788..b1ba3232 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,8 @@ jobs: tests/test_datasette_https_server.sh - name: Black run: black --check . + - name: Ruff + run: ruff check datasette tests - name: Check if cog needs to be run run: | cog --check docs/*.rst diff --git a/Justfile b/Justfile index 8c50e5ca..657881be 100644 --- a/Justfile +++ b/Justfile @@ -17,12 +17,16 @@ export DATASETTE_SECRET := "not_a_secret" uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt uv run codespell tests --ignore-words docs/codespell-ignore-words.txt -# Run linters: black, flake8, mypy, cog +# Run linters: black, ruff, cog @lint: codespell - uv run black . --check - uv run flake8 + uv run black datasette tests --check + uv run ruff check datasette tests uv run cog --check README.md docs/*.rst +# Apply ruff fixes +@fix: + uv run ruff check --fix datasette tests + # Rebuild docs with cog @cog: uv run cog -r README.md docs/*.rst @@ -37,7 +41,7 @@ export DATASETTE_SECRET := "not_a_secret" # Apply Black @black: - uv run black . + uv run black datasette tests # Apply blacken-docs @blacken-docs: diff --git a/datasette/app.py b/datasette/app.py index b9955925..a5cd75c5 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -6,7 +6,7 @@ import contextvars from typing import TYPE_CHECKING, Any, Dict, Iterable, List if TYPE_CHECKING: - from datasette.permissions import AllowedResource, Resource + from datasette.permissions import Resource import asgi_csrf import collections import dataclasses @@ -1144,7 +1144,7 @@ class Datasette: # Validate that resource is a Resource object or None if resource is not None and not isinstance(resource, Resource): - raise TypeError(f"resource must be a Resource subclass instance or None.") + raise TypeError("resource must be a Resource subclass instance or None.") # Check if actor can see it if not await self.allowed(action=action, resource=resource, actor=actor): diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index 4c82d705..40373fa7 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -26,18 +26,18 @@ from datasette import hookimpl # Re-export all hooks and public utilities from .restrictions import ( - actor_restrictions_sql, - restrictions_allow_action, - ActorRestrictions, + actor_restrictions_sql as actor_restrictions_sql, + restrictions_allow_action as restrictions_allow_action, + ActorRestrictions as ActorRestrictions, ) -from .root import root_user_permissions_sql -from .config import config_permissions_sql +from .root import root_user_permissions_sql as root_user_permissions_sql +from .config import config_permissions_sql as config_permissions_sql from .defaults import ( - default_allow_sql_check, - default_action_permissions_sql, - DEFAULT_ALLOW_ACTIONS, + default_allow_sql_check as default_allow_sql_check, + default_action_permissions_sql as default_action_permissions_sql, + DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS, ) -from .tokens import actor_from_signed_api_token +from .tokens import actor_from_signed_api_token as actor_from_signed_api_token @hookimpl diff --git a/datasette/views/base.py b/datasette/views/base.py index 5216924f..bdc9f742 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -1,7 +1,6 @@ import asyncio import csv import hashlib -import json import sys import textwrap import time diff --git a/pyproject.toml b/pyproject.toml index 87884341..6fca673d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dev = [ "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", + "ruff>=0.9", # docs "Sphinx==7.4.7", "furo==2025.9.25", @@ -94,5 +95,9 @@ datasette = ["templates/*.html"] [tool.setuptools.dynamic] version = {attr = "datasette.version.__version__"} +[tool.ruff] +line-length = 160 +select = ["E", "F", "W"] + [tool.uv] package = true diff --git a/setup.cfg b/setup.cfg index ebf43062..b7e47898 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,2 @@ [aliases] test=pytest - -[flake8] -max-line-length = 160 diff --git a/tests/test_allowed_resources.py b/tests/test_allowed_resources.py index 0cd48ea9..08adbe48 100644 --- a/tests/test_allowed_resources.py +++ b/tests/test_allowed_resources.py @@ -117,7 +117,6 @@ async def test_tables_endpoint_database_restriction(test_ds): # Bob should only see analytics tables analytics_tables = [m for m in result if m["name"].startswith("analytics/")] - production_tables = [m for m in result if m["name"].startswith("production/")] assert len(analytics_tables) == 3 table_names = {m["name"] for m in analytics_tables} diff --git a/tests/test_api.py b/tests/test_api.py index 41bad84e..907d7445 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,21 +1,7 @@ from datasette.app import Datasette from datasette.plugins import DEFAULT_PLUGINS from datasette.version import __version__ -from .fixtures import ( # noqa - app_client, - app_client_no_files, - app_client_with_dot, - app_client_shorter_time_limit, - app_client_two_attached_databases_one_immutable, - app_client_larger_cache_size, - app_client_with_cors, - app_client_two_attached_databases, - app_client_conflicting_database_names, - app_client_immutable_and_inspect_file, - make_app_client, - EXPECTED_PLUGINS, - METADATA, -) +from .fixtures import make_app_client, EXPECTED_PLUGINS import pathlib import pytest import sys @@ -815,14 +801,14 @@ def test_databases_json(app_client_two_attached_databases_one_immutable): assert 2 == len(databases) extra_database, fixtures_database = databases assert "extra database" == extra_database["name"] - assert None == extra_database["hash"] - assert True == extra_database["is_mutable"] - assert False == extra_database["is_memory"] + assert extra_database["hash"] is None + assert extra_database["is_mutable"] is True + assert extra_database["is_memory"] is False assert "fixtures" == fixtures_database["name"] assert fixtures_database["hash"] is not None - assert False == fixtures_database["is_mutable"] - assert False == fixtures_database["is_memory"] + assert fixtures_database["is_mutable"] is False + assert fixtures_database["is_memory"] is False @pytest.mark.asyncio diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py index 0598a4a6..f9a90fbe 100644 --- a/tests/test_config_dir.py +++ b/tests/test_config_dir.py @@ -87,7 +87,7 @@ def test_invalid_settings(config_dir): ) try: with pytest.raises(StartupError) as ex: - ds = Datasette([], config_dir=config_dir) + Datasette([], config_dir=config_dir) assert ex.value.args[0] == "Invalid setting 'invalid' in config file" finally: (config_dir / "datasette.json").write_text(previous, "utf-8") diff --git a/tests/test_crossdb.py b/tests/test_crossdb.py index 1ec1a05c..7807cd5d 100644 --- a/tests/test_crossdb.py +++ b/tests/test_crossdb.py @@ -67,7 +67,7 @@ def test_crossdb_attached_database_list_display( ): app_client = app_client_two_attached_databases_crossdb_enabled response = app_client.get("/_memory") - response2 = app_client.get("/") + app_client.get("/") for fragment in ( "databases are attached to this connection", "
  • fixtures - ", diff --git a/tests/test_csv.py b/tests/test_csv.py index b4a71169..5589bd97 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -1,12 +1,6 @@ from datasette.app import Datasette from bs4 import BeautifulSoup as Soup import pytest -from .fixtures import ( # noqa - app_client, - app_client_csv_max_mb_one, - app_client_with_cors, - app_client_with_trace, -) import urllib.parse EXPECTED_TABLE_CSV = """id,content diff --git a/tests/test_filters.py b/tests/test_filters.py index a3fada98..eda9e9a1 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -103,27 +103,6 @@ async def test_through_filters_from_request(ds_client): assert filter_args.extra_context == {} -@pytest.mark.asyncio -async def test_through_filters_from_request(ds_client): - request = Request.fake( - '/?_through={"table":"roadside_attraction_characteristics","column":"characteristic_id","value":"1"}' - ) - filter_args = await through_filters( - request=request, - datasette=ds_client.ds, - table="roadside_attractions", - database="fixtures", - )() - assert filter_args.where_clauses == [ - "pk in (select attraction_id from roadside_attraction_characteristics where characteristic_id = :p0)" - ] - assert filter_args.params == {"p0": "1"} - assert filter_args.human_descriptions == [ - 'roadside_attraction_characteristics.characteristic_id = "1"' - ] - assert filter_args.extra_context == {} - - @pytest.mark.asyncio async def test_where_filters_from_request(ds_client): await ds_client.ds.invoke_startup() diff --git a/tests/test_html.py b/tests/test_html.py index 7b667301..8fad5764 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,14 +1,7 @@ from bs4 import BeautifulSoup as Soup from datasette.app import Datasette from datasette.utils import allowed_pragmas -from .fixtures import ( # noqa - app_client, - app_client_base_url_prefix, - app_client_shorter_time_limit, - app_client_two_attached_databases, - make_app_client, - METADATA, -) +from .fixtures import make_app_client from .utils import assert_footer_links, inner_html import copy import json diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index c64620a6..b378a158 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -158,7 +158,7 @@ def test_datasette_error_if_string_not_list(tmpdir): # https://github.com/simonw/datasette/issues/1985 db_path = str(tmpdir / "data.db") with pytest.raises(ValueError): - ds = Datasette(db_path) + Datasette(db_path) @pytest.mark.asyncio diff --git a/tests/test_permissions.py b/tests/test_permissions.py index e2dd92b8..96c0cf6f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -2,7 +2,7 @@ import collections from datasette.app import Datasette from datasette.cli import cli from datasette.default_permissions import restrictions_allow_action -from .fixtures import app_client, assert_permissions_checked, make_app_client +from .fixtures import assert_permissions_checked, make_app_client from click.testing import CliRunner from bs4 import BeautifulSoup as Soup import copy @@ -1481,7 +1481,6 @@ async def test_actor_restrictions_view_instance_only(perms_ds): assert response.status_code == 200 # But no databases should be visible (no view-database permission) - data = response.json() # The instance is visible but databases list should be empty or minimal # Actually, let's check via allowed_resources page = await perms_ds.allowed_resources("view-database", actor) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 42995c0d..6c23b3ef 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1172,8 +1172,6 @@ async def test_hook_filters_from_request(ds_client): @pytest.mark.asyncio @pytest.mark.parametrize("extra_metadata", (False, True)) async def test_hook_register_actions(extra_metadata): - from datasette.permissions import Action - from datasette.resources import DatabaseResource, InstanceResource ds = Datasette( config=( @@ -1527,7 +1525,7 @@ async def test_hook_register_events(): @pytest.mark.asyncio -async def test_hook_register_actions(): +async def test_hook_register_actions_view_collection(): datasette = Datasette(memory=True, plugins_dir=PLUGINS_DIR) await datasette.invoke_startup() # Check that the custom action from my_plugin.py is registered @@ -1545,7 +1543,7 @@ async def test_hook_register_actions_with_custom_resources(): - A parent-level action (DocumentCollectionResource) - A child-level action (DocumentResource) """ - from datasette.permissions import Resource, Action + from datasette.permissions import Resource # Define custom Resource classes class DocumentCollectionResource(Resource): diff --git a/tests/test_restriction_sql.py b/tests/test_restriction_sql.py index f23eb839..df6abd29 100644 --- a/tests/test_restriction_sql.py +++ b/tests/test_restriction_sql.py @@ -182,8 +182,8 @@ async def test_also_requires_with_restrictions(): """ ds = Datasette() await ds.invoke_startup() - db1 = ds.add_memory_database("db1_also_requires") - db2 = ds.add_memory_database("db2_also_requires") + ds.add_memory_database("db1_also_requires") + ds.add_memory_database("db2_also_requires") await ds._refresh_schemas() # Actor restricted to only db1_also_requires for view-database diff --git a/tests/test_schema_endpoints.py b/tests/test_schema_endpoints.py index 5500a7b0..50742df2 100644 --- a/tests/test_schema_endpoints.py +++ b/tests/test_schema_endpoints.py @@ -1,4 +1,3 @@ -import asyncio import pytest import pytest_asyncio from datasette.app import Datasette diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 527550fb..49df3ad5 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1,13 +1,6 @@ from datasette.utils import detect_json1 from datasette.utils.sqlite import sqlite_version -from .fixtures import ( # noqa - app_client, - app_client_with_trace, - app_client_returned_rows_matches_page_size, - generate_compound_rows, - generate_sortable_rows, - make_app_client, -) +from .fixtures import generate_compound_rows, generate_sortable_rows, make_app_client import json import pytest import urllib diff --git a/tests/test_table_html.py b/tests/test_table_html.py index e3ddb4b0..90be591a 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1,10 +1,6 @@ from datasette.app import Datasette from bs4 import BeautifulSoup as Soup -from .fixtures import ( # noqa - app_client, - make_app_client, - app_client_with_dot, -) +from .fixtures import make_app_client import pathlib import pytest import urllib.parse @@ -1263,7 +1259,7 @@ async def test_foreign_key_labels_obey_permissions(config): "insert or replace into b (id, name, a_id) values (1, 'world', 1)" ) # Anonymous user can see table b but not table a - blah = await ds.client.get("/foreign_key_labels.json") + await ds.client.get("/foreign_key_labels.json") anon_a = await ds.client.get("/foreign_key_labels/a.json?_labels=on") assert anon_a.status_code == 403 anon_b = await ds.client.get("/foreign_key_labels/b.json?_labels=on") From 7915c46ddd50e058cfc441c6b061cee177d6c562 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 23 Jan 2026 20:57:25 -0800 Subject: [PATCH 004/203] Fix flaky test_database_page test with deterministic ordering (#2628) * Fix flaky test_database_page test with deterministic ordering - Add ORDER BY to table_names() query in database.py - Sort foreign keys deterministically in get_all_foreign_keys() - Refactor test_database_page to use property-based assertions instead of 500+ lines of hardcoded expected data - Run blacken-docs on plugin_hooks.rst * Update test_row_foreign_key_tables for new deterministic FK ordering The foreign keys are now sorted by (other_table, column, other_column), so complex_foreign_keys comes before foreign_key_references alphabetically. * Update test_table_names for new alphabetical ordering The table_names() method now returns tables sorted alphabetically. * Fix for test that fails prior to SQLite 3.37 --------- Co-authored-by: Claude --- datasette/database.py | 2 +- datasette/utils/__init__.py | 14 +- tests/test_api.py | 725 +++++++++---------------------- tests/test_internals_database.py | 45 +- 4 files changed, 243 insertions(+), 543 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index e5858128..8e4ee2b6 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -431,7 +431,7 @@ class Database: async def table_names(self): results = await self.execute( - "select name from sqlite_master where type='table'" + "select name from sqlite_master where type='table' order by name" ) return [r[0] for r in results.rows] diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index ac2c74da..fb864077 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -612,7 +612,10 @@ def get_outbound_foreign_keys(conn, table): def get_all_foreign_keys(conn): tables = [ - r[0] for r in conn.execute('select name from sqlite_master where type="table"') + r[0] + for r in conn.execute( + 'select name from sqlite_master where type="table" order by name' + ) ] table_to_foreign_keys = {} for table in tables: @@ -634,6 +637,15 @@ def get_all_foreign_keys(conn): {"other_table": table_name, "column": from_, "other_column": to_} ) + # Sort foreign keys for deterministic ordering + for table in table_to_foreign_keys: + table_to_foreign_keys[table]["incoming"].sort( + key=lambda fk: (fk["other_table"], fk["column"], fk["other_column"]) + ) + table_to_foreign_keys[table]["outgoing"].sort( + key=lambda fk: (fk["other_table"], fk["column"], fk["other_column"]) + ) + return table_to_foreign_keys diff --git a/tests/test_api.py b/tests/test_api.py index 907d7445..e3951df9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ from datasette.app import Datasette from datasette.plugins import DEFAULT_PLUGINS +from datasette.utils.sqlite import sqlite_version from datasette.version import __version__ from .fixtures import make_app_client, EXPECTED_PLUGINS import pathlib @@ -59,504 +60,189 @@ async def test_database_page(ds_client): assert response.status_code == 200 data = response.json() assert data["database"] == "fixtures" - assert data["tables"] == [ - { - "name": "123_starts_with_digits", - "columns": ["content"], - "primary_keys": [], - "count": 0, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "Table With Space In Name", - "columns": ["pk", "content"], - "primary_keys": ["pk"], - "count": 0, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "attraction_characteristic", - "columns": ["pk", "name"], - "primary_keys": ["pk"], - "count": 2, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [ - { - "other_table": "roadside_attraction_characteristics", - "column": "pk", - "other_column": "characteristic_id", - } - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "binary_data", - "columns": ["data"], - "primary_keys": [], - "count": 3, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "complex_foreign_keys", - "columns": ["pk", "f1", "f2", "f3"], - "primary_keys": ["pk"], - "count": 1, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [], - "outgoing": [ - { - "other_table": "simple_primary_key", - "column": "f3", - "other_column": "id", - }, - { - "other_table": "simple_primary_key", - "column": "f2", - "other_column": "id", - }, - { - "other_table": "simple_primary_key", - "column": "f1", - "other_column": "id", - }, - ], - }, - "private": False, - }, - { - "name": "compound_primary_key", - "columns": ["pk1", "pk2", "content"], - "primary_keys": ["pk1", "pk2"], - "count": 2, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "compound_three_primary_keys", - "columns": ["pk1", "pk2", "pk3", "content"], - "primary_keys": ["pk1", "pk2", "pk3"], - "count": 1001, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "custom_foreign_key_label", - "columns": ["pk", "foreign_key_with_custom_label"], - "primary_keys": ["pk"], - "count": 1, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [], - "outgoing": [ - { - "other_table": "primary_key_multiple_columns_explicit_label", - "column": "foreign_key_with_custom_label", - "other_column": "id", - } - ], - }, - "private": False, - }, - { - "name": "facet_cities", - "columns": ["id", "name"], - "primary_keys": ["id"], - "count": 4, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [ - { - "other_table": "facetable", - "column": "id", - "other_column": "_city_id", - } - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "facetable", - "columns": [ - "pk", - "created", - "planet_int", - "on_earth", - "state", - "_city_id", - "_neighborhood", - "tags", - "complex_array", - "distinct_some_null", - "n", - ], - "primary_keys": ["pk"], - "count": 15, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [], - "outgoing": [ - { - "other_table": "facet_cities", - "column": "_city_id", - "other_column": "id", - } - ], - }, - "private": False, - }, - { - "name": "foreign_key_references", - "columns": [ - "pk", - "foreign_key_with_label", - "foreign_key_with_blank_label", - "foreign_key_with_no_label", - "foreign_key_compound_pk1", - "foreign_key_compound_pk2", - ], - "primary_keys": ["pk"], - "count": 2, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [], - "outgoing": [ - { - "other_table": "primary_key_multiple_columns", - "column": "foreign_key_with_no_label", - "other_column": "id", - }, - { - "other_table": "simple_primary_key", - "column": "foreign_key_with_blank_label", - "other_column": "id", - }, - { - "other_table": "simple_primary_key", - "column": "foreign_key_with_label", - "other_column": "id", - }, - ], - }, - "private": False, - }, - ] + [ - { - "name": "infinity", - "columns": ["value"], - "primary_keys": [], - "count": 3, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "primary_key_multiple_columns", - "columns": ["id", "content", "content2"], - "primary_keys": ["id"], - "count": 1, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [ - { - "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_no_label", - } - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "primary_key_multiple_columns_explicit_label", - "columns": ["id", "content", "content2"], - "primary_keys": ["id"], - "count": 1, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [ - { - "other_table": "custom_foreign_key_label", - "column": "id", - "other_column": "foreign_key_with_custom_label", - } - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "roadside_attraction_characteristics", - "columns": ["attraction_id", "characteristic_id"], - "primary_keys": [], - "count": 5, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [], - "outgoing": [ - { - "other_table": "attraction_characteristic", - "column": "characteristic_id", - "other_column": "pk", - }, - { - "other_table": "roadside_attractions", - "column": "attraction_id", - "other_column": "pk", - }, - ], - }, - "private": False, - }, - { - "name": "roadside_attractions", - "columns": ["pk", "name", "address", "url", "latitude", "longitude"], - "primary_keys": ["pk"], - "count": 4, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [ - { - "other_table": "roadside_attraction_characteristics", - "column": "pk", - "other_column": "attraction_id", - } - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "searchable", - "columns": ["pk", "text1", "text2", "name with . and spaces"], - "primary_keys": ["pk"], - "count": 2, - "hidden": False, - "fts_table": "searchable_fts", - "foreign_keys": { - "incoming": [ - { - "other_table": "searchable_tags", - "column": "pk", - "other_column": "searchable_id", - } - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "searchable_tags", - "columns": ["searchable_id", "tag"], - "primary_keys": ["searchable_id", "tag"], - "count": 2, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [], - "outgoing": [ - {"other_table": "tags", "column": "tag", "other_column": "tag"}, - { - "other_table": "searchable", - "column": "searchable_id", - "other_column": "pk", - }, - ], - }, - "private": False, - }, - { - "name": "select", - "columns": ["group", "having", "and", "json"], - "primary_keys": [], - "count": 1, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "simple_primary_key", - "columns": ["id", "content"], - "primary_keys": ["id"], - "count": 5, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [ - { - "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_blank_label", - }, - { - "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_label", - }, - { - "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f3", - }, - { - "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f2", - }, - { - "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f1", - }, - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "sortable", - "columns": [ - "pk1", - "pk2", - "content", - "sortable", - "sortable_with_nulls", - "sortable_with_nulls_2", - "text", - ], - "primary_keys": ["pk1", "pk2"], - "count": 201, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "table/with/slashes.csv", - "columns": ["pk", "content"], - "primary_keys": ["pk"], - "count": 1, - "hidden": False, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "tags", - "columns": ["tag"], - "primary_keys": ["tag"], - "count": 2, - "hidden": False, - "fts_table": None, - "foreign_keys": { - "incoming": [ - { - "other_table": "searchable_tags", - "column": "tag", - "other_column": "tag", - } - ], - "outgoing": [], - }, - "private": False, - }, - { - "name": "no_primary_key", - "columns": ["content", "a", "b", "c"], - "primary_keys": [], - "count": 201, - "hidden": True, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "columns": [ - "text1", - "text2", - "name with . and spaces", - "searchable_fts", - "rank", - ], - "count": 2, - "foreign_keys": {"incoming": [], "outgoing": []}, - "fts_table": "searchable_fts", - "hidden": True, - "name": "searchable_fts", - "primary_keys": [], - "private": False, - }, - { - "name": "searchable_fts_config", - "columns": ["k", "v"], - "primary_keys": ["k"], - "count": 1, - "hidden": True, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "searchable_fts_data", - "columns": ["id", "block"], - "primary_keys": ["id"], - "count": 3, - "hidden": True, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "searchable_fts_docsize", - "columns": ["id", "sz"], - "primary_keys": ["id"], - "count": 2, - "hidden": True, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - { - "name": "searchable_fts_idx", - "columns": ["segid", "term", "pgno"], - "primary_keys": ["segid", "term"], - "count": 1, - "hidden": True, - "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, - "private": False, - }, - ] + + # Build lookup for easier assertions + tables = data["tables"] + tables_by_name = {t["name"]: t for t in tables} + + # Verify tables are sorted by (hidden, name) - visible first, then hidden + table_names = [t["name"] for t in tables] + expected_order = sorted(tables, key=lambda t: (t["hidden"], t["name"])) + assert table_names == [t["name"] for t in expected_order] + + # Expected visible tables (not hidden) + expected_visible_tables = { + "123_starts_with_digits", + "Table With Space In Name", + "attraction_characteristic", + "binary_data", + "complex_foreign_keys", + "compound_primary_key", + "compound_three_primary_keys", + "custom_foreign_key_label", + "facet_cities", + "facetable", + "foreign_key_references", + "infinity", + "primary_key_multiple_columns", + "primary_key_multiple_columns_explicit_label", + "roadside_attraction_characteristics", + "roadside_attractions", + "searchable", + "searchable_tags", + "select", + "simple_primary_key", + "sortable", + "table/with/slashes.csv", + "tags", + } + + # Expected hidden tables + expected_hidden_tables = { + "no_primary_key", + "searchable_fts", + "searchable_fts_config", + "searchable_fts_data", + "searchable_fts_docsize", + "searchable_fts_idx", + } + + # Verify all expected tables exist + assert expected_visible_tables.issubset(tables_by_name.keys()) + assert expected_hidden_tables.issubset(tables_by_name.keys()) + + # Verify hidden status + visible_tables = {t["name"] for t in tables if not t["hidden"]} + hidden_tables = {t["name"] for t in tables if t["hidden"]} + assert expected_visible_tables == visible_tables + assert expected_hidden_tables == hidden_tables + + # Helper to compare foreign keys (order-insensitive) + def fk_set(fks): + return {(fk["other_table"], fk["column"], fk["other_column"]) for fk in fks} + + # Test specific table properties + # -- facetable: has outgoing FK to facet_cities + facetable = tables_by_name["facetable"] + assert facetable["count"] == 15 + assert facetable["primary_keys"] == ["pk"] + assert facetable["fts_table"] is None + assert facetable["private"] is False + assert fk_set(facetable["foreign_keys"]["outgoing"]) == { + ("facet_cities", "_city_id", "id") + } + assert fk_set(facetable["foreign_keys"]["incoming"]) == set() + + # -- facet_cities: has incoming FK from facetable + facet_cities = tables_by_name["facet_cities"] + assert facet_cities["count"] == 4 + assert facet_cities["columns"] == ["id", "name"] + assert fk_set(facet_cities["foreign_keys"]["incoming"]) == { + ("facetable", "id", "_city_id") + } + + # -- simple_primary_key: has multiple incoming FKs + simple_pk = tables_by_name["simple_primary_key"] + assert simple_pk["count"] == 5 + assert simple_pk["columns"] == ["id", "content"] + assert simple_pk["primary_keys"] == ["id"] + # Should have incoming FKs from complex_foreign_keys (f1, f2, f3) and foreign_key_references + incoming = fk_set(simple_pk["foreign_keys"]["incoming"]) + assert ("complex_foreign_keys", "id", "f1") in incoming + assert ("complex_foreign_keys", "id", "f2") in incoming + assert ("complex_foreign_keys", "id", "f3") in incoming + assert ("foreign_key_references", "id", "foreign_key_with_label") in incoming + assert ("foreign_key_references", "id", "foreign_key_with_blank_label") in incoming + + # -- complex_foreign_keys: has multiple outgoing FKs to same table + complex_fk = tables_by_name["complex_foreign_keys"] + assert complex_fk["count"] == 1 + assert complex_fk["columns"] == ["pk", "f1", "f2", "f3"] + outgoing = fk_set(complex_fk["foreign_keys"]["outgoing"]) + assert outgoing == { + ("simple_primary_key", "f1", "id"), + ("simple_primary_key", "f2", "id"), + ("simple_primary_key", "f3", "id"), + } + + # -- searchable: has FTS table association + searchable = tables_by_name["searchable"] + assert searchable["count"] == 2 + assert searchable["fts_table"] == "searchable_fts" + assert searchable["columns"] == ["pk", "text1", "text2", "name with . and spaces"] + + # -- searchable_fts: is the FTS virtual table (hidden) + searchable_fts = tables_by_name["searchable_fts"] + assert searchable_fts["hidden"] is True + assert searchable_fts["fts_table"] == "searchable_fts" + # The "rank" column became visible in pragma_table_info in SQLite 3.37+ + if sqlite_version() >= (3, 37, 0): + assert "rank" in searchable_fts["columns"] + + # -- compound primary keys + compound_pk = tables_by_name["compound_primary_key"] + assert compound_pk["primary_keys"] == ["pk1", "pk2"] + assert compound_pk["count"] == 2 + + compound_three = tables_by_name["compound_three_primary_keys"] + assert compound_three["primary_keys"] == ["pk1", "pk2", "pk3"] + assert compound_three["count"] == 1001 + + # -- sortable: generated data + sortable = tables_by_name["sortable"] + assert sortable["count"] == 201 + assert sortable["primary_keys"] == ["pk1", "pk2"] + + # -- no_primary_key: hidden table with generated data + no_pk = tables_by_name["no_primary_key"] + assert no_pk["hidden"] is True + assert no_pk["count"] == 201 + assert no_pk["primary_keys"] == [] + + # -- roadside attractions relationship chain + attractions = tables_by_name["roadside_attractions"] + assert attractions["count"] == 4 + assert fk_set(attractions["foreign_keys"]["incoming"]) == { + ("roadside_attraction_characteristics", "pk", "attraction_id") + } + + characteristics = tables_by_name["attraction_characteristic"] + assert characteristics["count"] == 2 + assert fk_set(characteristics["foreign_keys"]["incoming"]) == { + ("roadside_attraction_characteristics", "pk", "characteristic_id") + } + + # -- searchable_tags: multiple outgoing FKs + searchable_tags = tables_by_name["searchable_tags"] + assert searchable_tags["primary_keys"] == ["searchable_id", "tag"] + outgoing = fk_set(searchable_tags["foreign_keys"]["outgoing"]) + assert outgoing == { + ("searchable", "searchable_id", "pk"), + ("tags", "tag", "tag"), + } + + # -- tables with special names + assert "123_starts_with_digits" in tables_by_name + assert "Table With Space In Name" in tables_by_name + assert "table/with/slashes.csv" in tables_by_name + assert "select" in tables_by_name # SQL reserved word + + # Verify select table has SQL reserved word columns + select_table = tables_by_name["select"] + assert set(select_table["columns"]) == {"group", "having", "and", "json"} + + # Verify all tables have required fields + for table in tables: + assert "name" in table + assert "columns" in table + assert "primary_keys" in table + assert "count" in table + assert "hidden" in table + assert "fts_table" in table + assert "foreign_keys" in table + assert "private" in table + assert "incoming" in table["foreign_keys"] + assert "outgoing" in table["foreign_keys"] def test_no_files_uses_memory_database(app_client_no_files): @@ -699,7 +385,29 @@ async def test_row_foreign_key_tables(ds_client): "/fixtures/simple_primary_key/1.json?_extras=foreign_key_tables" ) assert response.status_code == 200 + # Foreign keys are sorted by (other_table, column, other_column) assert response.json()["foreign_key_tables"] == [ + { + "other_table": "complex_foreign_keys", + "column": "id", + "other_column": "f1", + "count": 1, + "link": "/fixtures/complex_foreign_keys?f1=1", + }, + { + "other_table": "complex_foreign_keys", + "column": "id", + "other_column": "f2", + "count": 0, + "link": "/fixtures/complex_foreign_keys?f2=1", + }, + { + "other_table": "complex_foreign_keys", + "column": "id", + "other_column": "f3", + "count": 1, + "link": "/fixtures/complex_foreign_keys?f3=1", + }, { "other_table": "foreign_key_references", "column": "id", @@ -714,27 +422,6 @@ async def test_row_foreign_key_tables(ds_client): "count": 1, "link": "/fixtures/foreign_key_references?foreign_key_with_label=1", }, - { - "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f3", - "count": 1, - "link": "/fixtures/complex_foreign_keys?f3=1", - }, - { - "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f2", - "count": 0, - "link": "/fixtures/complex_foreign_keys?f2=1", - }, - { - "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f1", - "count": 1, - "link": "/fixtures/complex_foreign_keys?f1=1", - }, ] diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index d2e06073..02c67bfc 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -418,36 +418,37 @@ async def test_get_all_foreign_keys(db): @pytest.mark.asyncio async def test_table_names(db): table_names = await db.table_names() + # Tables are sorted alphabetically by name assert table_names == [ - "simple_primary_key", - "primary_key_multiple_columns", - "primary_key_multiple_columns_explicit_label", - "compound_primary_key", - "compound_three_primary_keys", - "foreign_key_references", - "sortable", - "no_primary_key", "123_starts_with_digits", "Table With Space In Name", - "table/with/slashes.csv", + "attraction_characteristic", + "binary_data", "complex_foreign_keys", + "compound_primary_key", + "compound_three_primary_keys", "custom_foreign_key_label", - "tags", - "searchable", - "searchable_tags", - "searchable_fts", - "searchable_fts_data", - "searchable_fts_idx", - "searchable_fts_docsize", - "searchable_fts_config", - "select", - "infinity", "facet_cities", "facetable", - "binary_data", - "roadside_attractions", - "attraction_characteristic", + "foreign_key_references", + "infinity", + "no_primary_key", + "primary_key_multiple_columns", + "primary_key_multiple_columns_explicit_label", "roadside_attraction_characteristics", + "roadside_attractions", + "searchable", + "searchable_fts", + "searchable_fts_config", + "searchable_fts_data", + "searchable_fts_docsize", + "searchable_fts_idx", + "searchable_tags", + "select", + "simple_primary_key", + "sortable", + "table/with/slashes.csv", + "tags", ] From 7988a179fe317cdb3dfa5c13d879d192ae36898d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 23 Jan 2026 21:03:16 -0800 Subject: [PATCH 005/203] Throttle schema refreshes to at most once per second, refs #2629 --- datasette/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index a5cd75c5..75f6071e 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -589,6 +589,10 @@ class Datasette: return None async def refresh_schemas(self): + # Throttle schema refreshes to at most once per second + if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0: + return + self._last_schema_refresh = time.monotonic() if self._refresh_schemas_lock.locked(): return async with self._refresh_schemas_lock: From 2f7b120177f3285a8d504d5810fb081711d1b979 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 24 Jan 2026 22:07:54 -0800 Subject: [PATCH 006/203] Minor speedup for remove_infinites, refs #2629 --- datasette/utils/__init__.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index fb864077..4aaed967 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -901,18 +901,26 @@ _infinities = {float("inf"), float("-inf")} def remove_infinites(row): - to_check = row + """ + Replace float('inf') and float('-inf') with None in a row. + + Returns the original row object unchanged if no infinities are found. + """ if isinstance(row, dict): - to_check = row.values() - if not any((c in _infinities) if isinstance(c, float) else 0 for c in to_check): - return row - if isinstance(row, dict): - return { - k: (None if (isinstance(v, float) and v in _infinities) else v) - for k, v in row.items() - } + for v in row.values(): + if isinstance(v, float) and v in _infinities: + return { + k: (None if isinstance(v2, float) and v2 in _infinities else v2) + for k, v2 in row.items() + } else: - return [None if (isinstance(c, float) and c in _infinities) else c for c in row] + for v in row: + if isinstance(v, float) and v in _infinities: + return [ + None if isinstance(v2, float) and v2 in _infinities else v2 + for v2 in row + ] + return row class StaticMount(click.ParamType): From 3f8f97e92a2ec058d38dbc151eef40245cb234a3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 28 Jan 2026 09:55:25 -0800 Subject: [PATCH 007/203] Close more connections in test suite To try and avoid too many open files on macOS --- tests/test_api_write.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 3a76e655..05835e51 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -20,7 +20,12 @@ def ds_write(tmp_path_factory): ds = Datasette([db_path], immutables=[db_path_immutable]) ds.root_enabled = True yield ds - db.close() + # Close both setup connections plus any Datasette-managed connections. + db1.close() + db2.close() + for database in ds.databases.values(): + if not database.is_memory: + database.close() def write_token(ds, actor_id="root", permissions=None): From ffadb5f74cf4e649671be42d9f56d0c233d381fb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 28 Jan 2026 18:34:00 -0800 Subject: [PATCH 008/203] Workaround for intermittent test failure on SQLite 3.25.3 Closes: - #2632 --- datasette/utils/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 4aaed967..d0d216eb 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -706,8 +706,11 @@ def table_column_details(conn, table): ).fetchall() ] else: - # Treat hidden as 0 for all columns + # First trigger a query against sqlite_master to fix an intermittent + # test failure, see https://github.com/simonw/datasette/issues/2632 + conn.execute("select 1 from sqlite_master limit 1").fetchall() return [ + # Treat hidden as 0 for all columns. Column(*(list(r) + [0])) for r in conn.execute( f"PRAGMA table_info({escape_sqlite(table)});" From 40a37307ded36311a07eb2577cb74c92a2639f9d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 28 Jan 2026 18:41:03 -0800 Subject: [PATCH 009/203] Add request.form() for multipart form data and file uploads * Add request.form() for multipart form data and file uploads New Request.form() method that handles both application/x-www-form-urlencoded and multipart/form-data content types with streaming parsing. Features: - Streaming multipart parser that doesn't buffer entire body in memory - Files spill to disk above 1MB threshold via SpooledTemporaryFile - files=False (default) discards file content, files=True stores them - Security limits: max_request_size, max_file_size, max_fields, max_files - FormData container with dict-like access and getlist() for multiple values - UploadedFile class with async read(), seek(), filename, content_type, size - Support for RFC 5987 filename* encoding for international filenames Uses multipart-form-data-conformance test suite for validation. * Update views to use request.form() and document new API - Migrate PermissionsDebugView, MessagesDebugView, and CreateTokenView from post_vars() to form() - Add documentation for request.form(), FormData, and UploadedFile classes Centralize multipart defaults and expose stricter limits via Request.form(). Enforce header, part, file, and disk space limits even when files are discarded; detect truncated bodies and client disconnects; and move blocking work off the event loop. Add FormData close/aclose context managers, update internals docs, and expand multipart tests (including len semantics and stricter conformance expectations). --- datasette/utils/asgi.py | 81 +++ datasette/utils/multipart.py | 757 ++++++++++++++++++++++ datasette/views/special.py | 26 +- docs/internals.rst | 131 +++- pyproject.toml | 1 + tests/test_multipart.py | 1152 ++++++++++++++++++++++++++++++++++ 6 files changed, 2133 insertions(+), 15 deletions(-) create mode 100644 datasette/utils/multipart.py create mode 100644 tests/test_multipart.py diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 7f3329a6..35f243b6 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -1,5 +1,21 @@ import json +from typing import Optional from datasette.utils import MultiParams, calculate_etag +from datasette.utils.multipart import ( + parse_form_data, + MultipartParseError, + FormData, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_REQUEST_SIZE, + DEFAULT_MAX_FIELDS, + DEFAULT_MAX_FILES, + DEFAULT_MAX_PARTS, + DEFAULT_MAX_FIELD_SIZE, + DEFAULT_MAX_MEMORY_FILE_SIZE, + DEFAULT_MAX_PART_HEADER_BYTES, + DEFAULT_MAX_PART_HEADER_LINES, + DEFAULT_MIN_FREE_DISK_BYTES, +) from mimetypes import guess_type from urllib.parse import parse_qs, urlunparse, parse_qsl from pathlib import Path @@ -139,6 +155,71 @@ class Request: body = await self.post_body() return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True)) + async def form( + self, + files: bool = False, + max_file_size: int = DEFAULT_MAX_FILE_SIZE, + max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, + max_fields: int = DEFAULT_MAX_FIELDS, + max_files: int = DEFAULT_MAX_FILES, + max_parts: Optional[int] = DEFAULT_MAX_PARTS, + max_field_size: int = DEFAULT_MAX_FIELD_SIZE, + max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, + max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, + max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES, + min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES, + ) -> FormData: + """ + Parse form data from the request body. + + Supports both application/x-www-form-urlencoded and multipart/form-data. + + Args: + files: If True, store file uploads; if False (default), discard them + max_file_size: Maximum size per file in bytes (default 50MB) + max_request_size: Maximum total request size in bytes (default 100MB) + max_fields: Maximum number of form fields (default 1000) + max_files: Maximum number of file uploads (default 100) + max_parts: Maximum number of multipart parts (default max_fields + max_files) + max_field_size: Maximum size of a text field value in bytes (default 100KB) + max_memory_file_size: Threshold before files spill to disk (default 1MB) + max_part_header_bytes: Maximum bytes allowed in part headers (default 16KB) + max_part_header_lines: Maximum header lines per part (default 100) + min_free_disk_bytes: Minimum free bytes required in temp dir (default 50MB) + + Returns: + FormData object with dict-like access to fields and files. + Use form["key"] for first value, form.getlist("key") for all values. + + Raises: + BadRequest: If content-type is missing, unsupported, or parsing fails + """ + content_type = self.headers.get("content-type", "") + if not content_type: + raise BadRequest( + "Missing Content-Type header; expected application/x-www-form-urlencoded " + "or multipart/form-data" + ) + + try: + return await parse_form_data( + receive=self.receive, + content_type=content_type, + files=files, + max_file_size=max_file_size, + max_request_size=max_request_size, + max_fields=max_fields, + max_files=max_files, + max_parts=max_parts, + max_field_size=max_field_size, + max_memory_file_size=max_memory_file_size, + max_part_header_bytes=max_part_header_bytes, + max_part_header_lines=max_part_header_lines, + min_free_disk_bytes=min_free_disk_bytes, + ) + except MultipartParseError as e: + raise BadRequest(str(e)) + @classmethod def fake(cls, path_with_query_string, method="GET", scheme="http", url_vars=None): """Useful for constructing Request objects for tests""" diff --git a/datasette/utils/multipart.py b/datasette/utils/multipart.py new file mode 100644 index 00000000..cfa77486 --- /dev/null +++ b/datasette/utils/multipart.py @@ -0,0 +1,757 @@ +""" +Streaming multipart/form-data parser for ASGI applications. + +Supports: +- Streaming parsing without buffering entire body in memory +- Files spill to disk above configurable threshold +- Security limits on request size, file size, field count +- Both multipart/form-data and application/x-www-form-urlencoded +""" + +import asyncio +import shutil +import tempfile +from dataclasses import dataclass, field +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Union, +) +from urllib.parse import parse_qsl + +# Centralized defaults for multipart/form-data parsing +DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB +DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB +DEFAULT_MAX_FIELDS = 1000 +DEFAULT_MAX_FILES = 100 +# If max_parts is not specified, it defaults to max_fields + max_files +DEFAULT_MAX_PARTS: Optional[int] = None +DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB +DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB +DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB +DEFAULT_MAX_PART_HEADER_LINES = 100 +DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB + + +class MultipartParseError(Exception): + """Raised when multipart parsing fails.""" + + pass + + +@dataclass +class UploadedFile: + """ + Represents an uploaded file from a multipart form. + + Attributes: + name: The form field name + filename: The original filename from the upload + content_type: The MIME type of the file + size: Size in bytes + """ + + name: str + filename: str + content_type: Optional[str] + size: int + _file: tempfile.SpooledTemporaryFile = field(repr=False) + + async def read(self, size: int = -1) -> bytes: + """Read file contents.""" + return await asyncio.to_thread(self._file.read, size) + + async def seek(self, offset: int, whence: int = 0) -> int: + """Seek to position in file.""" + return await asyncio.to_thread(self._file.seek, offset, whence) + + async def close(self) -> None: + """Close the underlying file.""" + await asyncio.to_thread(self._file.close) + + def close_sync(self) -> None: + """Close the underlying file synchronously.""" + self._file.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.close() + + def __del__(self): + try: + self._file.close() + except Exception: + pass + + +class FormData: + """ + Container for parsed form data, supporting both fields and files. + + Provides dict-like access with support for multiple values per key. + """ + + def __init__(self): + self._data: List[Tuple[str, Union[str, UploadedFile]]] = [] + + def append(self, key: str, value: Union[str, UploadedFile]) -> None: + """Add a key-value pair.""" + self._data.append((key, value)) + + def __getitem__(self, key: str) -> Union[str, UploadedFile]: + """Get the first value for a key.""" + for k, v in self._data: + if k == key: + return v + raise KeyError(key) + + def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]: + """Get the first value for a key, or default if not found.""" + try: + return self[key] + except KeyError: + return default + + def getlist(self, key: str) -> List[Union[str, UploadedFile]]: + """Get all values for a key.""" + return [v for k, v in self._data if k == key] + + def __contains__(self, key: str) -> bool: + """Check if key exists.""" + return any(k == key for k, _ in self._data) + + def __len__(self) -> int: + """Return number of items.""" + return len(self._data) + + def __iter__(self): + """Iterate over unique keys.""" + seen = set() + for k, _ in self._data: + if k not in seen: + seen.add(k) + yield k + + def keys(self): + """Return unique keys.""" + return list(self) + + def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]: + """Return all key-value pairs.""" + return list(self._data) + + def values(self) -> List[Union[str, UploadedFile]]: + """Return all values.""" + return [v for _, v in self._data] + + def _uploaded_files(self) -> List[UploadedFile]: + """Return UploadedFile instances contained in this form.""" + return [v for _, v in self._data if isinstance(v, UploadedFile)] + + def close(self) -> None: + """ + Close any uploaded files. + + This provides deterministic cleanup for spooled temp files. + """ + for uploaded in self._uploaded_files(): + try: + uploaded.close_sync() + except Exception: + # Best-effort cleanup; ignore close errors + pass + + async def aclose(self) -> None: + """Asynchronously close any uploaded files.""" + for uploaded in self._uploaded_files(): + try: + await uploaded.close() + except Exception: + # Best-effort cleanup; ignore close errors + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + self.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.aclose() + + +def parse_content_disposition(header: str) -> Dict[str, Optional[str]]: + """ + Parse Content-Disposition header value. + + Returns dict with 'name', 'filename' keys (filename may be None). + """ + result: Dict[str, Optional[str]] = {"name": None, "filename": None} + + # Split on semicolons, handling quoted strings + parts = [] + current = "" + in_quotes = False + i = 0 + while i < len(header): + char = header[i] + if char == '"' and (i == 0 or header[i - 1] != "\\"): + in_quotes = not in_quotes + current += char + elif char == ";" and not in_quotes: + parts.append(current.strip()) + current = "" + else: + current += char + i += 1 + if current.strip(): + parts.append(current.strip()) + + for part in parts[1:]: # Skip the "form-data" part + if "=" not in part: + continue + + key, _, value = part.partition("=") + key = key.strip().lower() + value = value.strip() + + # Handle filename* (RFC 5987 encoding) + if key == "filename*": + # Format: utf-8''encoded_filename or charset'language'encoded_filename + if "'" in value: + parts_star = value.split("'", 2) + if len(parts_star) >= 3: + # charset = parts_star[0] + # language = parts_star[1] + encoded = parts_star[2] + # URL decode + try: + from urllib.parse import unquote + + result["filename"] = unquote(encoded, encoding="utf-8") + except Exception: + pass + continue + + # Remove quotes if present + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + # Unescape backslash sequences + value = value.replace('\\"', '"').replace("\\\\", "\\") + + if key == "name": + result["name"] = value + elif key == "filename": + # Only set if filename* hasn't already set it + if result["filename"] is None: + # Strip path components (security) + # Handle both Unix and Windows paths + value = value.replace("\\", "/") + if "/" in value: + value = value.rsplit("/", 1)[-1] + result["filename"] = value + + return result + + +def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]: + """ + Parse Content-Type header value. + + Returns (media_type, parameters_dict). + """ + parts = header.split(";") + media_type = parts[0].strip().lower() + params = {} + + for part in parts[1:]: + part = part.strip() + if "=" in part: + key, _, value = part.partition("=") + key = key.strip().lower() + value = value.strip() + # Remove quotes if present + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + params[key] = value + + return media_type, params + + +class MultipartParser: + """ + Streaming multipart/form-data parser. + + Processes the body chunk by chunk without loading everything into memory. + """ + + # Parser states + STATE_PREAMBLE = 0 + STATE_HEADER = 1 + STATE_BODY = 2 + STATE_DONE = 3 + + def __init__( + self, + boundary: bytes, + max_file_size: int = DEFAULT_MAX_FILE_SIZE, + max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, + max_fields: int = DEFAULT_MAX_FIELDS, + max_files: int = DEFAULT_MAX_FILES, + max_parts: Optional[int] = DEFAULT_MAX_PARTS, + max_field_size: int = DEFAULT_MAX_FIELD_SIZE, + max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, + max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, + max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES, + min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES, + handle_files: bool = False, + ): + self.boundary = b"--" + boundary + self.end_boundary = self.boundary + b"--" + self.max_file_size = max_file_size + self.max_request_size = max_request_size + self.max_fields = max_fields + self.max_files = max_files + # If not specified, tie max_parts to the other cardinality limits + if max_parts is None: + max_parts = max_fields + max_files + self.max_parts = max_parts + self.max_field_size = max_field_size + self.max_memory_file_size = max_memory_file_size + self.max_part_header_bytes = max_part_header_bytes + self.max_part_header_lines = max_part_header_lines + self.min_free_disk_bytes = min_free_disk_bytes + self.handle_files = handle_files + + self.state = self.STATE_PREAMBLE + self.buffer = bytearray() + self.total_bytes = 0 + self.field_count = 0 + self.file_count = 0 + self.part_count = 0 + self.current_part_size = 0 + self.current_header_bytes = 0 + self.current_header_lines = 0 + + self.form_data = FormData() + self._disk_check_interval_bytes = 1024 * 1024 # 1MB between disk checks + self._bytes_since_disk_check = 0 + self._tempdir = tempfile.gettempdir() + + # Current part state + self.current_headers: Dict[str, str] = {} + self.current_file: Optional[tempfile.SpooledTemporaryFile] = None + self.current_body = bytearray() + self.current_name: Optional[str] = None + self.current_filename: Optional[str] = None + self.current_content_type: Optional[str] = None + + def feed(self, chunk: bytes) -> None: + """Feed a chunk of data to the parser.""" + self.total_bytes += len(chunk) + if self.total_bytes > self.max_request_size: + raise MultipartParseError("Request body too large") + + self.buffer.extend(chunk) + self._process() + + def _process(self) -> None: + """Process buffered data.""" + while True: + if self.state == self.STATE_PREAMBLE: + if not self._process_preamble(): + break + elif self.state == self.STATE_HEADER: + if not self._process_header(): + break + elif self.state == self.STATE_BODY: + if not self._process_body(): + break + elif self.state == self.STATE_DONE: + break + + def _process_preamble(self) -> bool: + """Skip preamble and find first boundary.""" + # Look for boundary (could be at start or after preamble) + # Try both \r\n prefixed and bare boundary at start + idx = self.buffer.find(self.boundary) + if idx == -1: + # Keep potential partial boundary at end + keep = len(self.boundary) - 1 + if len(self.buffer) > keep: + self.buffer = self.buffer[-keep:] + return False + + # Found boundary, skip to after it + after_boundary = idx + len(self.boundary) + + # Check for end boundary + if self.buffer[idx : idx + len(self.end_boundary)] == self.end_boundary: + self.state = self.STATE_DONE + return False + + # Skip CRLF or LF after boundary + if after_boundary < len(self.buffer): + if self.buffer[after_boundary : after_boundary + 2] == b"\r\n": + after_boundary += 2 + elif self.buffer[after_boundary : after_boundary + 1] == b"\n": + after_boundary += 1 + + self.buffer = self.buffer[after_boundary:] + self.state = self.STATE_HEADER + self.current_headers = {} + self.current_header_bytes = 0 + self.current_header_lines = 0 + return True + + def _process_header(self) -> bool: + """Parse part headers.""" + while True: + # Look for end of header line + crlf_idx = self.buffer.find(b"\r\n") + lf_idx = self.buffer.find(b"\n") + + if crlf_idx == -1 and lf_idx == -1: + # Guard against unbounded header buffering if no newline is ever sent + if len(self.buffer) > self.max_part_header_bytes: + raise MultipartParseError("Part headers too large") + return False # Need more data + + # Use whichever comes first + if crlf_idx != -1 and (lf_idx == -1 or crlf_idx < lf_idx): + idx = crlf_idx + line_end_len = 2 + else: + idx = lf_idx + line_end_len = 1 + + line = self.buffer[:idx] + self.buffer = self.buffer[idx + line_end_len :] + + self.current_header_lines += 1 + self.current_header_bytes += idx + line_end_len + if ( + self.current_header_lines > self.max_part_header_lines + or self.current_header_bytes > self.max_part_header_bytes + ): + raise MultipartParseError("Part headers too large") + + if not line: + # Empty line = end of headers + self._start_body() + self.state = self.STATE_BODY + return True + + # Parse header + try: + line_str = line.decode("utf-8", errors="replace") + except Exception: + line_str = line.decode("latin-1") + + if ":" in line_str: + name, _, value = line_str.partition(":") + self.current_headers[name.strip().lower()] = value.strip() + + def _start_body(self) -> None: + """Initialize body parsing for current part.""" + self.part_count += 1 + if self.part_count > self.max_parts: + raise MultipartParseError("Too many parts") + + # Parse Content-Disposition + cd = self.current_headers.get("content-disposition", "") + parsed = parse_content_disposition(cd) + self.current_name = parsed.get("name") + self.current_filename = parsed.get("filename") + self.current_content_type = self.current_headers.get("content-type") + self.current_part_size = 0 + + if self.current_filename is not None: + # It's a file + self.file_count += 1 + if self.file_count > self.max_files: + raise MultipartParseError("Too many files") + if self.handle_files: + self.current_file = tempfile.SpooledTemporaryFile( + max_size=self.max_memory_file_size + ) + else: + # Will discard file content + self.current_file = None + else: + # It's a text field + self.field_count += 1 + if self.field_count > self.max_fields: + raise MultipartParseError("Too many fields") + self.current_body = bytearray() + self.current_file = None + + # Check disk space before allocating a spooled temp file + if self.current_filename is not None and self.handle_files: + self._ensure_disk_space() + + def _process_body(self) -> bool: + """Process body data for current part.""" + # Look for boundary in buffer + # Need to handle boundary potentially split across chunks + + # The boundary is preceded by \r\n (or \n for lenient parsing) + search_boundary = b"\r\n" + self.boundary + + idx = self.buffer.find(search_boundary) + if idx == -1: + # Try LF-only boundary (lenient) + search_boundary_lf = b"\n" + self.boundary + idx = self.buffer.find(search_boundary_lf) + if idx != -1: + search_boundary = search_boundary_lf + + if idx == -1: + # No boundary found yet + # Keep potential partial boundary at end of buffer + safe_len = len(self.buffer) - len(search_boundary) - 1 + if safe_len > 0: + safe_data = self.buffer[:safe_len] + self._write_body_data(bytes(safe_data)) + self.buffer = self.buffer[safe_len:] + return False + + # Found boundary - write remaining body data + body_data = self.buffer[:idx] + self._write_body_data(bytes(body_data)) + + # Move past the boundary + after_boundary = idx + len(search_boundary) + + # Check for end boundary + remaining = self.buffer[after_boundary:] + if remaining.startswith(b"--"): + # End boundary + self._finish_part() + self.state = self.STATE_DONE + return False + + # Skip CRLF or LF after boundary + if remaining.startswith(b"\r\n"): + after_boundary += 2 + elif remaining.startswith(b"\n"): + after_boundary += 1 + + self.buffer = self.buffer[after_boundary:] + self._finish_part() + self.state = self.STATE_HEADER + self.current_headers = {} + self.current_header_bytes = 0 + self.current_header_lines = 0 + return True + + def _write_body_data(self, data: bytes) -> None: + """Write data to current part body.""" + if not data: + return + + self.current_part_size += len(data) + + if self.current_filename is not None: + # File data + if self.current_part_size > self.max_file_size: + raise MultipartParseError("File too large") + if self.handle_files and self.current_file: + self._bytes_since_disk_check += len(data) + if self._bytes_since_disk_check >= self._disk_check_interval_bytes: + self._ensure_disk_space() + self._bytes_since_disk_check = 0 + self.current_file.write(data) + # else: discard file data + else: + # Field data + if self.current_part_size > self.max_field_size: + raise MultipartParseError("Field value too large") + self.current_body.extend(data) + + def _finish_part(self) -> None: + """Finalize current part and add to form data.""" + if self.current_name is None: + return + + if self.current_filename is not None: + # File + if self.handle_files and self.current_file: + self.current_file.seek(0) + uploaded = UploadedFile( + name=self.current_name, + filename=self.current_filename, + content_type=self.current_content_type, + size=self.current_part_size, + _file=self.current_file, + ) + self.form_data.append(self.current_name, uploaded) + # else: file was discarded + else: + # Text field + try: + value = bytes(self.current_body).decode("utf-8") + except UnicodeDecodeError: + value = bytes(self.current_body).decode("latin-1") + self.form_data.append(self.current_name, value) + + # Reset part state + self.current_file = None + self.current_body = bytearray() + self.current_name = None + self.current_filename = None + self.current_content_type = None + + def finalize(self) -> FormData: + """Finalize parsing and return form data.""" + # Process any remaining data + self._process() + if self.state != self.STATE_DONE: + raise MultipartParseError( + "Truncated multipart body (missing closing boundary)" + ) + return self.form_data + + def _ensure_disk_space(self) -> None: + """ + Ensure there is enough free space on the temp filesystem. + + This is a best-effort guard against filling the disk with uploads. + """ + if not self.handle_files: + return + if self.min_free_disk_bytes <= 0: + return + free_bytes = shutil.disk_usage(self._tempdir).free + if free_bytes < self.min_free_disk_bytes: + raise MultipartParseError("Insufficient disk space for uploads") + + +async def parse_form_data( + receive: Callable, + content_type: str, + files: bool = False, + max_file_size: int = DEFAULT_MAX_FILE_SIZE, + max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, + max_fields: int = DEFAULT_MAX_FIELDS, + max_files: int = DEFAULT_MAX_FILES, + max_parts: Optional[int] = DEFAULT_MAX_PARTS, + max_field_size: int = DEFAULT_MAX_FIELD_SIZE, + max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, + max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, + max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES, + min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES, +) -> FormData: + """ + Parse form data from an ASGI receive callable. + + Supports both application/x-www-form-urlencoded and multipart/form-data. + + Args: + receive: ASGI receive callable + content_type: Content-Type header value + files: If True, store file uploads; if False, discard them + max_file_size: Maximum size per file in bytes + max_request_size: Maximum total request size in bytes + max_fields: Maximum number of form fields + max_files: Maximum number of file uploads + max_field_size: Maximum size of a text field value + max_memory_file_size: File size threshold before spilling to disk + + Returns: + FormData object containing parsed fields and files + """ + media_type, params = parse_content_type(content_type) + + if media_type == "application/x-www-form-urlencoded": + # Read entire body for URL-encoded forms (they're typically small) + body = bytearray() + total = 0 + while True: + message = await receive() + message_type = message.get("type") + if message_type == "http.disconnect": + raise MultipartParseError("Client disconnected during request body") + if message_type is not None and message_type != "http.request": + continue + chunk = message.get("body", b"") + total += len(chunk) + if total > max_request_size: + raise MultipartParseError("Request body too large") + body.extend(chunk) + if not message.get("more_body", False): + break + + form_data = FormData() + try: + pairs = parse_qsl(bytes(body).decode("utf-8"), keep_blank_values=True) + except UnicodeDecodeError: + pairs = parse_qsl(bytes(body).decode("latin-1"), keep_blank_values=True) + + for key, value in pairs: + form_data.append(key, value) + + return form_data + + elif media_type == "multipart/form-data": + boundary = params.get("boundary") + if not boundary: + raise MultipartParseError("Missing boundary in Content-Type") + + parser = MultipartParser( + boundary=boundary.encode("utf-8"), + max_file_size=max_file_size, + max_request_size=max_request_size, + max_fields=max_fields, + max_files=max_files, + max_parts=max_parts, + max_field_size=max_field_size, + max_memory_file_size=max_memory_file_size, + max_part_header_bytes=max_part_header_bytes, + max_part_header_lines=max_part_header_lines, + min_free_disk_bytes=min_free_disk_bytes, + handle_files=files, + ) + + # Stream body through parser + batch_target = 64 * 1024 + batch = bytearray() + + async def flush_batch() -> None: + if batch: + data = bytes(batch) + batch.clear() + await asyncio.to_thread(parser.feed, data) + + while True: + message = await receive() + message_type = message.get("type") + if message_type == "http.disconnect": + raise MultipartParseError("Client disconnected during request body") + if message_type is not None and message_type != "http.request": + continue + chunk = message.get("body", b"") + if chunk: + batch.extend(chunk) + if len(batch) >= batch_target: + await flush_batch() + if not message.get("more_body", False): + break + + await flush_batch() + return await asyncio.to_thread(parser.finalize) + + else: + raise MultipartParseError( + f"Unsupported Content-Type: {media_type}. " + "Expected application/x-www-form-urlencoded or multipart/form-data" + ) diff --git a/datasette/views/special.py b/datasette/views/special.py index 411363ec..57a3024d 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -177,11 +177,11 @@ class PermissionsDebugView(BaseView): async def post(self, request): await self.ds.ensure_permission(action="view-instance", actor=request.actor) await self.ds.ensure_permission(action="permissions-debug", actor=request.actor) - vars = await request.post_vars() - actor = json.loads(vars["actor"]) - permission = vars["permission"] - parent = vars.get("resource_1") or None - child = vars.get("resource_2") or None + form = await request.form() + actor = json.loads(form["actor"]) + permission = form["permission"] + parent = form.get("resource_1") or None + child = form.get("resource_2") or None response, status = await _check_permission_for_actor( self.ds, permission, parent, child, actor @@ -602,9 +602,9 @@ class MessagesDebugView(BaseView): async def post(self, request): await self.ds.ensure_permission(action="view-instance", actor=request.actor) - post = await request.post_vars() - message = post.get("message", "") - message_type = post.get("message_type") or "INFO" + form = await request.form() + message = form.get("message", "") + message_type = form.get("message_type") or "INFO" assert message_type in ("INFO", "WARNING", "ERROR", "all") datasette = self.ds if message_type == "all": @@ -688,11 +688,11 @@ class CreateTokenView(BaseView): async def post(self, request): self.check_permission(request) - post = await request.post_vars() + form = await request.form() errors = [] expires_after = None - if post.get("expire_type"): - duration_string = post.get("expire_duration") + if form.get("expire_type"): + duration_string = form.get("expire_duration") if ( not duration_string or not duration_string.isdigit() @@ -700,7 +700,7 @@ class CreateTokenView(BaseView): ): errors.append("Invalid expire duration") else: - unit = post["expire_type"] + unit = form["expire_type"] if unit == "minutes": expires_after = int(duration_string) * 60 elif unit == "hours": @@ -715,7 +715,7 @@ class CreateTokenView(BaseView): restrict_database = {} restrict_resource = {} - for key in post: + for key in form: if key.startswith("all:") and key.count(":") == 1: restrict_all.append(key.split(":")[1]) elif key.startswith("database:") and key.count(":") == 2: diff --git a/docs/internals.rst b/docs/internals.rst index cfd78593..0491c1f7 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -52,10 +52,59 @@ The request object is passed to various plugin hooks. It represents an incoming ``.actor`` - dictionary (str -> Any) or None The currently authenticated actor (see :ref:`actors `), or ``None`` if the request is unauthenticated. -The object also has two awaitable methods: +The object also has the following awaitable methods: + +``await request.form(files=False, ...)`` - FormData + Parses form data from the request body. Supports both ``application/x-www-form-urlencoded`` and ``multipart/form-data`` content types. + + Returns a :ref:`internals_formdata` object with dict-like access to form fields and uploaded files. + + Requirements and errors: + + - A ``Content-Type`` header is required. Missing or unsupported content types raise ``BadRequest``. + - For ``multipart/form-data``, the ``boundary=...`` parameter is required. + + Parameters: + + - ``files`` (bool, default ``False``): If ``True``, uploaded files are stored and accessible. If ``False`` (default), file content is discarded but form fields are still available. + - ``max_file_size`` (int, default 50MB): Maximum size per uploaded file in bytes. + - ``max_request_size`` (int, default 100MB): Maximum total request body size in bytes. + - ``max_fields`` (int, default 1000): Maximum number of form fields. + - ``max_files`` (int, default 100): Maximum number of uploaded files. + - ``max_parts`` (int, default ``max_fields + max_files``): Maximum number of multipart parts in total. + - ``max_field_size`` (int, default 100KB): Maximum size of a text field value in bytes. + - ``max_memory_file_size`` (int, default 1MB): File size threshold before uploads spill to disk. + - ``max_part_header_bytes`` (int, default 16KB): Maximum total bytes allowed in part headers. + - ``max_part_header_lines`` (int, default 100): Maximum header lines per part. + - ``min_free_disk_bytes`` (int, default 50MB): Minimum free bytes required in the temp directory before accepting file uploads. + + Example usage: + + .. code-block:: python + + # Parse form fields only (files are discarded) + form = await request.form() + username = form["username"] + tags = form.getlist("tags") # For multiple values + + # Parse form fields AND files + form = await request.form(files=True) + uploaded = form["avatar"] + content = await uploaded.read() + print( + uploaded.filename, uploaded.content_type, uploaded.size + ) + + Cleanup note: + + When using ``files=True``, call ``await form.aclose()`` once you are done with the uploads + to ensure spooled temporary files are closed promptly. You can also use + ``async with form: ...`` for automatic cleanup. + + Don't forget to read about :ref:`internals_csrf`! ``await request.post_vars()`` - dictionary - Returns a dictionary of form variables that were submitted in the request body via ``POST``. Don't forget to read about :ref:`internals_csrf`! + Returns a dictionary of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. For multipart forms or file uploads, use ``request.form()`` instead. ``await request.post_body()`` - bytes Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data. @@ -117,6 +166,84 @@ Consider the query string ``?foo=1&foo=2&bar=3`` - with two values for ``foo`` a ``len(request.args)`` - integer Returns the number of keys. +.. _internals_formdata: + +The FormData class +================== + +``await request.form()`` returns a ``FormData`` object - a dictionary-like object which provides access to form fields and uploaded files. It has a similar interface to ``MultiParams``. + +``form[key]`` - string or UploadedFile + Returns the first value for that key, or raises a ``KeyError`` if the key is missing. + +``form.get(key)`` - string, UploadedFile, or None + Returns the first value for that key, or ``None`` if the key is missing. Pass a second argument to specify a different default. + +``form.getlist(key)`` - list + Returns the list of values for that key. If the key is missing an empty list will be returned. + +``form.keys()`` - list of strings + Returns the list of available keys. + +``key in form`` - True or False + You can use ``if key in form`` to check if a key is present. + +``for key in form`` - iterator + This lets you loop through every available key. + +``len(form)`` - integer + Returns the total number of submitted values. + +.. _internals_uploadedfile: + +The UploadedFile class +====================== + +When parsing multipart form data with ``files=True``, file uploads are returned as ``UploadedFile`` objects with the following properties and methods: + +``uploaded_file.name`` - string + The form field name. + +``uploaded_file.filename`` - string + The original filename provided by the client. Note: This is sanitized to remove path components for security. + +``uploaded_file.content_type`` - string or None + The MIME type of the uploaded file, if provided by the client. + +``uploaded_file.size`` - integer + The size of the uploaded file in bytes. + +``await uploaded_file.read(size=-1)`` - bytes + Read and return up to ``size`` bytes from the file. If ``size`` is -1 (default), read the entire file. + +``await uploaded_file.seek(offset, whence=0)`` - integer + Seek to the given position in the file. Returns the new position. + +``await uploaded_file.close()`` + Close the underlying file. This is called automatically when the object is garbage collected. + +Files smaller than 1MB are stored in memory. Larger files are automatically spilled to temporary files on disk and cleaned up when the request completes. + +Example: + +.. code-block:: python + + form = await request.form(files=True) + uploaded = form["document"] + + # Check file metadata + print(f"Filename: {uploaded.filename}") + print(f"Content-Type: {uploaded.content_type}") + print(f"Size: {uploaded.size} bytes") + + # Read file content + content = await uploaded.read() + + # Or read in chunks + await uploaded.seek(0) + while chunk := await uploaded.read(8192): + process_chunk(chunk) + .. _internals_response: Response class diff --git a/pyproject.toml b/pyproject.toml index 6fca673d..d9ef2a73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dev = [ "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", + "multipart-form-data-conformance==0.1a0", "ruff>=0.9", # docs "Sphinx==7.4.7", diff --git a/tests/test_multipart.py b/tests/test_multipart.py new file mode 100644 index 00000000..0dc3ecd7 --- /dev/null +++ b/tests/test_multipart.py @@ -0,0 +1,1152 @@ +""" +Tests for request.form() multipart form data parsing. + +Uses TDD approach - these tests are written first, then implementation follows. +""" + +import base64 +import json +import pytest +from collections import namedtuple + +from multipart_form_data_conformance import get_tests_dir + +from datasette.utils.asgi import Request, BadRequest + + +def make_receive(body: bytes): + """Create an async receive callable that yields body in chunks.""" + consumed = False + + async def receive(): + nonlocal consumed + if consumed: + return {"type": "http.request", "body": b"", "more_body": False} + consumed = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +def make_chunked_receive(body: bytes, chunk_size: int = 64): + """Create an async receive callable that yields body in small chunks.""" + offset = 0 + + async def receive(): + nonlocal offset + chunk = body[offset : offset + chunk_size] + offset += chunk_size + more_body = offset < len(body) + return {"type": "http.request", "body": chunk, "more_body": more_body} + + return receive + + +def make_receive_with_noise(body: bytes): + """ + Create an async receive callable that includes an unexpected ASGI message. + + The parser should ignore the unknown message type and continue. + """ + messages = [ + {"type": "http.response.start", "status": 200, "headers": []}, + {"type": "http.request", "body": body, "more_body": False}, + ] + index = 0 + + async def receive(): + nonlocal index + if index >= len(messages): + return {"type": "http.request", "body": b"", "more_body": False} + message = messages[index] + index += 1 + return message + + return receive + + +def make_disconnect_receive(body: bytes, chunk_size: int = 64): + """ + Create an async receive callable that disconnects mid-request. + + The parser should raise on the disconnect. + """ + offset = 0 + disconnected = False + + async def receive(): + nonlocal offset, disconnected + if disconnected: + return {"type": "http.disconnect"} + chunk = body[offset : offset + chunk_size] + offset += chunk_size + more_body = offset < len(body) + if more_body: + disconnected = True + return {"type": "http.request", "body": chunk, "more_body": more_body} + + return receive + + +class TestFormUrlEncoded: + """Test request.form() with application/x-www-form-urlencoded data.""" + + @pytest.mark.asyncio + async def test_basic_form_fields(self): + """Basic URL-encoded form should be parseable via request.form().""" + body = b"username=john&password=secret" + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", b"application/x-www-form-urlencoded"), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() + + assert form["username"] == "john" + assert form["password"] == "secret" + + @pytest.mark.asyncio + async def test_form_with_multiple_values(self): + """Multiple values for same key should be accessible via getlist().""" + body = b"tag=python&tag=web&tag=api" + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", b"application/x-www-form-urlencoded"), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() + + assert form["tag"] == "python" # First value + assert form.getlist("tag") == ["python", "web", "api"] + + @pytest.mark.asyncio + async def test_empty_form(self): + """Empty form should return empty FormData.""" + body = b"" + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", b"application/x-www-form-urlencoded"), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() + + assert len(form) == 0 + + @pytest.mark.asyncio + async def test_form_with_special_characters(self): + """URL-encoded special characters should be decoded properly.""" + body = b"message=hello%20world&emoji=%F0%9F%91%8B" + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", b"application/x-www-form-urlencoded"), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() + + assert form["message"] == "hello world" + assert form["emoji"] == "👋" + + +class TestMultipartBasic: + """Test request.form() with multipart/form-data (fields only, no files).""" + + @pytest.mark.asyncio + async def test_single_text_field(self): + """Single text field in multipart should be parseable.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="username"\r\n' + b"\r\n" + b"john_doe\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() + + assert form["username"] == "john_doe" + + @pytest.mark.asyncio + async def test_multiple_text_fields(self): + """Multiple text fields in multipart should all be accessible.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="first_name"\r\n' + b"\r\n" + b"John\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="last_name"\r\n' + b"\r\n" + b"Doe\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() + + assert form["first_name"] == "John" + assert form["last_name"] == "Doe" + + @pytest.mark.asyncio + async def test_file_discarded_when_files_false(self): + """File content should be discarded when files=False (default).""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="title"\r\n' + b"\r\n" + b"My Document\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="doc.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"File content here\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="description"\r\n' + b"\r\n" + b"A sample document\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() # files=False is default + + # Text fields should be present + assert form["title"] == "My Document" + assert form["description"] == "A sample document" + # File should NOT be present + assert "file" not in form + + @pytest.mark.asyncio + async def test_chunked_body_parsing(self): + """Multipart should work when body arrives in small chunks.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="username"\r\n' + b"\r\n" + b"john_doe\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + # Use small chunks to test streaming parser + request = Request(scope, make_chunked_receive(body, chunk_size=16)) + + form = await request.form() + + assert form["username"] == "john_doe" + + +class TestMultipartWithFiles: + """Test request.form(files=True) for file uploads.""" + + @pytest.mark.asyncio + async def test_single_file_upload(self): + """Single file upload should create UploadedFile object.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="document"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"Hello, World!\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form(files=True) + + uploaded_file = form["document"] + assert uploaded_file.filename == "test.txt" + assert uploaded_file.content_type == "text/plain" + assert await uploaded_file.read() == b"Hello, World!" + assert uploaded_file.size == 13 + + @pytest.mark.asyncio + async def test_mixed_fields_and_files(self): + """Mixed form fields and files should all be accessible.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="title"\r\n' + b"\r\n" + b"My Document\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="doc.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"Document content\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="description"\r\n' + b"\r\n" + b"A sample\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form(files=True) + + # Text fields + assert form["title"] == "My Document" + assert form["description"] == "A sample" + # File + uploaded_file = form["file"] + assert uploaded_file.filename == "doc.txt" + assert await uploaded_file.read() == b"Document content" + + @pytest.mark.asyncio + async def test_multiple_files_same_name(self): + """Multiple files with same name should be accessible via getlist().""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="files"; filename="a.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"File A\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="files"; filename="b.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"File B\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form(files=True) + + files = form.getlist("files") + assert len(files) == 2 + assert files[0].filename == "a.txt" + assert files[1].filename == "b.txt" + + @pytest.mark.asyncio + async def test_large_file_spills_to_disk(self): + """Files larger than threshold should spill to temp file.""" + boundary = "----TestBoundary123" + # Create a body larger than the in-memory threshold (1MB) + large_content = b"x" * (2 * 1024 * 1024) # 2MB + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="bigfile"; filename="large.bin"\r\n' + b"Content-Type: application/octet-stream\r\n" + b"\r\n" + large_content + b"\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form(files=True) + + uploaded_file = form["bigfile"] + assert uploaded_file.size == len(large_content) + # Content should still be readable + content = await uploaded_file.read() + assert content == large_content + + @pytest.mark.asyncio + async def test_uploaded_file_seek_and_read(self): + """UploadedFile should support seek and multiple reads.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"Hello, World!\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form(files=True) + uploaded_file = form["file"] + + # First read + content1 = await uploaded_file.read() + assert content1 == b"Hello, World!" + + # Seek back to start + await uploaded_file.seek(0) + + # Second read + content2 = await uploaded_file.read() + assert content2 == b"Hello, World!" + + +class TestMultipartCleanup: + """Test deterministic cleanup of uploaded files.""" + + @pytest.mark.asyncio + async def test_formdata_close_closes_uploaded_files(self): + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"Hello\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + form = await request.form(files=True) + uploaded_file = form["file"] + + form.close() + + with pytest.raises(ValueError): + await uploaded_file.read() + + @pytest.mark.asyncio + async def test_formdata_async_context_manager_closes_files(self): + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"Hello\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + form = await request.form(files=True) + uploaded_file = form["file"] + + async with form: + pass + + with pytest.raises(ValueError): + await uploaded_file.read() + + +class TestMultipartEdgeCases: + """Test edge cases in multipart parsing.""" + + @pytest.mark.asyncio + async def test_empty_file_upload(self): + """Empty file (filename but no content) should be handled.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="empty.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form(files=True) + + uploaded_file = form["file"] + assert uploaded_file.filename == "empty.txt" + assert uploaded_file.size == 0 + assert await uploaded_file.read() == b"" + + @pytest.mark.asyncio + async def test_filename_with_path(self): + """Filename containing path should extract just the filename.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="C:\\Users\\test\\doc.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"content\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form(files=True) + + # Should extract just the filename, not the full path + uploaded_file = form["file"] + assert uploaded_file.filename == "doc.txt" + + @pytest.mark.asyncio + async def test_missing_content_type_header(self): + """Missing content-type in request should raise BadRequest.""" + body = b"some body" + scope = { + "type": "http", + "method": "POST", + "headers": [], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest): + await request.form() + + @pytest.mark.asyncio + async def test_invalid_content_type(self): + """Non-form content-type should raise BadRequest.""" + body = b'{"key": "value"}' + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", b"application/json"), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest): + await request.form() + + @pytest.mark.asyncio + async def test_missing_boundary(self): + """Multipart without boundary should raise BadRequest.""" + body = b"some body" + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", b"multipart/form-data"), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest): + await request.form() + + +class TestSecurityLimits: + """Test security limits on form parsing.""" + + @pytest.mark.asyncio + async def test_max_fields_limit(self): + """Should reject requests with too many fields.""" + boundary = "----TestBoundary123" + # Create body with many fields + parts = [] + for i in range(1001): # Default max is 1000 + parts.append( + f"------TestBoundary123\r\n" + f'Content-Disposition: form-data; name="field{i}"\r\n' + f"\r\n" + f"value{i}\r\n" + ) + parts.append("------TestBoundary123--\r\n") + body = "".join(parts).encode() + + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="(?i)too many"): + await request.form(max_fields=1000) + + @pytest.mark.asyncio + async def test_max_file_size_limit(self): + """Should reject files exceeding size limit.""" + boundary = "----TestBoundary123" + large_content = b"x" * (11 * 1024 * 1024) # 11MB + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="big.bin"\r\n' + b"Content-Type: application/octet-stream\r\n" + b"\r\n" + large_content + b"\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="(?i)file.*too large|too large"): + await request.form(files=True, max_file_size=10 * 1024 * 1024) + + @pytest.mark.asyncio + async def test_max_request_size_limit(self): + """Should reject requests exceeding total size limit.""" + boundary = "----TestBoundary123" + large_content = b"x" * (6 * 1024 * 1024) # 6MB + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="big.bin"\r\n' + b"Content-Type: application/octet-stream\r\n" + b"\r\n" + large_content + b"\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="(?i)too large|request.*too large"): + await request.form(files=True, max_request_size=5 * 1024 * 1024) + + +class TestMultipartStrictnessAndLimits: + """Tests that enforce stricter ASGI and multipart behaviors.""" + + @pytest.mark.asyncio + async def test_multipart_truncated_body_is_error(self): + """Truncated multipart without closing boundary should raise.""" + boundary = "----TestBoundary123" + # Missing the final closing boundary line + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="field"\r\n' + b"\r\n" + b"value\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="Truncated multipart body"): + await request.form() + + @pytest.mark.asyncio + async def test_disconnect_mid_body_is_error(self): + """Client disconnect during body streaming should raise.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="field"\r\n' + b"\r\n" + b"value\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_disconnect_receive(body, chunk_size=16)) + + with pytest.raises(BadRequest, match="disconnected"): + await request.form() + + @pytest.mark.asyncio + async def test_unknown_asgi_message_type_is_ignored(self): + """Unexpected ASGI message types should be ignored.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="field"\r\n' + b"\r\n" + b"value\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive_with_noise(body)) + + form = await request.form() + assert form["field"] == "value" + + @pytest.mark.asyncio + async def test_max_files_enforced_even_when_files_false(self): + """File count limits should apply even when file handling is disabled.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="f1"; filename="a.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"a\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="f2"; filename="b.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"b\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="Too many files"): + await request.form(files=False, max_files=1) + + @pytest.mark.asyncio + async def test_max_parts_limit(self): + """Total part count should be bounded.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="a"\r\n' + b"\r\n" + b"1\r\n" + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="b"\r\n' + b"\r\n" + b"2\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="Too many parts"): + await request.form(max_parts=1) + + @pytest.mark.asyncio + async def test_max_file_size_enforced_even_when_files_false(self): + """File size limits should apply even when file handling is disabled.""" + boundary = "----TestBoundary123" + big_content = b"x" * 2048 + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="big.bin"\r\n' + b"Content-Type: application/octet-stream\r\n" + b"\r\n" + big_content + b"\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="File too large"): + await request.form(files=False, max_file_size=1024) + + @pytest.mark.asyncio + async def test_part_header_limits(self): + """Overly large part headers should be rejected.""" + boundary = "----TestBoundary123" + huge_header_value = "x" * 5000 + body = ( + b"------TestBoundary123\r\n" + + f'Content-Disposition: form-data; name="field"; foo="{huge_header_value}"\r\n'.encode() + + b"\r\n" + + b"value\r\n" + + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="headers too large"): + await request.form(max_part_header_bytes=1024) + + @pytest.mark.asyncio + async def test_insufficient_disk_space_rejects_upload(self, monkeypatch): + """Uploads should be rejected when free disk is below the floor.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n" + b"\r\n" + b"Hello\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + + DiskUsage = namedtuple("DiskUsage", ("total", "used", "free")) + monkeypatch.setattr( + "datasette.utils.multipart.shutil.disk_usage", + lambda path: DiskUsage(total=100, used=95, free=5), + ) + + request = Request(scope, make_receive(body)) + with pytest.raises(BadRequest, match="Insufficient disk space"): + await request.form(files=True, min_free_disk_bytes=50) + + @pytest.mark.asyncio + async def test_low_disk_space_does_not_block_field_only_forms(self, monkeypatch): + """Low disk space should not reject multipart forms with no file parts.""" + boundary = "----TestBoundary123" + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="field"\r\n' + b"\r\n" + b"value\r\n" + b"------TestBoundary123--\r\n" + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + + DiskUsage = namedtuple("DiskUsage", ("total", "used", "free")) + monkeypatch.setattr( + "datasette.utils.multipart.shutil.disk_usage", + lambda path: DiskUsage(total=100, used=99, free=1), + ) + + request = Request(scope, make_receive(body)) + form = await request.form(files=True, min_free_disk_bytes=50) + assert form["field"] == "value" + + @pytest.mark.asyncio + async def test_headers_without_newline_hit_header_byte_limit(self): + """Headers that never terminate should still hit the header byte limit.""" + boundary = "----TestBoundary123" + huge = b"x" * 5000 + # No CRLF is included after the header line + body = ( + b"------TestBoundary123\r\n" + b'Content-Disposition: form-data; name="field"; foo="' + huge + b'"' + ) + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + ], + } + request = Request(scope, make_receive(body)) + + with pytest.raises(BadRequest, match="headers too large"): + await request.form(max_part_header_bytes=1024) + + +class TestFormDataLenSemantics: + """Test that FormData.__len__ reflects number of items, not unique keys.""" + + @pytest.mark.asyncio + async def test_len_counts_items(self): + body = b"tag=python&tag=web&tag=api" + scope = { + "type": "http", + "method": "POST", + "headers": [ + (b"content-type", b"application/x-www-form-urlencoded"), + ], + } + request = Request(scope, make_receive(body)) + + form = await request.form() + assert len(form) == 3 + + +# Conformance test suite using multipart-form-data-conformance + +# Tests where our parser intentionally differs from strict spec for security/practicality +# Our parser sanitizes filenames (strips paths) while the conformance suite expects raw +FILENAME_SANITIZATION_TESTS = { + "026-filename-with-backslash", # We preserve backslashes but they test expects raw + "029-filename-path-traversal", # We strip path components for security +} + +# Tests for optional/lenient features we don't implement +OPTIONAL_TESTS = { + "085-header-folding", # Obsolete header folding feature +} + +# Tests for malformed input where we're lenient instead of erroring +LENIENT_PARSING_TESTS = { + "203-missing-content-disposition", + "204-invalid-content-disposition", +} + + +def load_conformance_test_cases(): + """Load all test cases from multipart-form-data-conformance.""" + tests_dir = get_tests_dir() + test_cases = [] + + for category_dir in sorted(tests_dir.iterdir()): + if not category_dir.is_dir(): + continue + for test_dir in sorted(category_dir.iterdir()): + if not test_dir.is_dir(): + continue + test_json = test_dir / "test.json" + headers_json = test_dir / "headers.json" + input_raw = test_dir / "input.raw" + + if not all(f.exists() for f in [test_json, headers_json, input_raw]): + continue + + with open(test_json) as f: + test_spec = json.load(f) + with open(headers_json) as f: + headers = json.load(f) + with open(input_raw, "rb") as f: + body = f.read() + + test_id = test_spec["id"] + + # Add marks for tests we handle differently + marks = [] + if test_id in FILENAME_SANITIZATION_TESTS: + marks.append( + pytest.mark.xfail(reason="Parser sanitizes filenames for security") + ) + elif test_id in OPTIONAL_TESTS: + marks.append( + pytest.mark.xfail(reason="Optional feature not implemented") + ) + elif test_id in LENIENT_PARSING_TESTS: + marks.append( + pytest.mark.xfail(reason="Parser is lenient with malformed input") + ) + + test_cases.append( + pytest.param( + test_spec, + headers, + body, + id=test_id, + marks=marks, + ) + ) + + return test_cases + + +CONFORMANCE_TEST_CASES = load_conformance_test_cases() + + +@pytest.mark.parametrize("test_spec,headers,body", CONFORMANCE_TEST_CASES) +@pytest.mark.asyncio +async def test_conformance(test_spec, headers, body): + """ + Run conformance test cases from multipart-form-data-conformance. + + Each test case specifies: + - headers: HTTP headers including Content-Type with boundary + - body: Raw multipart body bytes + - expected: Expected parse result (valid/invalid, parts list) + """ + scope = { + "type": "http", + "method": "POST", + "headers": [(k.encode(), v.encode()) for k, v in headers.items()], + } + request = Request(scope, make_receive(body)) + + expected = test_spec["expected"] + + if not expected["valid"]: + # Should raise an error for invalid input + with pytest.raises((BadRequest, ValueError)): + await request.form(files=True) + return + + # Parse form data + form = await request.form(files=True) + + # Verify each expected part + for i, expected_part in enumerate(expected["parts"]): + name = expected_part["name"] + + # Get value(s) for this name + values = form.getlist(name) + + # Find the value at the correct index for this name + # (handles multiple values with same name) + same_name_count = sum(1 for p in expected["parts"][:i] if p["name"] == name) + + if same_name_count >= len(values): + pytest.fail( + f"Expected part {name} at index {same_name_count} but only {len(values)} found" + ) + + value = values[same_name_count] + + # Determine expected content + if "body_base64" in expected_part: + expected_content = base64.b64decode(expected_part["body_base64"]) + elif "body_text" in expected_part: + expected_content = expected_part["body_text"].encode("utf-8") + else: + expected_content = None + + # Check for file vs field + # A part is a file if it has a filename OR filename_star + is_file = ( + expected_part.get("filename") is not None + or expected_part.get("filename_star") is not None + ) + + if is_file: + # It's a file + assert hasattr(value, "filename"), f"Expected file for {name}" + + # Check filename - use filename_star if present, else filename + expected_filename = expected_part.get("filename_star") or expected_part.get( + "filename" + ) + if expected_filename: + assert ( + value.filename == expected_filename + ), f"Filename mismatch: expected {expected_filename!r}, got {value.filename!r}" + + if expected_part.get("content_type"): + assert value.content_type == expected_part["content_type"] + + content = await value.read() + assert ( + len(content) == expected_part["body_size"] + ), f"Size mismatch: expected {expected_part['body_size']}, got {len(content)}" + if expected_content is not None: + assert content == expected_content + else: + # It's a text field + if hasattr(value, "filename"): + pytest.fail(f"Expected text field for {name}, got file") + + if expected_content is not None: + # For text fields, value is a string + try: + expected_text = expected_content.decode("utf-8") + except UnicodeDecodeError: + expected_text = expected_content.decode("latin-1") + assert ( + value == expected_text + ), f"Value mismatch: expected {expected_text!r}, got {value!r}" From b771e930bc16e128b48da80c9ccbba20cba177b5 Mon Sep 17 00:00:00 2001 From: Daniel Olasubomi Sobowale Date: Wed, 28 Jan 2026 20:41:58 -0600 Subject: [PATCH 010/203] Fix filter-input and search-input zoom on iOS Safari Closes #2346 --- .gitignore | 2 ++ datasette/static/app.css | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ce256606..12acd87e 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ node_modules tests/*.dylib tests/*.so tests/*.dll + +.idea \ No newline at end of file diff --git a/datasette/static/app.css b/datasette/static/app.css index a3117152..a7fc7fa3 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -647,10 +647,14 @@ button.core[type=button] { border-radius: 3px; -webkit-appearance: none; padding: 9px 4px; - font-size: 1em; + font-size: 16px; font-family: Helvetica, sans-serif; } +#_search { + font-size: 16px; +} + From 5873578d49a894e358f8480fee27e17e37f6c97e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Jan 2026 09:00:22 -0800 Subject: [PATCH 011/203] Release 1.0a24 Refs #2050, #2346, #2608, #2609, #2610, #2611, #2613, #2619, #2624, #2627, #2628, #2629, #2630, #2632 --- datasette/version.py | 2 +- docs/changelog.rst | 55 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index fff37a72..de7585ca 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a23" +__version__ = "1.0a24" __version_info__ = tuple(__version__.split(".")) diff --git a/docs/changelog.rst b/docs/changelog.rst index feba7e86..67ceeece 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,61 @@ Changelog ========= +.. _v1_0_a24: + +1.0a24 (2026-01-29) +------------------- + +``request.form()`` method for POST data and file uploads +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Datasette now includes a ``request.form()`` method for parsing form submissions, including handling file uploads. (`#2626 `__) + +This supports both ``application/x-www-form-urlencoded`` and ``multipart/form-data`` content types, and uses a new streaming multipart parser that processes uploads without buffering entire request bodies in memory. + +.. code-block:: python + + # Parse form fields (files are discarded by default) + form = await request.form() + username = form["username"] + + # Parse form fields AND file uploads + form = await request.form(files=True) + uploaded = form["avatar"] + content = await uploaded.read() + +The returned :ref:`FormData ` object provides dictionary-style access with support for multiple values per key via ``form.getlist("key")``. Uploaded files are represented as :ref:`UploadedFile ` objects with ``filename``, ``content_type``, ``size`` properties and async ``read()`` and ``seek()`` methods. + +Files smaller than 1MB are held in memory; larger files automatically spill to temporary files on disk. Configurable limits control maximum file size, request size, field counts and more. + +Several internal views (permissions debug, messages debug, create token) now use ``request.form()`` instead of ``request.post_vars()``. + +``request.post_vars()`` remains available for backwards compatibility but is no longer the recommended API for handling POST data. + +``render_cell`` and ``foreign_key_tables`` extras for the JSON API +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The table JSON API now supports ``?_extra=render_cell``, which returns the rendered HTML for each cell as produced by the :ref:`render_cell plugin hook `. Only columns whose rendered output differs from the default are included. (:issue:`2619`) + +The row JSON API also gains ``?_extra=render_cell`` and ``?_extra=foreign_key_tables`` extras, bringing it closer to parity with the table API. + +The row JSON API now returns ``"ok": true`` in its response, for consistency with the table API. + +``uv run pytest`` with a ``dev=`` dependency group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The recommended development environment for Datasette now uses `uv `__. You can now set up a development environment and run the test suite with just ``uv run pytest`` — no manual virtualenv or ``pip install`` step required. (:issue:`2611`) + +Other changes +~~~~~~~~~~~~~ + +- Plugins that raise ``datasette.utils.StartupError()`` during startup now display a clean error message instead of a full traceback. (:issue:`2624`) +- Schema refreshes are now throttled to at most once per second, providing a small performance increase. (:issue:`2629`) +- Minor performance improvement to ``remove_infinites`` — rows without infinity values now skip the list/dict reconstruction step. (:issue:`2629`) +- Filter inputs and the search input no longer trigger unwanted zoom on iOS Safari. Thanks, `Daniel Olasubomi Sobowale `__. (:issue:`2346`) +- ``table_names()`` and ``get_all_foreign_keys()`` now return results in deterministic sorted order. (:issue:`2628`) +- Switched linting to `ruff `__ and fixed all lint errors. (:issue:`2630`) + .. _v1_0_a23: 1.0a23 (2025-12-02) From 80b7f987cad59113896f28a29828ffe856218216 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 9 Feb 2026 13:20:33 -0800 Subject: [PATCH 012/203] write_wrapper plugin hook for intercepting write operations (#2636) * Implement write_wrapper plugin hook for intercepting database writes Add a new `write_wrapper` plugin hook that lets plugins wrap write operations with before/after logic using a generator-based context manager pattern. The hook receives (datasette, database, request, transaction) and returns a generator function that takes a conn, yields once to let the write execute, and can run cleanup after. The write result is sent back via `generator.send()` and exceptions are thrown via `generator.throw()`, giving plugins full visibility. Also adds `request=None` parameter to execute_write, execute_write_fn, execute_write_script, and execute_write_many, and threads request through all view-layer call sites (insert, upsert, update, delete, drop, create table, canned queries). * Add documentation for wrap_write hook, fix lint issues Document the wrap_write plugin hook in plugin_hooks.rst with parameter descriptions and two examples: a simple logging wrapper and an advanced SQLite authorizer-based table protection pattern. Also fix black formatting and remove unused variable flagged by ruff. * Rename wrap_write hook to write_wrapper for consistency with asgi_wrapper * Move write_wrapper docs to just below prepare_connection * Refactor write_wrapper tests to use pytest.parametrize Consolidate duplicate test cases: merge before/after tests for execute_write_fn and execute_write into one parametrized test, and merge three parameter-passing tests into one parametrized test. Claude Code transcript: https://gisthost.github.io/?c4c12079434e69677e4aa8ac664b21b8/index.html --- datasette/database.py | 77 ++++++- datasette/hookspecs.py | 22 ++ datasette/views/database.py | 6 +- datasette/views/row.py | 4 +- datasette/views/table.py | 4 +- docs/plugin_hooks.rst | 87 ++++++++ tests/test_plugins.py | 30 +++ tests/test_write_wrapper.py | 387 ++++++++++++++++++++++++++++++++++++ 8 files changed, 604 insertions(+), 13 deletions(-) create mode 100644 tests/test_write_wrapper.py diff --git a/datasette/database.py b/datasette/database.py index 8e4ee2b6..1e6f9032 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -130,25 +130,25 @@ class Database: for connection in self._all_file_connections: connection.close() - async def execute_write(self, sql, params=None, block=True): + async def execute_write(self, sql, params=None, block=True, request=None): def _inner(conn): return conn.execute(sql, params or []) with trace("sql", database=self.name, sql=sql.strip(), params=params): - results = await self.execute_write_fn(_inner, block=block) + results = await self.execute_write_fn(_inner, block=block, request=request) return results - async def execute_write_script(self, sql, block=True): + async def execute_write_script(self, sql, block=True, request=None): def _inner(conn): return conn.executescript(sql) with trace("sql", database=self.name, sql=sql.strip(), executescript=True): results = await self.execute_write_fn( - _inner, block=block, transaction=False + _inner, block=block, transaction=False, request=request ) return results - async def execute_write_many(self, sql, params_seq, block=True): + async def execute_write_many(self, sql, params_seq, block=True, request=None): def _inner(conn): count = 0 @@ -163,7 +163,9 @@ class Database: with trace( "sql", database=self.name, sql=sql.strip(), executemany=True ) as kwargs: - results, count = await self.execute_write_fn(_inner, block=block) + results, count = await self.execute_write_fn( + _inner, block=block, request=request + ) kwargs["count"] = count return results @@ -187,7 +189,8 @@ class Database: # Threaded mode - send to write thread return await self._send_to_write_thread(fn, isolated_connection=True) - async def execute_write_fn(self, fn, block=True, transaction=True): + async def execute_write_fn(self, fn, block=True, transaction=True, request=None): + fn = self._wrap_fn_with_hooks(fn, request, transaction) if self.ds.executor is None: # non-threaded mode if self._write_connection is None: @@ -203,6 +206,25 @@ class Database: fn, block=block, transaction=transaction ) + def _wrap_fn_with_hooks(self, fn, request, transaction): + from .plugins import pm + + wrappers = pm.hook.write_wrapper( + datasette=self.ds, + database=self.name, + request=request, + transaction=transaction, + ) + wrappers = [w for w in wrappers if w is not None] + if not wrappers: + return fn + # Build the wrapped fn by nesting context manager generators. + # The first wrapper returned by pluggy is outermost. + original_fn = fn + for wrapper_factory in reversed(wrappers): + original_fn = _apply_write_wrapper(original_fn, wrapper_factory) + return original_fn + async def _send_to_write_thread( self, fn, block=True, isolated_connection=False, transaction=True ): @@ -680,6 +702,47 @@ class Database: return f"" +def _apply_write_wrapper(fn, wrapper_factory): + """Apply a single write_wrapper context manager around fn. + + ``wrapper_factory`` is a callable that takes ``(conn)`` and returns a + generator that yields exactly once. Code before the yield runs before + ``fn(conn)``, code after the yield runs after. The result of + ``fn(conn)`` is sent into the generator via ``.send()``, and any + exception raised by ``fn(conn)`` is thrown via ``.throw()``. + """ + + def wrapped(conn): + gen = wrapper_factory(conn) + # Advance to the yield point (run "before" code) + try: + next(gen) + except StopIteration: + # Generator didn't yield — just run fn unchanged + return fn(conn) + + # Execute the actual write + try: + result = fn(conn) + except Exception: + # Throw exception into generator so it can handle it + try: + gen.throw(*sys.exc_info()) + except StopIteration: + pass + # Re-raise the original exception + raise + else: + # Send the result back through the yield + try: + gen.send(result) + except StopIteration: + pass + return result + + return wrapped + + class WriteTask: __slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction") diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 3f6a1425..b993fb61 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -220,3 +220,25 @@ def top_query(datasette, request, database, sql): @hookspec def top_canned_query(datasette, request, database, query_name): """HTML to include at the top of the canned query page""" + + +@hookspec +def write_wrapper(datasette, database, request, transaction): + """Called when a write function is about to execute. + + Return a generator function that accepts a ``conn`` argument. + The generator should ``yield`` exactly once: code before the + ``yield`` runs before the write, code after the ``yield`` runs + after the write completes. The result of the write is sent + back through the ``yield``, so you can capture it with + ``result = yield``. + + If the write raises an exception, it is thrown into the generator + so you can handle it with a try/except around the ``yield``. + + ``request`` may be ``None`` for writes not originating from an + HTTP request. ``transaction`` is ``True`` if the write will + be wrapped in a transaction. + + Return ``None`` to skip wrapping. + """ diff --git a/datasette/views/database.py b/datasette/views/database.py index 51c752a0..e5f2cf16 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -466,7 +466,9 @@ class QueryView(View): ok = None redirect_url = None try: - cursor = await db.execute_write(canned_query["sql"], params_for_query) + cursor = await db.execute_write( + canned_query["sql"], params_for_query, request=request + ) # success message can come from on_success_message or on_success_message_sql message = None message_type = datasette.INFO @@ -1119,7 +1121,7 @@ class TableCreateView(BaseView): return table.schema try: - schema = await db.execute_write_fn(create_table) + schema = await db.execute_write_fn(create_table, request=request) except Exception as e: return _error([str(e)]) diff --git a/datasette/views/row.py b/datasette/views/row.py index 718ee00c..ff0a3594 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -245,7 +245,7 @@ class RowDeleteView(BaseView): sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values) try: - await resolved.db.execute_write_fn(delete_row) + await resolved.db.execute_write_fn(delete_row, request=request) except Exception as e: return _error([str(e)], 500) @@ -305,7 +305,7 @@ class RowUpdateView(BaseView): ) try: - await resolved.db.execute_write_fn(update_row) + await resolved.db.execute_write_fn(update_row, request=request) except Exception as e: return _error([str(e)], 400) diff --git a/datasette/views/table.py b/datasette/views/table.py index b07b62ae..d4dbc194 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -550,7 +550,7 @@ class TableInsertView(BaseView): method_all(rows, **kwargs) try: - rows = await db.execute_write_fn(insert_or_upsert_rows) + rows = await db.execute_write_fn(insert_or_upsert_rows, request=request) except Exception as e: return _error([str(e)]) result = {"ok": True} @@ -670,7 +670,7 @@ class TableDropView(BaseView): def drop_table(conn): sqlite_utils.Database(conn)[table_name].drop() - await db.execute_write_fn(drop_table) + await db.execute_write_fn(drop_table, request=request) await self.ds.track_event( DropTableEvent( actor=request.actor, database=database_name, table=table_name diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index ad4a70f8..468b0ade 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -61,6 +61,92 @@ arguments and can be called like this:: Examples: `datasette-jellyfish `__, `datasette-jq `__, `datasette-haversine `__, `datasette-rure `__ +.. _plugin_hook_write_wrapper: + +write_wrapper(datasette, database, request, transaction) +-------------------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``database`` - string + The name of the database being written to. + +``request`` - :ref:`internals_request` or ``None`` + The HTTP request that triggered this write, if available. This will be ``None`` for writes that do not originate from an HTTP request (e.g. writes triggered by plugins during startup). + +``transaction`` - bool + ``True`` if the write will be wrapped in a database transaction. + +Return a generator function that accepts a ``conn`` argument (a SQLite connection object). The generator should ``yield`` exactly once. Code before the ``yield`` runs before the write function executes; code after the ``yield`` runs after it completes. + +The result of the write function is sent back through the ``yield``, so you can capture it with ``result = yield``. + +If the write function raises an exception, it is thrown into the generator so you can handle it with a ``try`` / ``except`` around the ``yield``. + +Return ``None`` to skip wrapping for this particular write. + +This example logs every write operation: + +.. code-block:: python + + from datasette import hookimpl + + + @hookimpl + def write_wrapper(datasette, database, request): + def wrapper(conn): + print(f"Before write to {database}") + result = yield + print(f"After write to {database}") + + return wrapper + +This more advanced example uses the SQLite authorizer callback to block writes to a specific table for non-admin users: + +.. code-block:: python + + import sqlite3 + from datasette import hookimpl + + WRITE_ACTIONS = ( + sqlite3.SQLITE_INSERT, + sqlite3.SQLITE_UPDATE, + sqlite3.SQLITE_DELETE, + ) + + + @hookimpl + def write_wrapper(datasette, database, request): + actor = None + if request: + actor = request.actor + if actor and actor.get("id") == "admin": + return None + + def wrapper(conn): + def authorizer( + action, arg1, arg2, db_name, trigger + ): + if ( + action in WRITE_ACTIONS + and arg1 == "protected_table" + ): + return sqlite3.SQLITE_DENY + return sqlite3.SQLITE_OK + + conn.set_authorizer(authorizer) + try: + yield + finally: + conn.set_authorizer(None) + + return wrapper + +The ``conn`` object passed to the generator is the same connection that the write function will use. Because the generator and the write function execute together in a single call on the write thread, any state you set on the connection (authorizers, pragmas, temporary tables) is visible to the write and can be cleaned up afterwards. + +When multiple plugins implement ``write_wrapper``, they are nested following pluggy's default calling convention. + .. _plugin_hook_prepare_jinja2_environment: prepare_jinja2_environment(env, datasette) @@ -2249,3 +2335,4 @@ The plugin can then call ``datasette.track_event(...)`` to send a ``ban-user`` e await datasette.track_event( BanUserEvent(user={"id": 1, "username": "cleverbot"}) ) + diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 6c23b3ef..7c2180e8 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1524,6 +1524,36 @@ async def test_hook_register_events(): assert any(k.__name__ == "OneEvent" for k in datasette.event_classes) +@pytest.mark.asyncio +async def test_hook_write_wrapper(): + datasette = Datasette(memory=True) + log = [] + + class WrapWritePlugin: + __name__ = "WrapWritePlugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + if database != "_memory": + return None + + def wrapper(conn): + log.append("before") + yield + log.append("after") + + return wrapper + + pm.register(WrapWritePlugin(), name="WrapWritePluginTest") + try: + db = datasette.get_database("_memory") + await db.execute_write("create table t (id integer primary key)") + assert log == ["before", "after"] + finally: + pm.unregister(name="WrapWritePluginTest") + + @pytest.mark.asyncio async def test_hook_register_actions_view_collection(): datasette = Datasette(memory=True, plugins_dir=PLUGINS_DIR) diff --git a/tests/test_write_wrapper.py b/tests/test_write_wrapper.py new file mode 100644 index 00000000..e05a2a9f --- /dev/null +++ b/tests/test_write_wrapper.py @@ -0,0 +1,387 @@ +""" +Tests for the write_wrapper plugin hook. +""" + +from datasette.app import Datasette +from datasette.hookspecs import hookimpl +from datasette.plugins import pm +import pytest +import time + + +@pytest.fixture +def datasette(tmp_path): + db_path = str(tmp_path / "test.db") + ds = Datasette([db_path]) + return ds + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "use_execute_write", + (False, True), + ids=["execute_write_fn", "execute_write"], +) +async def test_write_wrapper_before_and_after(datasette, use_execute_write): + """Test that code before and after yield both execute.""" + log = [] + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + def wrapper(conn): + log.append("before") + yield + log.append("after") + + return wrapper + + pm.register(Plugin(), name="test_before_after") + try: + db = datasette.get_database("test") + if use_execute_write: + await db.execute_write( + "create table if not exists t (id integer primary key)" + ) + else: + await db.execute_write_fn( + lambda conn: conn.execute( + "create table if not exists t (id integer primary key)" + ) + ) + assert log == ["before", "after"] + finally: + pm.unregister(name="test_before_after") + + +@pytest.mark.asyncio +async def test_write_wrapper_receives_result_via_yield(datasette): + """Test that the result of fn(conn) is sent back through yield.""" + captured = {} + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + def wrapper(conn): + result = yield + captured["result"] = result + + return wrapper + + pm.register(Plugin(), name="test_result") + try: + db = datasette.get_database("test") + await db.execute_write_fn( + lambda conn: conn.execute( + "create table if not exists t2 (id integer primary key)" + ) + ) + assert "result" in captured + # Should be a sqlite3 Cursor + assert captured["result"] is not None + finally: + pm.unregister(name="test_result") + + +@pytest.mark.asyncio +async def test_write_wrapper_exception_thrown_into_generator(datasette): + """Test that exceptions from fn(conn) are thrown into the generator.""" + caught = {} + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + def wrapper(conn): + try: + yield + except Exception as e: + caught["error"] = e + + return wrapper + + pm.register(Plugin(), name="test_exception") + try: + db = datasette.get_database("test") + with pytest.raises(Exception, match="deliberate"): + await db.execute_write_fn( + lambda conn: (_ for _ in ()).throw(Exception("deliberate")) + ) + assert "error" in caught + assert str(caught["error"]) == "deliberate" + finally: + pm.unregister(name="test_exception") + + +@pytest.mark.asyncio +async def test_write_wrapper_conn_is_usable(datasette): + """Test that the conn passed to the wrapper can execute SQL.""" + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + def wrapper(conn): + conn.execute("create table if not exists hook_log (msg text)") + conn.execute("insert into hook_log values ('before')") + yield + conn.execute("insert into hook_log values ('after')") + + return wrapper + + pm.register(Plugin(), name="test_conn") + try: + db = datasette.get_database("test") + await db.execute_write_fn( + lambda conn: conn.execute( + "create table if not exists t3 (id integer primary key)" + ) + ) + result = await db.execute("select msg from hook_log order by rowid") + messages = [row[0] for row in result.rows] + assert messages == ["before", "after"] + finally: + pm.unregister(name="test_conn") + + +@pytest.mark.asyncio +async def test_write_wrapper_multiple_plugins_nest(datasette): + """Test that multiple write_wrapper plugins nest correctly.""" + log = [] + + class PluginA: + __name__ = "PluginA" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + def wrapper(conn): + log.append("A-before") + yield + log.append("A-after") + + return wrapper + + class PluginB: + __name__ = "PluginB" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + def wrapper(conn): + log.append("B-before") + yield + log.append("B-after") + + return wrapper + + pm.register(PluginA(), name="PluginA") + pm.register(PluginB(), name="PluginB") + try: + db = datasette.get_database("test") + await db.execute_write_fn( + lambda conn: conn.execute( + "create table if not exists t4 (id integer primary key)" + ) + ) + assert set(log) == {"A-before", "A-after", "B-before", "B-after"} + # Verify proper nesting: each plugin's before/after should be + # symmetric around the write + a_before = log.index("A-before") + a_after = log.index("A-after") + b_before = log.index("B-before") + b_after = log.index("B-after") + if a_before < b_before: + assert a_after > b_after, "A is outer so A-after should come after B-after" + else: + assert b_after > a_after, "B is outer so B-after should come after A-after" + finally: + pm.unregister(name="PluginA") + pm.unregister(name="PluginB") + + +@pytest.mark.asyncio +async def test_write_wrapper_return_none_skips(datasette): + """Test that returning None from write_wrapper means no wrapping.""" + log = [] + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + log.append("hook-called") + return None + + pm.register(Plugin(), name="test_skip") + try: + db = datasette.get_database("test") + await db.execute_write_fn( + lambda conn: conn.execute( + "create table if not exists t5 (id integer primary key)" + ) + ) + assert log == ["hook-called"] + finally: + pm.unregister(name="test_skip") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "request_value,transaction_value,expected_request,expected_transaction", + ( + ("fake-request", True, "fake-request", True), + (None, True, None, True), + (None, False, None, False), + ), + ids=["with-request", "request-none-by-default", "transaction-false"], +) +async def test_write_wrapper_hook_parameters( + datasette, + request_value, + transaction_value, + expected_request, + expected_transaction, +): + """Test that request and transaction parameters are passed through.""" + captured = {} + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + captured["request"] = request + captured["database"] = database + captured["transaction"] = transaction + + pm.register(Plugin(), name="test_params") + try: + db = datasette.get_database("test") + kwargs = {"transaction": transaction_value} + if request_value is not None: + kwargs["request"] = request_value + await db.execute_write_fn( + lambda conn: conn.execute( + "create table if not exists t6 (id integer primary key)" + ), + **kwargs, + ) + assert captured["request"] == expected_request + assert captured["database"] == "test" + assert captured["transaction"] == expected_transaction + finally: + pm.unregister(name="test_params") + + +@pytest.mark.asyncio +async def test_write_wrapper_via_api(tmp_path): + """Test that write_wrapper fires for API write operations.""" + log = [] + + db_path = str(tmp_path / "test.db") + ds = Datasette([db_path], pdb=False) + ds.root_enabled = True + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + if database != "test": + return None + + def wrapper(conn): + log.append("before") + yield + log.append("after") + + return wrapper + + pm.register(Plugin(), name="test_api") + try: + db = ds.get_database("test") + await db.execute_write( + "create table if not exists api_test (id integer primary key, name text)" + ) + log.clear() + + token = "dstok_{}".format( + ds.sign( + {"a": "root", "token": "dstok", "t": int(time.time())}, + namespace="token", + ) + ) + response = await ds.client.post( + "/test/api_test/-/insert", + json={"row": {"name": "test"}, "return": True}, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 201, response.json() + assert log == ["before", "after"] + finally: + pm.unregister(name="test_api") + + +@pytest.mark.asyncio +async def test_write_wrapper_change_group_pattern(datasette): + """Test the motivating use case: activating a change group around a write.""" + db = datasette.get_database("test") + + await db.execute_write( + "create table if not exists groups (id integer primary key, current integer)" + ) + await db.execute_write( + "create table if not exists data (id integer primary key, value text)" + ) + await db.execute_write("insert into groups (id, current) values (1, null)") + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + if request and getattr(request, "group_id", None): + group_id = request.group_id + + def wrapper(conn): + conn.execute( + "update groups set current = 1 where id = ?", [group_id] + ) + yield + conn.execute("update groups set current = null where current = 1") + + return wrapper + + pm.register(Plugin(), name="test_change_group") + try: + + class FakeRequest: + group_id = 1 + + await db.execute_write_fn( + lambda conn: conn.execute("insert into data (value) values ('test')"), + request=FakeRequest(), + ) + + result = await db.execute("select current from groups where id = 1") + assert result.rows[0][0] is None + finally: + pm.unregister(name="test_change_group") From 8a315f3d7df8c668fdca216bbb55fe7ef44626dd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 9 Feb 2026 13:27:23 -0800 Subject: [PATCH 013/203] Added a test to exercise the write_wrapper example This example in the docs is now dulicated in a test: https://github.com/simonw/datasette/blob/80b7f987cad59113896f28a29828ffe856218216/docs/plugin_hooks.rst#write-wrapper-datasette-database-request-transaction Refs #2637 --- tests/test_write_wrapper.py | 90 +++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/test_write_wrapper.py b/tests/test_write_wrapper.py index e05a2a9f..38e5c94e 100644 --- a/tests/test_write_wrapper.py +++ b/tests/test_write_wrapper.py @@ -6,6 +6,7 @@ from datasette.app import Datasette from datasette.hookspecs import hookimpl from datasette.plugins import pm import pytest +import sqlite3 import time @@ -385,3 +386,92 @@ async def test_write_wrapper_change_group_pattern(datasette): assert result.rows[0][0] is None finally: pm.unregister(name="test_change_group") + + +WRITE_ACTIONS = ( + sqlite3.SQLITE_INSERT, + sqlite3.SQLITE_UPDATE, + sqlite3.SQLITE_DELETE, +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "actor,table,should_deny", + ( + (None, "protected_table", True), + ({"id": "regular"}, "protected_table", True), + ({"id": "admin"}, "protected_table", False), + (None, "other_table", False), + ({"id": "regular"}, "other_table", False), + ), + ids=[ + "no-actor-protected", + "regular-user-protected", + "admin-protected", + "no-actor-other", + "regular-user-other", + ], +) +async def test_write_wrapper_set_authorizer(datasette, actor, table, should_deny): + """Test the docs example that uses set_authorizer to block writes to a protected table.""" + db = datasette.get_database("test") + await db.execute_write( + "create table if not exists protected_table (id integer primary key, value text)" + ) + await db.execute_write( + "create table if not exists other_table (id integer primary key, value text)" + ) + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(datasette, database, request, transaction): + actor = None + if request: + actor = request.actor + if actor and actor.get("id") == "admin": + return None + + def wrapper(conn): + def authorizer(action, arg1, arg2, db_name, trigger): + if action in WRITE_ACTIONS and arg1 == "protected_table": + return sqlite3.SQLITE_DENY + return sqlite3.SQLITE_OK + + conn.set_authorizer(authorizer) + try: + yield + finally: + conn.set_authorizer(None) + + return wrapper + + class FakeRequest: + def __init__(self, actor): + self.actor = actor + + pm.register(Plugin(), name="test_set_authorizer") + try: + request = FakeRequest(actor) + if should_deny: + with pytest.raises(Exception): + await db.execute_write_fn( + lambda conn: conn.execute( + f"insert into {table} (value) values ('test')" + ), + request=request, + ) + else: + await db.execute_write_fn( + lambda conn: conn.execute( + f"insert into {table} (value) values ('test')" + ), + request=request, + ) + result = await db.execute(f"select value from {table} order by rowid desc limit 1") + assert result.rows[0][0] == "test" + finally: + pm.unregister(name="test_set_authorizer") From 170f9de774fd3d7487a40c9f67dc12a2c626e96e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 18:21:25 +0000 Subject: [PATCH 014/203] Add pks parameter to render_cell() plugin hook The render_cell() hook now receives a pks parameter containing the list of primary key column names for the table being rendered. This avoids plugins needing to make redundant async calls to look up primary keys. For tables without an explicit primary key, pks is ["rowid"]. For custom SQL queries and views, pks is an empty list []. https://claude.ai/code/session_01HFYfevAziq4fSYTNRD9ZCh --- datasette/hookspecs.py | 2 +- datasette/views/database.py | 1 + datasette/views/row.py | 1 + datasette/views/table.py | 3 +++ docs/plugin_hooks.rst | 9 +++++--- tests/fixtures.py | 2 ++ tests/plugins/my_plugin.py | 3 ++- tests/test_plugins.py | 46 +++++++++++++++++++++++++++++++++++++ 8 files changed, 62 insertions(+), 5 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index b993fb61..89be6a65 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -55,7 +55,7 @@ def publish_subcommand(publish): @hookspec -def render_cell(row, value, column, table, database, datasette, request): +def render_cell(row, value, column, table, pks, database, datasette, request): """Customize rendering of HTML table cell values""" diff --git a/datasette/views/database.py b/datasette/views/database.py index e5f2cf16..a42ac758 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1205,6 +1205,7 @@ async def display_rows(datasette, database, request, rows, columns): value=value, column=column, table=None, + pks=[], database=database, datasette=datasette, request=request, diff --git a/datasette/views/row.py b/datasette/views/row.py index ff0a3594..9c59cd3b 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -130,6 +130,7 @@ class RowView(DataView): value=value, column=column, table=table, + pks=resolved.pks, database=database, datasette=self.ds, request=request, diff --git a/datasette/views/table.py b/datasette/views/table.py index d4dbc194..594e925e 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -235,6 +235,7 @@ async def display_columns_and_rows( value=value, column=column, table=table_name, + pks=pks_for_display, database=database_name, datasette=datasette, request=request, @@ -1494,6 +1495,7 @@ async def table_view_data( async def extra_render_cell(): "Rendered HTML for each cell using the render_cell plugin hook" + pks_for_display = pks if pks else (["rowid"] if not is_view else []) columns = [col[0] for col in results.description] rendered_rows = [] for row in rows: @@ -1506,6 +1508,7 @@ async def table_view_data( value=value, column=column, table=table_name, + pks=pks_for_display, database=database_name, datasette=datasette, request=request, diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 468b0ade..068469a8 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -9,7 +9,7 @@ Each plugin can implement one or more hooks using the ``@hookimpl`` decorator ag When you implement a plugin hook you can accept any or all of the parameters that are documented as being passed to that hook. -For example, you can implement the ``render_cell`` plugin hook like this even though the full documented hook signature is ``render_cell(row, value, column, table, database, datasette)``: +For example, you can implement the ``render_cell`` plugin hook like this even though the full documented hook signature is ``render_cell(row, value, column, table, pks, database, datasette, request)``: .. code-block:: python @@ -474,8 +474,8 @@ Examples: `datasette-publish-fly Date: Tue, 17 Feb 2026 20:09:04 +0000 Subject: [PATCH 015/203] Fix test assertions broken by new fixture rows in 170f9de The render_cell pks parameter commit added rows to compound_primary_key (2->3 rows) and no_primary_key (201->202 rows) tables but did not update existing tests that had hardcoded row count expectations. https://claude.ai/code/session_01XfPSZfK57bzRRiEa7Kz5n1 --- tests/test_api.py | 4 ++-- tests/test_table_api.py | 17 +++++++++-------- tests/test_table_html.py | 6 ++++++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index e3951df9..95958a72 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -182,7 +182,7 @@ async def test_database_page(ds_client): # -- compound primary keys compound_pk = tables_by_name["compound_primary_key"] assert compound_pk["primary_keys"] == ["pk1", "pk2"] - assert compound_pk["count"] == 2 + assert compound_pk["count"] == 3 compound_three = tables_by_name["compound_three_primary_keys"] assert compound_three["primary_keys"] == ["pk1", "pk2", "pk3"] @@ -196,7 +196,7 @@ async def test_database_page(ds_client): # -- no_primary_key: hidden table with generated data no_pk = tables_by_name["no_primary_key"] assert no_pk["hidden"] is True - assert no_pk["count"] == 201 + assert no_pk["count"] == 202 assert no_pk["primary_keys"] == [] # -- roadside attractions relationship chain diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 49df3ad5..943a1549 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -136,6 +136,7 @@ async def test_table_shape_object_compound_primary_key(ds_client): assert response.json() == { "a,b": {"pk1": "a", "pk2": "b", "content": "c"}, "a~2Fb,~2Ec-d": {"pk1": "a/b", "pk2": ".c-d", "content": "c"}, + "d,e": {"pk1": "d", "pk2": "e", "content": "RENDER_CELL_DEMO"}, } @@ -169,11 +170,11 @@ async def test_table_with_reserved_word_name(ds_client): @pytest.mark.parametrize( "path,expected_rows,expected_pages", [ - ("/fixtures/no_primary_key.json", 201, 5), - ("/fixtures/paginated_view.json", 201, 9), - ("/fixtures/no_primary_key.json?_size=25", 201, 9), - ("/fixtures/paginated_view.json?_size=50", 201, 5), - ("/fixtures/paginated_view.json?_size=max", 201, 3), + ("/fixtures/no_primary_key.json", 202, 5), + ("/fixtures/paginated_view.json", 202, 9), + ("/fixtures/no_primary_key.json?_size=25", 202, 9), + ("/fixtures/paginated_view.json?_size=50", 202, 5), + ("/fixtures/paginated_view.json?_size=max", 202, 3), ("/fixtures/123_starts_with_digits.json", 0, 1), # Ensure faceting doesn't break pagination: ("/fixtures/compound_three_primary_keys.json?_facet=pk1", 1001, 21), @@ -232,7 +233,7 @@ async def test_page_size_zero(ds_client): ) assert response.status_code == 200 assert [] == response.json()["rows"] - assert 201 == response.json()["count"] + assert 202 == response.json()["count"] assert None is response.json()["next"] assert None is response.json()["next_url"] @@ -722,11 +723,11 @@ def test_page_size_matching_max_returned_rows( while path: response = app_client_returned_rows_matches_page_size.get(path) fetched.extend(response.json["rows"]) - assert len(response.json["rows"]) in (1, 50) + assert len(response.json["rows"]) in (2, 50) path = response.json["next_url"] if path: path = path.replace("http://localhost", "") - assert len(fetched) == 201 + assert len(fetched) == 202 @pytest.mark.asyncio diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 90be591a..00cf9e19 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -597,6 +597,12 @@ async def test_table_html_compound_primary_key(ds_client): '.c-d', 'c', ], + [ + 'd,e', + 'd', + 'e', + '{"row": {"pk1": "d", "pk2": "e", "content": "RENDER_CELL_DEMO"}, "column": "content", "table": "compound_primary_key", "database": "fixtures", "pks": ["pk1", "pk2"], "config": {"depth": "database"}}', + ], ] assert [ [str(td) for td in tr.select("td")] for tr in table.select("tbody tr") From 5c3137d14858c0750c93bb61ef593d807cadba43 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 17 Feb 2026 13:30:24 -0800 Subject: [PATCH 016/203] Black formatting --- datasette/app.py | 10 ++----- datasette/cli.py | 8 ++--- datasette/database.py | 36 +++++------------------ datasette/default_permissions/defaults.py | 1 - datasette/facets.py | 12 ++------ datasette/inspect.py | 17 +++-------- datasette/permissions.py | 1 - datasette/utils/__init__.py | 4 +-- datasette/utils/actions_sql.py | 18 ++++-------- datasette/utils/internal_db.py | 14 +++------ datasette/utils/permissions.py | 7 ++--- datasette/views/base.py | 8 ++--- datasette/views/database.py | 8 ++--- datasette/views/index.py | 1 - datasette/views/special.py | 1 - tests/conftest.py | 1 - tests/fixtures.py | 20 +++---------- tests/plugins/my_plugin.py | 8 ++--- tests/test_cli.py | 8 ++--- tests/test_cli_serve_get.py | 4 +-- tests/test_config_dir.py | 6 ++-- tests/test_csv.py | 22 ++++---------- tests/test_html.py | 7 ++--- tests/test_internals_database.py | 12 +++----- tests/test_plugins.py | 10 ++----- tests/test_publish_cloudrun.py | 14 +++------ tests/test_routes.py | 6 ++-- tests/test_table_api.py | 8 ++--- tests/test_utils.py | 22 ++++---------- tests/test_utils_permissions.py | 6 ++-- tests/test_write_wrapper.py | 4 ++- 31 files changed, 82 insertions(+), 222 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 75f6071e..6efaa430 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -633,9 +633,7 @@ class Datasette: """ INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version) VALUES {} - """.format( - placeholders - ), + """.format(placeholders), values, ) await populate_schema_tables(internal_db, db) @@ -813,14 +811,12 @@ class Datasette: return orig async def get_instance_metadata(self): - rows = await self.get_internal_database().execute( - """ + rows = await self.get_internal_database().execute(""" SELECT key, value FROM metadata_instance - """ - ) + """) return dict(rows) async def get_database_metadata(self, database_name: str): diff --git a/datasette/cli.py b/datasette/cli.py index 1d0cb022..121911ab 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -109,15 +109,11 @@ def sqlite_extensions(fn): return fn(*args, **kwargs) except AttributeError as e: if "enable_load_extension" in str(e): - raise click.ClickException( - textwrap.dedent( - """ + raise click.ClickException(textwrap.dedent(""" Your Python installation does not have the ability to load SQLite extensions. More information: https://datasette.io/help/extensions - """ - ).strip() - ) + """).strip()) raise return wrapped diff --git a/datasette/database.py b/datasette/database.py index 1e6f9032..fcf69c7f 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -532,10 +532,7 @@ class Database: ] if sqlite_version()[1] >= 37: - hidden_tables += [ - x[0] - for x in await self.execute( - """ + hidden_tables += [x[0] for x in await self.execute(""" with shadow_tables as ( select name from pragma_table_list @@ -554,14 +551,9 @@ class Database: select name from core_tables ) select name from combined order by 1 - """ - ) - ] + """)] else: - hidden_tables += [ - x[0] - for x in await self.execute( - """ + hidden_tables += [x[0] for x in await self.execute(""" WITH base AS ( SELECT name FROM sqlite_master @@ -607,22 +599,15 @@ class Database: SELECT name FROM fts3_shadow_tables ) SELECT name FROM final ORDER BY 1 - """ - ) - ] + """)] # Also hide any FTS tables that have a content= argument - hidden_tables += [ - x[0] - for x in await self.execute( - """ + hidden_tables += [x[0] for x in await self.execute(""" SELECT name FROM sqlite_master WHERE sql LIKE '%VIRTUAL TABLE%' AND sql LIKE '%USING FTS%' AND sql LIKE '%content=%' - """ - ) - ] + """)] has_spatialite = await self.execute_fn(detect_spatialite) if has_spatialite: @@ -641,16 +626,11 @@ class Database: "KNN", "KNN2", ] + [ - r[0] - for r in ( - await self.execute( - """ + r[0] for r in (await self.execute(""" select name from sqlite_master where name like "idx_%" and type = "table" - """ - ) - ).rows + """)).rows ] return hidden_tables diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index f5a6a270..4c74219d 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from datasette import hookimpl from datasette.permissions import PermissionSQL - # Actions that are allowed by default (unless --default-deny is used) DEFAULT_ALLOW_ACTIONS = frozenset( { diff --git a/datasette/facets.py b/datasette/facets.py index dd149424..bc4b6904 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -233,9 +233,7 @@ class ColumnFacet(Facet): ) where {col} is not null group by {col} order by count desc, value limit {limit} - """.format( - col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1 - ) + """.format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1) try: facet_rows_results = await self.ds.execute( self.database, @@ -482,9 +480,7 @@ class DateFacet(Facet): select date({column}) from ( select * from ({sql}) limit 100 ) where {column} glob "????-??-*" - """.format( - column=escape_sqlite(column), sql=self.sql - ) + """.format(column=escape_sqlite(column), sql=self.sql) try: results = await self.ds.execute( self.database, @@ -530,9 +526,7 @@ class DateFacet(Facet): ) where date({col}) is not null group by date({col}) order by count desc, value limit {limit} - """.format( - col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1 - ) + """.format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1) try: facet_rows_results = await self.ds.execute( self.database, diff --git a/datasette/inspect.py b/datasette/inspect.py index ede142d0..5e681e03 100644 --- a/datasette/inspect.py +++ b/datasette/inspect.py @@ -10,7 +10,6 @@ from .utils import ( sqlite3, ) - HASH_BLOCK_SIZE = 1024 * 1024 @@ -70,16 +69,11 @@ def inspect_tables(conn, database_metadata): tables[table]["foreign_keys"] = info # Mark tables 'hidden' if they relate to FTS virtual tables - hidden_tables = [ - r["name"] - for r in conn.execute( - """ + hidden_tables = [r["name"] for r in conn.execute(""" select name from sqlite_master where rootpage = 0 and sql like '%VIRTUAL TABLE%USING FTS%' - """ - ) - ] + """)] if detect_spatialite(conn): # Also hide Spatialite internal tables @@ -94,14 +88,11 @@ def inspect_tables(conn, database_metadata): "views_geometry_columns", "virts_geometry_columns", ] + [ - r["name"] - for r in conn.execute( - """ + r["name"] for r in conn.execute(""" select name from sqlite_master where name like "idx_%" and type = "table" - """ - ) + """) ] for t in tables.keys(): diff --git a/datasette/permissions.py b/datasette/permissions.py index c48293ac..b5e72b8e 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from typing import Any, NamedTuple import contextvars - # Context variable to track when permission checks should be skipped _skip_permission_checks = contextvars.ContextVar( "skip_permission_checks", default=False diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index d0d216eb..c6973d06 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -677,9 +677,7 @@ def detect_fts_sql(table): and sql like '%VIRTUAL TABLE%USING FTS%' ) ) - """.format( - table=table.replace("'", "''") - ) + """.format(table=table.replace("'", "''")) def detect_json1(conn=None): diff --git a/datasette/utils/actions_sql.py b/datasette/utils/actions_sql.py index 9c2add0e..14383253 100644 --- a/datasette/utils/actions_sql.py +++ b/datasette/utils/actions_sql.py @@ -180,13 +180,11 @@ async def _build_single_action_sql( # Skip plugins that only provide restriction_sql (no permission rules) if permission_sql.sql is None: continue - rule_sqls.append( - f""" + rule_sqls.append(f""" SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM ( {permission_sql.sql} ) - """.strip() - ) + """.strip()) # If no rules, return empty result (deny all) if not rule_sqls: @@ -405,14 +403,12 @@ async def _build_single_action_sql( # Add restriction filter if there are restrictions if restriction_sqls: - query_parts.append( - """ + query_parts.append(""" AND EXISTS ( SELECT 1 FROM restriction_list r WHERE (r.parent = decisions.parent OR r.parent IS NULL) AND (r.child = decisions.child OR r.child IS NULL) - )""" - ) + )""") # Add parent filter if specified if parent is not None: @@ -479,13 +475,11 @@ async def build_permission_rules_sql( if permission_sql.sql is None: continue - union_parts.append( - f""" + union_parts.append(f""" SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM ( {permission_sql.sql} ) - """.strip() - ) + """.strip()) rules_union = " UNION ALL ".join(union_parts) return rules_union, all_params, restriction_sqls diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index a3afbab2..e4ebddde 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -3,8 +3,7 @@ from datasette.utils import table_column_details async def init_internal_db(db): - create_tables_sql = textwrap.dedent( - """ + create_tables_sql = textwrap.dedent(""" CREATE TABLE IF NOT EXISTS catalog_databases ( database_name TEXT PRIMARY KEY, path TEXT, @@ -68,16 +67,13 @@ async def init_internal_db(db): FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name), FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name) ); - """ - ).strip() + """).strip() await db.execute_write_script(create_tables_sql) await initialize_metadata_tables(db) async def initialize_metadata_tables(db): - await db.execute_write_script( - textwrap.dedent( - """ + await db.execute_write_script(textwrap.dedent(""" CREATE TABLE IF NOT EXISTS metadata_instance ( key text, value text, @@ -107,9 +103,7 @@ async def initialize_metadata_tables(db): value text, unique(database_name, resource_name, column_name, key) ); - """ - ) - ) + """)) async def populate_schema_tables(internal_db, db): diff --git a/datasette/utils/permissions.py b/datasette/utils/permissions.py index 6c30a12a..fd1e41a1 100644 --- a/datasette/utils/permissions.py +++ b/datasette/utils/permissions.py @@ -9,7 +9,6 @@ from datasette.permissions import PermissionSQL from datasette.plugins import pm from datasette.utils import await_me_maybe - # Sentinel object to indicate permission checks should be skipped SKIP_PERMISSION_CHECKS = object() @@ -116,13 +115,11 @@ def build_rules_union( if p.sql is None: continue - parts.append( - f""" + parts.append(f""" SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM ( {p.sql} ) - """.strip() - ) + """.strip()) if not parts: # Empty UNION that returns no rows diff --git a/datasette/views/base.py b/datasette/views/base.py index bdc9f742..e4c1c738 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -241,8 +241,7 @@ class DataView(BaseView): data, extra_template_data, templates = response_or_template_contexts except QueryInterrupted as ex: raise DatasetteError( - textwrap.dedent( - """ + textwrap.dedent("""

    SQL query took too long. The time limit is controlled by the sql_time_limit_ms configuration option.

    @@ -251,10 +250,7 @@ class DataView(BaseView): let ta = document.querySelector("textarea"); ta.style.height = ta.scrollHeight + "px"; - """.format( - escape(ex.sql) - ) - ).strip(), + """.format(escape(ex.sql))).strip(), title="SQL Interrupted", status=400, message_is_html=True, diff --git a/datasette/views/database.py b/datasette/views/database.py index a42ac758..93ad8eda 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -615,8 +615,7 @@ class QueryView(View): rows = results.rows except QueryInterrupted as ex: raise DatasetteError( - textwrap.dedent( - """ + textwrap.dedent("""

    SQL query took too long. The time limit is controlled by the sql_time_limit_ms configuration option.

    @@ -625,10 +624,7 @@ class QueryView(View): let ta = document.querySelector("textarea"); ta.style.height = ta.scrollHeight + "px"; - """.format( - markupsafe.escape(ex.sql) - ) - ).strip(), + """.format(markupsafe.escape(ex.sql))).strip(), title="SQL Interrupted", status=400, message_is_html=True, diff --git a/datasette/views/index.py b/datasette/views/index.py index a59c687c..6a9462ac 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -12,7 +12,6 @@ from datasette.version import __version__ from .base import BaseView - # Truncate table list on homepage at: TRUNCATE_AT = 5 diff --git a/datasette/views/special.py b/datasette/views/special.py index 57a3024d..640c82eb 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -13,7 +13,6 @@ from .base import BaseView, View import secrets import urllib - logger = logging.getLogger(__name__) diff --git a/tests/conftest.py b/tests/conftest.py index ad7243c1..efa02c0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,6 @@ import time from dataclasses import dataclass from datasette import Event, hookimpl - try: import pysqlite3 as sqlite3 except ImportError: diff --git a/tests/fixtures.py b/tests/fixtures.py index 0c110a94..9f99519a 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -13,7 +13,6 @@ import string import tempfile import textwrap - # This temp file is used by one of the plugin config tests TEMP_PLUGIN_SECRET_FILE = os.path.join(tempfile.gettempdir(), "plugin-secret") @@ -331,16 +330,14 @@ CONFIG = { "sql": "select :_header_user_agent as user_agent, :_now_datetime_utc as datetime", }, "neighborhood_search": { - "sql": textwrap.dedent( - """ + "sql": textwrap.dedent(""" select _neighborhood, facet_cities.name, state from facetable join facet_cities on facetable._city_id = facet_cities.id where _neighborhood like '%' || :text || '%' order by _neighborhood; - """ - ), + """), "title": "Search neighborhoods", "description_html": "Demonstrating simple like search", "fragment": "fragment-goes-here", @@ -710,19 +707,10 @@ CREATE VIEW searchable_view_configured_by_metadata AS for a, b, c, content in generate_compound_rows(1001) ] ) - + "\n".join( - [ - """INSERT INTO sortable VALUES ( + + "\n".join(["""INSERT INTO sortable VALUES ( "{pk1}", "{pk2}", "{content}", {sortable}, {sortable_with_nulls}, {sortable_with_nulls_2}, "{text}"); - """.format( - **row - ).replace( - "None", "null" - ) - for row in generate_sortable_rows(201) - ] - ) + """.format(**row).replace("None", "null") for row in generate_sortable_rows(201)]) ) TABLE_PARAMETERIZED_SQL = [ ("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]), diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index c8794fad..20e7d111 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -261,8 +261,7 @@ def register_routes(): response = Response.redirect("/") datasette.set_actor_cookie(response, {"id": "root"}) return response - return Response.html( - """ + return Response.html("""

    @@ -271,10 +270,7 @@ def register_routes(): style="font-size: 2em; padding: 0.1em 0.5em;">

    - """.format( - request.path, request.scope["csrftoken"]() - ) - ) + """.format(request.path, request.scope["csrftoken"]())) def asgi_scope(scope): return Response.json(scope, default=repr) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6cdfd924..7673c3f3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -115,13 +115,9 @@ def test_plugins_cli(app_client): def test_metadata_yaml(): - yaml_file = io.StringIO( - textwrap.dedent( - """ + yaml_file = io.StringIO(textwrap.dedent(""" title: Hello from YAML - """ - ) - ) + """)) # Annoyingly we have to provide all default arguments here: ds = serve.callback( [], diff --git a/tests/test_cli_serve_get.py b/tests/test_cli_serve_get.py index 5ad01bfa..dc852201 100644 --- a/tests/test_cli_serve_get.py +++ b/tests/test_cli_serve_get.py @@ -16,9 +16,7 @@ def test_serve_with_get(tmp_path_factory): def startup(datasette): with open("{}", "w") as fp: fp.write("hello") - """.format( - str(plugins_dir / "hello.txt") - ), + """.format(str(plugins_dir / "hello.txt")), ), "utf-8", ) diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py index f9a90fbe..ae7fe500 100644 --- a/tests/test_config_dir.py +++ b/tests/test_config_dir.py @@ -51,8 +51,7 @@ def config_dir(tmp_path_factory): for dbname in ("demo.db", "immutable.db", "j.sqlite3", "k.sqlite"): db = sqlite3.connect(str(config_dir / dbname)) - db.executescript( - """ + db.executescript(""" CREATE TABLE cities ( id integer primary key, name text @@ -60,8 +59,7 @@ def config_dir(tmp_path_factory): INSERT INTO cities (id, name) VALUES (1, 'San Francisco') ; - """ - ) + """) # Mark "immutable.db" as immutable (config_dir / "inspect-data.json").write_text( diff --git a/tests/test_csv.py b/tests/test_csv.py index 5589bd97..a2f03776 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -9,16 +9,12 @@ EXPECTED_TABLE_CSV = """id,content 3, 4,RENDER_CELL_DEMO 5,RENDER_CELL_ASYNC -""".replace( - "\n", "\r\n" -) +""".replace("\n", "\r\n") EXPECTED_CUSTOM_CSV = """content hello world -""".replace( - "\n", "\r\n" -) +""".replace("\n", "\r\n") EXPECTED_TABLE_WITH_LABELS_CSV = """ pk,created,planet_int,on_earth,state,_city_id,_city_id_label,_neighborhood,tags,complex_array,distinct_some_null,n @@ -37,17 +33,13 @@ pk,created,planet_int,on_earth,state,_city_id,_city_id_label,_neighborhood,tags, 13,2019-01-17 08:00:00,1,1,MI,3,Detroit,Corktown,[],[],, 14,2019-01-17 08:00:00,1,1,MI,3,Detroit,Mexicantown,[],[],, 15,2019-01-17 08:00:00,2,0,MC,4,Memnonia,Arcadia Planitia,[],[],, -""".lstrip().replace( - "\n", "\r\n" -) +""".lstrip().replace("\n", "\r\n") EXPECTED_TABLE_WITH_NULLABLE_LABELS_CSV = """ pk,foreign_key_with_label,foreign_key_with_label_label,foreign_key_with_blank_label,foreign_key_with_blank_label_label,foreign_key_with_no_label,foreign_key_with_no_label_label,foreign_key_compound_pk1,foreign_key_compound_pk2 1,1,hello,3,,1,1,a,b 2,,,,,,,, -""".lstrip().replace( - "\n", "\r\n" -) +""".lstrip().replace("\n", "\r\n") @pytest.mark.asyncio @@ -108,8 +100,7 @@ async def test_table_csv_with_invalid_labels(): ) await ds.invoke_startup() db = ds.add_memory_database("db_2214") - await db.execute_write_script( - """ + await db.execute_write_script(""" create table t1 (id integer primary key, name text); insert into t1 (id, name) values (1, 'one'); insert into t1 (id, name) values (2, 'two'); @@ -124,8 +115,7 @@ async def test_table_csv_with_invalid_labels(): insert into maintable (id, fk_integer, fk_text) values (1, 1, 'a'); insert into maintable (id, fk_integer, fk_text) values (2, 3, 'b'); -- invalid fk_integer insert into maintable (id, fk_integer, fk_text) values (3, 2, 'c'); -- invalid fk_text - """ - ) + """) response = await ds.client.get("/db_2214/maintable.csv?_labels=1") assert response.status_code == 200 assert response.text == ( diff --git a/tests/test_html.py b/tests/test_html.py index 8fad5764..757f3e6e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -620,14 +620,11 @@ async def test_urlify_custom_queries(ds_client): response = await ds_client.get(path) assert response.status_code == 200 soup = Soup(response.content, "html.parser") - assert ( - """ + assert """ https://twitter.com/simonw -""" - == soup.find("td", {"class": "col-user_url"}).prettify().strip() - ) +""" == soup.find("td", {"class": "col-user_url"}).prettify().strip() @pytest.mark.asyncio diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index 02c67bfc..5e3459cd 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -747,19 +747,15 @@ async def test_replace_database(tmpdir): path1 = str(tmpdir / "data1.db") (tmpdir / "two").mkdir() path2 = str(tmpdir / "two" / "data1.db") - sqlite3.connect(path1).executescript( - """ + sqlite3.connect(path1).executescript(""" create table t (id integer primary key); insert into t (id) values (1); insert into t (id) values (2); - """ - ) - sqlite3.connect(path2).executescript( - """ + """) + sqlite3.connect(path2).executescript(""" create table t (id integer primary key); insert into t (id) values (1); - """ - ) + """) datasette = Datasette([path1]) db = datasette.get_database("data1") count = (await db.execute("select count(*) from t")).first()[0] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 190ef659..754b199c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -233,9 +233,7 @@ async def test_hook_render_cell_pks_compound_pk(ds_client): @pytest.mark.asyncio async def test_hook_render_cell_pks_rowid_table(ds_client): """pks should be ["rowid"] for a table with no explicit primary key""" - response = await ds_client.get( - "/fixtures/no_primary_key?content=RENDER_CELL_DEMO" - ) + response = await ds_client.get("/fixtures/no_primary_key?content=RENDER_CELL_DEMO") soup = Soup(response.text, "html.parser") td = soup.find("td", {"class": "col-content"}) data = json.loads(td.string) @@ -457,14 +455,12 @@ def view_names_client(tmp_path_factory): ): (templates / template).write_text("view_name:{{ view_name }}", "utf-8") (plugins / "extra_vars.py").write_text( - textwrap.dedent( - """ + textwrap.dedent(""" from datasette import hookimpl @hookimpl def extra_template_vars(view_name): return {"view_name": view_name} - """ - ), + """), "utf-8", ) db_path = str(tmpdir / "fixtures.db") diff --git a/tests/test_publish_cloudrun.py b/tests/test_publish_cloudrun.py index f53e5059..6617bc77 100644 --- a/tests/test_publish_cloudrun.py +++ b/tests/test_publish_cloudrun.py @@ -231,16 +231,12 @@ def test_publish_cloudrun_plugin_secrets( with open("test.db", "w") as fp: fp.write("data") with open("metadata.yml", "w") as fp: - fp.write( - textwrap.dedent( - """ + fp.write(textwrap.dedent(""" title: Hello from metadata YAML plugins: datasette-auth-github: foo: bar - """ - ).strip() - ) + """).strip()) result = runner.invoke( cli.cli, [ @@ -333,8 +329,7 @@ def test_publish_cloudrun_apt_get_install( .split("\n====================\n")[0] .strip() ) - expected = textwrap.dedent( - r""" + expected = textwrap.dedent(r""" FROM python:3.11.0-slim-bullseye COPY . /app WORKDIR /app @@ -350,8 +345,7 @@ def test_publish_cloudrun_apt_get_install( ENV PORT 8001 EXPOSE 8001 CMD datasette serve --host 0.0.0.0 -i test.db --cors --inspect-file inspect-data.json --setting force_https_urls on --port $PORT - """ - ).strip() + """).strip() assert expected == dockerfile diff --git a/tests/test_routes.py b/tests/test_routes.py index 9866cc76..24c702fc 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -63,12 +63,10 @@ async def ds_with_route(): ds.remove_database("_memory") db = Database(ds, is_memory=True, memory_name="route-name-db") ds.add_database(db, name="original-name", route="custom-route-name") - await db.execute_write_script( - """ + await db.execute_write_script(""" create table if not exists t (id integer primary key); insert or replace into t (id) values (1); - """ - ) + """) return ds diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 943a1549..51e40ad1 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1243,9 +1243,7 @@ async def test_paginate_using_link_header(ds_client, qs): reason="generated columns were added in SQLite 3.31.0", ) def test_generated_columns_are_visible_in_datasette(): - with make_app_client( - extra_databases={ - "generated.db": """ + with make_app_client(extra_databases={"generated.db": """ CREATE TABLE generated_columns ( body TEXT, id INT GENERATED ALWAYS AS (json_extract(body, '$.number')) STORED, @@ -1253,9 +1251,7 @@ def test_generated_columns_are_visible_in_datasette(): ); INSERT INTO generated_columns (body) VALUES ( '{"number": 1, "string": "This is a string"}' - );""" - } - ) as client: + );"""}) as client: response = client.get("/generated/generated_columns.json?_shape=array") assert response.json == [ { diff --git a/tests/test_utils.py b/tests/test_utils.py index b8d047e9..85ab9e6b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -201,9 +201,7 @@ def test_detect_fts(open_quote, close_quote): CREATE VIEW Test_View AS SELECT * FROM Dumb_Table; CREATE VIRTUAL TABLE {open}Street_Tree_List_fts{close} USING FTS4 ("qAddress", "qCaretaker", "qSpecies", content={open}Street_Tree_List{close}); CREATE VIRTUAL TABLE r USING rtree(a, b, c); - """.format( - open=open_quote, close=close_quote - ) + """.format(open=open_quote, close=close_quote) conn = utils.sqlite3.connect(":memory:") conn.executescript(sql) assert None is utils.detect_fts(conn, "Dumb_Table") @@ -220,9 +218,7 @@ def test_detect_fts_different_table_names(table): "qSpecies" TEXT ); CREATE VIRTUAL TABLE [{table}_fts] USING FTS4 ("qSpecies", content="{table}"); - """.format( - table=table - ) + """.format(table=table) conn = utils.sqlite3.connect(":memory:") conn.executescript(sql) assert "{table}_fts".format(table=table) == utils.detect_fts(conn, table) @@ -347,27 +343,21 @@ def test_compound_keys_after_sql(): ((a > :p0) or (a = :p0 and b > :p1)) - """.strip() == utils.compound_keys_after_sql( - ["a", "b"] - ) + """.strip() == utils.compound_keys_after_sql(["a", "b"]) assert """ ((a > :p0) or (a = :p0 and b > :p1) or (a = :p0 and b = :p1 and c > :p2)) - """.strip() == utils.compound_keys_after_sql( - ["a", "b", "c"] - ) + """.strip() == utils.compound_keys_after_sql(["a", "b", "c"]) def test_table_columns(): conn = sqlite3.connect(":memory:") - conn.executescript( - """ + conn.executescript(""" create table places (id integer primary key, name text, bob integer) - """ - ) + """) assert ["id", "name", "bob"] == utils.table_columns(conn, "places") diff --git a/tests/test_utils_permissions.py b/tests/test_utils_permissions.py index b412de0f..bc3599c2 100644 --- a/tests/test_utils_permissions.py +++ b/tests/test_utils_permissions.py @@ -497,16 +497,14 @@ async def test_actor_actor_id_action_parameters_available(db): def plugin_using_all_parameters() -> Callable[[str], PermissionSQL]: def provider(action: str) -> PermissionSQL: - return PermissionSQL( - """ + return PermissionSQL(""" SELECT NULL AS parent, NULL AS child, 1 AS allow, 'Actor ID: ' || COALESCE(:actor_id, 'null') || ', Actor JSON: ' || COALESCE(:actor, 'null') || ', Action: ' || :action AS reason WHERE :actor_id = 'test_user' AND :action = 'view-table' AND json_extract(:actor, '$.role') = 'admin' - """ - ) + """) return provider diff --git a/tests/test_write_wrapper.py b/tests/test_write_wrapper.py index 38e5c94e..cb320c06 100644 --- a/tests/test_write_wrapper.py +++ b/tests/test_write_wrapper.py @@ -471,7 +471,9 @@ async def test_write_wrapper_set_authorizer(datasette, actor, table, should_deny ), request=request, ) - result = await db.execute(f"select value from {table} order by rowid desc limit 1") + result = await db.execute( + f"select value from {table} order by rowid desc limit 1" + ) assert result.rows[0][0] == "test" finally: pm.unregister(name="test_set_authorizer") From 1c6c6d2e6897c1173ed6e209c8b7133688e75c58 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 17 Feb 2026 13:30:46 -0800 Subject: [PATCH 017/203] Fix test_write_wrapper_set_authorizer: use permissive callback instead of None conn.set_authorizer(None) does not clear the authorizer - SQLite treats None as an invalid callback. The denied state persists on the shared write connection, causing subsequent non-deny test cases to fail. Fixes test added in 8a315f3d. --- tests/test_write_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_write_wrapper.py b/tests/test_write_wrapper.py index cb320c06..55e0461e 100644 --- a/tests/test_write_wrapper.py +++ b/tests/test_write_wrapper.py @@ -445,7 +445,7 @@ async def test_write_wrapper_set_authorizer(datasette, actor, table, should_deny try: yield finally: - conn.set_authorizer(None) + conn.set_authorizer(lambda *args: sqlite3.SQLITE_OK) return wrapper From 7a66456615cad38d9e70267a14ca30dcc4bca701 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 20 Feb 2026 11:19:19 -0800 Subject: [PATCH 018/203] black --version --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1ba3232..a0f5477b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,9 @@ jobs: # And the test that exceeds a localhost HTTPS server tests/test_datasette_https_server.sh - name: Black - run: black --check . + run: | + black --version + black --check . - name: Ruff run: ruff check datasette tests - name: Check if cog needs to be run From 2f0e64df681c7bf65e8ce3065380be36a4ccd266 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 20 Feb 2026 11:24:52 -0800 Subject: [PATCH 019/203] black==26.1.0 I'm getting CI failures for Black, maybe this will help --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d9ef2a73..2ab2ce10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dev = [ "pytest-xdist>=2.2.1", "pytest-asyncio>=1.2.0", "beautifulsoup4>=4.8.1", - "black==25.11.0", + "black==26.1.0", "blacken-docs==1.20.0", "pytest-timeout>=1.4.2", "trustme>=0.7", From 6a2c27b15b300ba1b924ce00a61532943482392e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 20 Feb 2026 11:28:39 -0800 Subject: [PATCH 020/203] blacken-docs --- docs/plugin_hooks.rst | 13 ++++--------- docs/spatialite.rst | 6 ++---- docs/testing_plugins.rst | 8 ++------ 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 068469a8..fa335368 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1074,11 +1074,9 @@ You can also return an async function, which will be awaited on startup. Use thi async def inner(): db = datasette.get_database() if "my_table" not in await db.table_names(): - await db.execute_write( - """ + await db.execute_write(""" create table my_table (mycol text) - """ - ) + """) return inner @@ -1561,7 +1559,6 @@ The resolver will automatically apply the most specific rule. from datasette import hookimpl from datasette.permissions import PermissionSQL - TRUSTED = {"alice", "bob"} @@ -2261,8 +2258,7 @@ This example logs events to a ``datasette_events`` table in a database called `` def startup(datasette): async def inner(): db = datasette.get_database("events") - await db.execute_write( - """ + await db.execute_write(""" create table if not exists datasette_events ( id integer primary key, event_type text, @@ -2270,8 +2266,7 @@ This example logs events to a ``datasette_events`` table in a database called `` actor text, properties text ) - """ - ) + """) return inner diff --git a/docs/spatialite.rst b/docs/spatialite.rst index fbe0d75f..c93c1e00 100644 --- a/docs/spatialite.rst +++ b/docs/spatialite.rst @@ -90,12 +90,10 @@ Here's a recipe for taking a table with existing latitude and longitude columns, "SELECT AddGeometryColumn('museums', 'point_geom', 4326, 'POINT', 2);" ) # Now update that geometry column with the lat/lon points - conn.execute( - """ + conn.execute(""" UPDATE museums SET point_geom = GeomFromText('POINT('||"longitude"||' '||"latitude"||')',4326); - """ - ) + """) # Now add a spatial index to that column conn.execute( 'select CreateSpatialIndex("museums", "point_geom");' diff --git a/docs/testing_plugins.rst b/docs/testing_plugins.rst index fc1aa6f6..b0713e7c 100644 --- a/docs/testing_plugins.rst +++ b/docs/testing_plugins.rst @@ -233,15 +233,11 @@ As an example, here's a very simple plugin which executes an HTTP response and r async def fetch_url(datasette, request): if request.method == "GET": - return Response.html( - """ + return Response.html("""
    -
    """.format( - request.scope["csrftoken"]() - ) - ) + """.format(request.scope["csrftoken"]())) vars = await request.post_vars() url = vars["url"] return Response.text(httpx.get(url).text) From c96dc5ce2656607b9e81743acf600f8fd5f6a795 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 25 Feb 2026 16:32:45 -0800 Subject: [PATCH 021/203] register_token_handler() plugin hook for custom API token backends (#2650) Closes #2649 * Add register_token_handler plugin hook for pluggable token backends Adds a new register_token_handler hook that allows plugins to provide custom token creation and verification backends. This enables plugins like datasette-oauth to issue tokens without depending on specific backend plugins like datasette-auth-tokens. Key changes: - New datasette/tokens.py with TokenHandler base class and SignedTokenHandler (the default signed-token implementation moved here) - New register_token_handler hookspec in hookspecs.py - Datasette.create_token() is now async and delegates to token handlers - New Datasette.verify_token() method tries all handlers in sequence - handler= parameter on create_token() to select a specific backend - TokenHandler exported from datasette package for plugin use - Fixed actor_from_request loop to await all coroutines (avoids warnings) * Add documentation and hook test for register_token_handler Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst (checked by test_plugin_hooks_are_documented) and a test_hook_* function in test_plugins.py (checked by test_plugin_hooks_have_tests). * Register tokens module as separate default plugin Instead of re-exporting hookimpls from default_permissions/__init__.py, register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS entry. Cleaner and avoids confusing import-for-side-effect patterns. * Replace restrict_x params with TokenRestrictions dataclass Consolidates the three separate restrict_all, restrict_database, and restrict_resource parameters into a single TokenRestrictions dataclass. Cleaner API surface for both Datasette.create_token() and TokenHandler.create_token(). Also clarifies docs re: default handler selection via pluggy ordering. * Add builder methods to TokenRestrictions Adds allow_all(), allow_database(), and allow_resource() methods that return self for chaining. Callers no longer need to manipulate nested dicts directly: restrictions = (TokenRestrictions() .allow_all("view-instance") .allow_database("mydb", "create-table") .allow_resource("mydb", "mytable", "insert-row")) * docs: add 1.0a25 upgrade guide section for create_token() signature change Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393 * docs: note that create_token() is now async in upgrade guide * docs: update internals, plugin_hooks, authentication for new token API - internals.rst: new async create_token() signature with restrictions and handler params, add TokenRestrictions reference docs - plugin_hooks.rst: show full create_token signature in TokenHandler example, note list returns and error cases - authentication.rst: cross-reference TokenRestrictions from the restrictions section * style: apply black formatting to token handler files * docs: fix RST heading underline length in internals.rst * tests: add restrictions round-trip and expiration tests for token handler Covers allow_database/allow_resource builders, _r payload encoding, and token_expires in verified actors. Coverage 76% -> 90%. * tests: add test for signed tokens disabled * fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821 * docs: regenerate plugins.rst with cog * docs: reformat code blocks in plugin_hooks.rst with blacken-docs * docs: add await .verify_token() to internals.rst * tests: rewrite register_token_handler test to use real plugin handler Adds a HardcodedTokenHandler to the test plugins dir that creates tokens like dstok_hardcoded_token_1. The test now exercises creating tokens via the default handler (which is the plugin's hardcoded one), by explicitly naming the hardcoded handler, and by explicitly naming the signed handler -- then verifies each token round-trips correctly. * tests: clarify test_token_handler_via_http tests the default signed handler * fix: use handler="signed" explicitly where signed tokens are expected The HardcodedTokenHandler in my_plugin.py gets globally registered, so create_token() without a handler name picks it up as the default. Fix the create-token view, CLI, and tests to explicitly request the signed handler where they depend on signed token behavior. * fix: use handler="signed" in test_create_table_permissions https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS --- datasette/__init__.py | 1 + datasette/app.py | 102 +++++--- datasette/cli.py | 30 ++- datasette/default_permissions/__init__.py | 1 - datasette/default_permissions/tokens.py | 85 ++---- datasette/hookspecs.py | 5 + datasette/plugins.py | 1 + datasette/tokens.py | 180 +++++++++++++ datasette/views/special.py | 34 +-- docs/authentication.rst | 1 + docs/internals.rst | 81 ++++-- docs/plugin_hooks.rst | 59 +++++ docs/plugins.rst | 11 +- docs/upgrade_guide.md | 40 +++ tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 27 ++ tests/test_api_write.py | 9 +- tests/test_permissions.py | 2 +- tests/test_plugins.py | 32 +++ tests/test_token_handler.py | 301 ++++++++++++++++++++++ 20 files changed, 839 insertions(+), 164 deletions(-) create mode 100644 datasette/tokens.py create mode 100644 tests/test_token_handler.py diff --git a/datasette/__init__.py b/datasette/__init__.py index 47d2b4f6..eb18e59e 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,6 +1,7 @@ from datasette.permissions import Permission # noqa from datasette.version import __version_info__, __version__ # noqa from datasette.events import Event # noqa +from datasette.tokens import TokenHandler, TokenRestrictions # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa from datasette.utils import actor_matches_allow # noqa from datasette.views import Context # noqa diff --git a/datasette/app.py b/datasette/app.py index 6efaa430..2df6e4e8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, Iterable, List if TYPE_CHECKING: from datasette.permissions import Resource + from datasette.tokens import TokenRestrictions import asgi_csrf import collections import dataclasses @@ -713,44 +714,70 @@ class Datasette: """ return _in_datasette_client.get() - def create_token( + def _token_handlers(self): + """Collect all registered token handlers from plugins.""" + from datasette.tokens import TokenHandler + + handlers = [] + for result in pm.hook.register_token_handler(datasette=self): + if isinstance(result, TokenHandler): + handlers.append(result) + elif isinstance(result, list): + handlers.extend(h for h in result if isinstance(h, TokenHandler)) + return handlers + + async def create_token( self, actor_id: str, *, expires_after: int | None = None, - restrict_all: Iterable[str] | None = None, - restrict_database: Dict[str, Iterable[str]] | None = None, - restrict_resource: Dict[str, Dict[str, Iterable[str]]] | None = None, - ): - token = {"a": actor_id, "t": int(time.time())} - if expires_after: - token["d"] = expires_after + restrictions: "TokenRestrictions | None" = None, + handler: str | None = None, + ) -> str: + """ + Create an API token for the given actor. - def abbreviate_action(action): - # rename to abbr if possible - action_obj = self.actions.get(action) - if not action_obj: - return action - return action_obj.abbr or action + Uses the first registered token handler by default, or a specific + handler if ``handler`` is provided (matched by handler name). - if expires_after: - token["d"] = expires_after - if restrict_all or restrict_database or restrict_resource: - token["_r"] = {} - if restrict_all: - token["_r"]["a"] = [abbreviate_action(a) for a in restrict_all] - if restrict_database: - token["_r"]["d"] = {} - for database, actions in restrict_database.items(): - token["_r"]["d"][database] = [abbreviate_action(a) for a in actions] - if restrict_resource: - token["_r"]["r"] = {} - for database, resources in restrict_resource.items(): - for resource, actions in resources.items(): - token["_r"]["r"].setdefault(database, {})[resource] = [ - abbreviate_action(a) for a in actions - ] - return "dstok_{}".format(self.sign(token, namespace="token")) + Pass a :class:`TokenRestrictions` to limit which actions the token + can perform. + """ + handlers = self._token_handlers() + if not handlers: + raise RuntimeError("No token handlers are registered") + + if handler is not None: + matched = [h for h in handlers if h.name == handler] + if not matched: + available = [h.name for h in handlers] + raise ValueError( + f"Token handler {handler!r} not found. " + f"Available handlers: {available}" + ) + chosen = matched[0] + else: + chosen = handlers[0] + + return await chosen.create_token( + self, + actor_id, + expires_after=expires_after, + restrictions=restrictions, + ) + + async def verify_token(self, token: str) -> dict | None: + """ + Verify an API token by trying all registered token handlers. + + Returns an actor dict from the first handler that recognizes the + token, or None if no handler accepts it. + """ + for token_handler in self._token_handlers(): + result = await token_handler.verify_token(self, token) + if result is not None: + return result + return None def get_database(self, name=None, route=None): if route is not None: @@ -2159,10 +2186,13 @@ class DatasetteRouter: # Handle authentication default_actor = scope.get("actor") or None actor = None - for actor in pm.hook.actor_from_request(datasette=self.ds, request=request): - actor = await await_me_maybe(actor) - if actor: - break + results = pm.hook.actor_from_request(datasette=self.ds, request=request) + for result in results: + result = await await_me_maybe(result) + if result and actor is None: + actor = result + # Don't break — we must await all coroutines to avoid + # "coroutine was never awaited" warnings scope_modifications["actor"] = actor or default_actor scope = dict(scope, **scope_modifications) diff --git a/datasette/cli.py b/datasette/cli.py index 121911ab..b473fbb7 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -832,21 +832,23 @@ def create_token( err=True, ) - restrict_database = {} - for database, action in databases: - restrict_database.setdefault(database, []).append(action) - restrict_resource = {} - for database, resource, action in resources: - restrict_resource.setdefault(database, {}).setdefault(resource, []).append( - action - ) + from datasette.tokens import TokenRestrictions - token = ds.create_token( - id, - expires_after=expires_after, - restrict_all=alls, - restrict_database=restrict_database, - restrict_resource=restrict_resource, + restrictions = TokenRestrictions() + for action in alls: + restrictions.allow_all(action) + for database, action in databases: + restrictions.allow_database(database, action) + for database, resource, action in resources: + restrictions.allow_resource(database, resource, action) + + token = run_sync( + lambda: ds.create_token( + id, + expires_after=expires_after, + restrictions=restrictions, + handler="signed", + ) ) click.echo(token) if debug: diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index 40373fa7..4ebe6147 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -37,7 +37,6 @@ from .defaults import ( default_action_permissions_sql as default_action_permissions_sql, DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS, ) -from .tokens import actor_from_signed_api_token as actor_from_signed_api_token @hookimpl diff --git a/datasette/default_permissions/tokens.py b/datasette/default_permissions/tokens.py index 474b0c23..7a359dc6 100644 --- a/datasette/default_permissions/tokens.py +++ b/datasette/default_permissions/tokens.py @@ -1,44 +1,35 @@ """ Token authentication for Datasette. -Handles signed API tokens (dstok_ prefix). +Registers the default SignedTokenHandler and delegates token verification +to datasette.verify_token() so all registered handlers are tried. """ from __future__ import annotations -import time from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from datasette.app import Datasette -import itsdangerous - from datasette import hookimpl +from datasette.tokens import SignedTokenHandler + + +@hookimpl +def register_token_handler(datasette: "Datasette"): + """Register the default signed token handler.""" + return SignedTokenHandler() @hookimpl(specname="actor_from_request") -def actor_from_signed_api_token(datasette: "Datasette", request) -> Optional[dict]: +async def actor_from_signed_api_token( + datasette: "Datasette", request +) -> Optional[dict]: """ - Authenticate requests using signed API tokens (dstok_ prefix). - - Token structure (signed JSON): - { - "a": "actor_id", # Actor ID - "t": 1234567890, # Timestamp (Unix epoch) - "d": 3600, # Optional: Duration in seconds - "_r": {...} # Optional: Restrictions - } + Authenticate requests using API tokens by delegating to all registered + token handlers via datasette.verify_token(). """ - prefix = "dstok_" - - # Check if tokens are enabled - if not datasette.setting("allow_signed_tokens"): - return None - - max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl") - - # Get authorization header authorization = request.headers.get("authorization") if not authorization: return None @@ -46,50 +37,4 @@ def actor_from_signed_api_token(datasette: "Datasette", request) -> Optional[dic return None token = authorization[len("Bearer ") :] - if not token.startswith(prefix): - return None - - # Remove prefix and verify signature - token = token[len(prefix) :] - try: - decoded = datasette.unsign(token, namespace="token") - except itsdangerous.BadSignature: - return None - - # Validate timestamp - if "t" not in decoded: - return None - created = decoded["t"] - if not isinstance(created, int): - return None - - # Handle duration/expiry - duration = decoded.get("d") - if duration is not None and not isinstance(duration, int): - return None - - # Apply max TTL if configured - if (duration is None and max_signed_tokens_ttl) or ( - duration is not None - and max_signed_tokens_ttl - and duration > max_signed_tokens_ttl - ): - duration = max_signed_tokens_ttl - - # Check expiry - if duration: - if time.time() - created > duration: - return None - - # Build actor dict - actor = {"id": decoded["a"], "token": "dstok"} - - # Copy restrictions if present - if "_r" in decoded: - actor["_r"] = decoded["_r"] - - # Add expiry timestamp if applicable - if duration: - actor["token_expires"] = created + duration - - return actor + return await datasette.verify_token(token) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 89be6a65..64901900 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -222,6 +222,11 @@ def top_canned_query(datasette, request, database, query_name): """HTML to include at the top of the canned query page""" +@hookspec +def register_token_handler(datasette): + """Return a TokenHandler instance for token creation and verification""" + + @hookspec def write_wrapper(datasette, database, request, transaction): """Called when a write function is about to execute. diff --git a/datasette/plugins.py b/datasette/plugins.py index e9818885..992137bd 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -23,6 +23,7 @@ DEFAULT_PLUGINS = ( "datasette.sql_functions", "datasette.actor_auth_cookie", "datasette.default_permissions", + "datasette.default_permissions.tokens", "datasette.default_actions", "datasette.default_magic_parameters", "datasette.blob_renderer", diff --git a/datasette/tokens.py b/datasette/tokens.py new file mode 100644 index 00000000..5a12d8e0 --- /dev/null +++ b/datasette/tokens.py @@ -0,0 +1,180 @@ +""" +Token handler system for Datasette. + +Provides a base class for token handlers and the default signed token handler. +Plugins can implement register_token_handler to provide custom token backends +(e.g. database-backed tokens that can be revoked and audited). +""" + +from __future__ import annotations + +import dataclasses +import time +from typing import TYPE_CHECKING, Optional + +import itsdangerous + +if TYPE_CHECKING: + from datasette.app import Datasette + + +@dataclasses.dataclass +class TokenRestrictions: + """ + Restrictions to apply to a token, limiting which actions it can perform. + + Use the builder methods to construct restrictions:: + + restrictions = (TokenRestrictions() + .allow_all("view-instance") + .allow_database("mydb", "create-table") + .allow_resource("mydb", "mytable", "insert-row")) + """ + + all: list[str] = dataclasses.field(default_factory=list) + database: dict[str, list[str]] = dataclasses.field(default_factory=dict) + resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict) + + def allow_all(self, action: str) -> "TokenRestrictions": + """Allow an action across all databases and resources.""" + self.all.append(action) + return self + + def allow_database(self, database: str, action: str) -> "TokenRestrictions": + """Allow an action on a specific database.""" + self.database.setdefault(database, []).append(action) + return self + + def allow_resource( + self, database: str, resource: str, action: str + ) -> "TokenRestrictions": + """Allow an action on a specific resource within a database.""" + self.resource.setdefault(database, {}).setdefault(resource, []).append(action) + return self + + +class TokenHandler: + """ + Base class for token handlers. + + Subclass this and implement create_token() and verify_token() to provide + a custom token backend. Return an instance from the register_token_handler hook. + """ + + name: str = "" + + async def create_token( + self, + datasette: "Datasette", + actor_id: str, + *, + expires_after: Optional[int] = None, + restrictions: Optional[TokenRestrictions] = None, + ) -> str: + """Create and return a token string for the given actor.""" + raise NotImplementedError + + async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]: + """ + Verify a token and return an actor dict, or None if this handler + does not recognize the token. + """ + raise NotImplementedError + + +class SignedTokenHandler(TokenHandler): + """ + Default token handler using itsdangerous signed tokens (dstok_ prefix). + """ + + name = "signed" + + async def create_token( + self, + datasette: "Datasette", + actor_id: str, + *, + expires_after: Optional[int] = None, + restrictions: Optional[TokenRestrictions] = None, + ) -> str: + if not datasette.setting("allow_signed_tokens"): + raise ValueError( + "Signed tokens are not enabled for this Datasette instance" + ) + + token = {"a": actor_id, "t": int(time.time())} + + def abbreviate_action(action): + action_obj = datasette.actions.get(action) + if not action_obj: + return action + return action_obj.abbr or action + + if expires_after: + token["d"] = expires_after + if restrictions and ( + restrictions.all or restrictions.database or restrictions.resource + ): + token["_r"] = {} + if restrictions.all: + token["_r"]["a"] = [abbreviate_action(a) for a in restrictions.all] + if restrictions.database: + token["_r"]["d"] = {} + for database, actions in restrictions.database.items(): + token["_r"]["d"][database] = [abbreviate_action(a) for a in actions] + if restrictions.resource: + token["_r"]["r"] = {} + for database, resources in restrictions.resource.items(): + for resource, actions in resources.items(): + token["_r"]["r"].setdefault(database, {})[resource] = [ + abbreviate_action(a) for a in actions + ] + return "dstok_{}".format(datasette.sign(token, namespace="token")) + + async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]: + prefix = "dstok_" + + if not datasette.setting("allow_signed_tokens"): + return None + + max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl") + + if not token.startswith(prefix): + return None + + raw = token[len(prefix) :] + try: + decoded = datasette.unsign(raw, namespace="token") + except itsdangerous.BadSignature: + return None + + if "t" not in decoded: + return None + created = decoded["t"] + if not isinstance(created, int): + return None + + duration = decoded.get("d") + if duration is not None and not isinstance(duration, int): + return None + + if (duration is None and max_signed_tokens_ttl) or ( + duration is not None + and max_signed_tokens_ttl + and duration > max_signed_tokens_ttl + ): + duration = max_signed_tokens_ttl + + if duration: + if time.time() - created > duration: + return None + + actor = {"id": decoded["a"], "token": "dstok"} + + if "_r" in decoded: + actor["_r"] = decoded["_r"] + + if duration: + actor["token_expires"] = created + duration + + return actor diff --git a/datasette/views/special.py b/datasette/views/special.py index 640c82eb..dbe5eab1 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -710,42 +710,36 @@ class CreateTokenView(BaseView): errors.append("Invalid expire duration unit") # Are there any restrictions? - restrict_all = [] - restrict_database = {} - restrict_resource = {} + from datasette.tokens import TokenRestrictions + + restrictions = TokenRestrictions() for key in form: if key.startswith("all:") and key.count(":") == 1: - restrict_all.append(key.split(":")[1]) + restrictions.allow_all(key.split(":")[1]) elif key.startswith("database:") and key.count(":") == 2: bits = key.split(":") - database = tilde_decode(bits[1]) - action = bits[2] - restrict_database.setdefault(database, []).append(action) + restrictions.allow_database(tilde_decode(bits[1]), bits[2]) elif key.startswith("resource:") and key.count(":") == 3: bits = key.split(":") - database = tilde_decode(bits[1]) - resource = tilde_decode(bits[2]) - action = bits[3] - restrict_resource.setdefault(database, {}).setdefault( - resource, [] - ).append(action) + restrictions.allow_resource( + tilde_decode(bits[1]), tilde_decode(bits[2]), bits[3] + ) - token = self.ds.create_token( + token = await self.ds.create_token( request.actor["id"], expires_after=expires_after, - restrict_all=restrict_all, - restrict_database=restrict_database, - restrict_resource=restrict_resource, + restrictions=restrictions, + handler="signed", ) token_bits = self.ds.unsign(token[len("dstok_") :], namespace="token") await self.ds.track_event( CreateTokenEvent( actor=request.actor, expires_after=expires_after, - restrict_all=restrict_all, - restrict_database=restrict_database, - restrict_resource=restrict_resource, + restrict_all=restrictions.all, + restrict_database=restrictions.database, + restrict_resource=restrictions.resource, ) ) context = await self.shared(request) diff --git a/docs/authentication.rst b/docs/authentication.rst index 69a6f606..1b949f9a 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1072,6 +1072,7 @@ cannot grant new access. If the underlying actor is denied by ``allow`` rules in ``datasette.yaml`` or by a plugin, a token that lists that resource in its ``"_r"`` section will still be denied. +To create tokens with restrictions in Python code, use the :ref:`TokenRestrictions ` builder and pass it to :ref:`datasette.create_token() `. .. _permissions_plugins: diff --git a/docs/internals.rst b/docs/internals.rst index 0491c1f7..7d607bfe 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -673,8 +673,8 @@ This example checks if the user can access a specific table, and sets ``private` .. _datasette_create_token: -.create_token(actor_id, expires_after=None, restrict_all=None, restrict_database=None, restrict_resource=None) --------------------------------------------------------------------------------------------------------------- +await .create_token(actor_id, expires_after=None, restrictions=None, handler=None) +---------------------------------------------------------------------------------- ``actor_id`` - string The ID of the actor to create a token for. @@ -682,16 +682,13 @@ This example checks if the user can access a specific table, and sets ``private` ``expires_after`` - int, optional The number of seconds after which the token should expire. -``restrict_all`` - iterable, optional - A list of actions that this token should be restricted to across all databases and resources. +``restrictions`` - :ref:`TokenRestrictions `, optional + A :ref:`TokenRestrictions ` object limiting which actions the token can perform. -``restrict_database`` - dict, optional - For restricting actions within specific databases, e.g. ``{"mydb": ["view-table", "view-query"]}``. +``handler`` - string, optional + The name of a specific token handler to use. If omitted, the first registered handler is used. See :ref:`plugin_hook_register_token_handler`. -``restrict_resource`` - dict, optional - For restricting actions to specific resources (tables, SQL views and :ref:`canned_queries`) within a database. For example: ``{"mydb": {"mytable": ["insert-row", "update-row"]}}``. - -This method returns a signed :ref:`API token ` of the format ``dstok_...`` which can be used to authenticate requests to the Datasette API. +This is an ``async`` method that returns an :ref:`API token ` string which can be used to authenticate requests to the Datasette API. The default ``SignedTokenHandler`` returns tokens of the format ``dstok_...``. All tokens must have an ``actor_id`` string indicating the ID of the actor which the token will act on behalf of. @@ -699,28 +696,72 @@ Tokens default to lasting forever, but can be set to expire after a given number .. code-block:: python - token = datasette.create_token( + token = await datasette.create_token( actor_id="user1", expires_after=3600, ) -The three ``restrict_*`` arguments can be used to create a token that has additional restrictions beyond what the associated actor is allowed to do. +.. _TokenRestrictions: + +TokenRestrictions +~~~~~~~~~~~~~~~~~ + +The ``TokenRestrictions`` class uses a builder pattern to specify which actions a token is allowed to perform. Import it from ``datasette.tokens``: + +.. code-block:: python + + from datasette.tokens import TokenRestrictions + + restrictions = ( + TokenRestrictions() + .allow_all("view-instance") + .allow_all("view-table") + .allow_database("docs", "view-query") + .allow_resource("docs", "attachments", "insert-row") + .allow_resource("docs", "attachments", "update-row") + ) + +The builder methods are: + +- ``allow_all(action)`` - allow an action across all databases and resources +- ``allow_database(database, action)`` - allow an action on a specific database +- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`canned query `) within a database + +Each method returns the ``TokenRestrictions`` instance so calls can be chained. The following example creates a token that can access ``view-instance`` and ``view-table`` across everything, can additionally use ``view-query`` for anything in the ``docs`` database and is allowed to execute ``insert-row`` and ``update-row`` in the ``attachments`` table in that database: .. code-block:: python - token = datasette.create_token( + token = await datasette.create_token( actor_id="user1", - restrict_all=("view-instance", "view-table"), - restrict_database={"docs": ("view-query",)}, - restrict_resource={ - "docs": { - "attachments": ("insert-row", "update-row") - } - }, + restrictions=( + TokenRestrictions() + .allow_all("view-instance") + .allow_all("view-table") + .allow_database("docs", "view-query") + .allow_resource("docs", "attachments", "insert-row") + .allow_resource("docs", "attachments", "update-row") + ), ) +.. _datasette_verify_token: + +await .verify_token(token) +-------------------------- + +``token`` - string + The token string to verify. + +This is an ``async`` method that verifies an API token by trying each registered token handler in order. Returns an actor dictionary from the first handler that recognizes the token, or ``None`` if no handler accepts it. + +.. code-block:: python + + actor = await datasette.verify_token(token) + if actor: + # Token was valid + print(actor["id"]) + .. _datasette_get_database: .get_database(name) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index fa335368..b9701f7c 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -2334,3 +2334,62 @@ The plugin can then call ``datasette.track_event(...)`` to send a ``ban-user`` e BanUserEvent(user={"id": 1, "username": "cleverbot"}) ) +.. _plugin_hook_register_token_handler: + +register_token_handler(datasette) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +Return a ``TokenHandler`` instance to provide a custom token creation and verification backend. This hook can return a single ``TokenHandler`` or a list of them. + +The default ``SignedTokenHandler`` uses itsdangerous signed tokens (``dstok_`` prefix). Plugins can provide alternative backends such as database-backed tokens that support revocation and auditing. + +.. code-block:: python + + from datasette import hookimpl, TokenHandler + + + class DatabaseTokenHandler(TokenHandler): + name = "database" + + async def create_token( + self, + datasette, + actor_id, + *, + expires_after=None, + restrictions=None + ): + # Store token in database and return token string + ... + + async def verify_token(self, datasette, token): + # Look up token in database, return actor dict or None + ... + + + @hookimpl + def register_token_handler(datasette): + return DatabaseTokenHandler() + +The ``create_token`` method receives a ``restrictions`` argument which will be a :ref:`TokenRestrictions ` instance or ``None``. + +Tokens can then be created and verified using :ref:`datasette.create_token() ` and ``datasette.verify_token()``, which delegate to the registered handlers. If no ``handler`` is specified, the first handler is used according to `pluggy call-time ordering `_. Use the ``handler`` parameter to select a specific backend by name: + +.. code-block:: python + + # Uses first registered handler (default) + token = await datasette.create_token("user123") + + # Uses a specific handler by name + token = await datasette.create_token( + "user123", handler="database" + ) + + # Verification tries all handlers + actor = await datasette.verify_token(token) + +If no handlers are registered, ``create_token()`` raises ``RuntimeError``. If the requested ``handler`` name is not found, it raises ``ValueError``. + diff --git a/docs/plugins.rst b/docs/plugins.rst index d5a98923..60bdc111 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -231,12 +231,21 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "templates": false, "version": null, "hooks": [ - "actor_from_request", "canned_queries", "permission_resources_sql", "skip_csrf" ] }, + { + "name": "datasette.default_permissions.tokens", + "static": false, + "templates": false, + "version": null, + "hooks": [ + "actor_from_request", + "register_token_handler" + ] + }, { "name": "datasette.events", "static": false, diff --git a/docs/upgrade_guide.md b/docs/upgrade_guide.md index a3c321a4..861a8795 100644 --- a/docs/upgrade_guide.md +++ b/docs/upgrade_guide.md @@ -114,3 +114,43 @@ Instead, one should use the following methods on a Datasette class: ```{include} upgrade-1.0a20.md :heading-offset: 1 ``` + +(upgrade_guide_v1_a25)= +### Datasette 1.0a25: `create_token()` signature change + +`datasette.create_token()` is now an `async` method (previously it was synchronous). The `restrict_all`, `restrict_database`, and `restrict_resource` keyword arguments have been replaced by a single `restrictions` parameter that accepts a {ref}`TokenRestrictions ` object. + +Old code: + +```python +token = datasette.create_token( + actor_id="user1", + restrict_all=["view-instance", "view-table"], + restrict_database={"docs": ["view-query"]}, + restrict_resource={ + "docs": { + "attachments": ["insert-row", "update-row"] + } + }, +) +``` + +New code: + +```python +from datasette.tokens import TokenRestrictions + +token = await datasette.create_token( + actor_id="user1", + restrictions=( + TokenRestrictions() + .allow_all("view-instance") + .allow_all("view-table") + .allow_database("docs", "view-query") + .allow_resource("docs", "attachments", "insert-row") + .allow_resource("docs", "attachments", "update-row") + ), +) +``` + +The `datasette create-token` CLI command is unchanged. diff --git a/tests/fixtures.py b/tests/fixtures.py index 9f99519a..1f6c491d 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -51,6 +51,7 @@ EXPECTED_PLUGINS = [ "register_facet_classes", "register_magic_parameters", "register_routes", + "register_token_handler", "render_cell", "row_actions", "skip_csrf", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 20e7d111..77079557 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -1,6 +1,7 @@ import asyncio from datasette import hookimpl from datasette.facets import Facet +from datasette.tokens import TokenHandler from datasette import tracer from datasette.permissions import Action from datasette.resources import DatabaseResource @@ -586,3 +587,29 @@ def permission_resources_sql(datasette, actor, action): return PermissionSQL.allow(reason=f"todomvc actor allowed for {action}") return None + + +class HardcodedTokenHandler(TokenHandler): + name = "hardcoded" + _counter = 0 + + async def create_token( + self, + datasette, + actor_id, + *, + expires_after=None, + restrictions=None, + ): + HardcodedTokenHandler._counter += 1 + return f"dstok_hardcoded_token_{HardcodedTokenHandler._counter}" + + async def verify_token(self, datasette, token): + if token.startswith("dstok_hardcoded_token_"): + return {"id": "hardcoded-actor", "token": "hardcoded"} + return None + + +@hookimpl +def register_token_handler(datasette): + return HardcodedTokenHandler() diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 05835e51..e59c4295 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1362,7 +1362,14 @@ async def test_create_table( async def test_create_table_permissions( ds_write, permissions, body, expected_status, expected_errors ): - token = ds_write.create_token("root", restrict_all=["view-instance"] + permissions) + from datasette.tokens import TokenRestrictions + + restrictions = TokenRestrictions() + for action in ["view-instance"] + permissions: + restrictions.allow_all(action) + token = await ds_write.create_token( + "root", handler="signed", restrictions=restrictions + ) response = await ds_write.client.post( "/data/-/create", json=body, diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 96c0cf6f..42a19ca4 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -1657,7 +1657,7 @@ async def test_permission_check_view_requires_debug_permission(): # Root user should have access (root has all permissions) ds_with_root = Datasette() ds_with_root.root_enabled = True - root_token = ds_with_root.create_token("root") + root_token = await ds_with_root.create_token("root", handler="signed") response = await ds_with_root.client.get( "/-/check.json?action=view-instance", headers={"Authorization": f"Bearer {root_token}"}, diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 754b199c..fa9d1a1f 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1566,6 +1566,38 @@ async def test_hook_register_events(): assert any(k.__name__ == "OneEvent" for k in datasette.event_classes) +@pytest.mark.asyncio +async def test_hook_register_token_handler(ds_client): + handlers = ds_client.ds._token_handlers() + handler_names = [h.name for h in handlers] + # Both the default signed handler and the test hardcoded handler + assert "signed" in handler_names + assert "hardcoded" in handler_names + + # Create a token using the hardcoded handler (first registered from plugins dir) + token = await ds_client.ds.create_token("test-user") + assert token.startswith("dstok_hardcoded_token_") + + # Verify it + actor = await ds_client.ds.verify_token(token) + assert actor["id"] == "hardcoded-actor" + assert actor["token"] == "hardcoded" + + # Create a token by explicitly requesting the hardcoded handler by name + token2 = await ds_client.ds.create_token("test-user", handler="hardcoded") + assert token2.startswith("dstok_hardcoded_token_") + actor2 = await ds_client.ds.verify_token(token2) + assert actor2["id"] == "hardcoded-actor" + + # Create a token by explicitly requesting the signed handler by name + signed_token = await ds_client.ds.create_token("test-user", handler="signed") + assert signed_token.startswith("dstok_") + assert not signed_token.startswith("dstok_hardcoded_token_") + signed_actor = await ds_client.ds.verify_token(signed_token) + assert signed_actor["id"] == "test-user" + assert signed_actor["token"] == "dstok" + + @pytest.mark.asyncio async def test_hook_write_wrapper(): datasette = Datasette(memory=True) diff --git a/tests/test_token_handler.py b/tests/test_token_handler.py new file mode 100644 index 00000000..83f09046 --- /dev/null +++ b/tests/test_token_handler.py @@ -0,0 +1,301 @@ +""" +Tests for the register_token_handler plugin hook. +""" + +from datasette.app import Datasette +from datasette.hookspecs import hookimpl +from datasette.plugins import pm +from datasette.tokens import TokenHandler, TokenRestrictions, SignedTokenHandler +import pytest + + +@pytest.fixture +def datasette(): + return Datasette() + + +@pytest.mark.asyncio +async def test_default_signed_handler_registered(datasette): + """The default SignedTokenHandler should be registered automatically.""" + handlers = datasette._token_handlers() + assert len(handlers) >= 1 + assert any(isinstance(h, SignedTokenHandler) for h in handlers) + assert any(h.name == "signed" for h in handlers) + + +@pytest.mark.asyncio +async def test_create_token_default(datasette): + """create_token() with handler='signed' should create a signed token.""" + token = await datasette.create_token("test_actor", handler="signed") + assert token.startswith("dstok_") + + +@pytest.mark.asyncio +async def test_create_token_with_restrictions(datasette): + """create_token() should handle restriction parameters.""" + token = await datasette.create_token( + "test_actor", + handler="signed", + expires_after=3600, + restrictions=TokenRestrictions().allow_all("view-instance"), + ) + assert token.startswith("dstok_") + # Verify the token contains the expected data + decoded = datasette.unsign(token[len("dstok_") :], namespace="token") + assert decoded["a"] == "test_actor" + assert decoded["d"] == 3600 + assert "_r" in decoded + assert "a" in decoded["_r"] + + +@pytest.mark.asyncio +async def test_verify_token_default(datasette): + """verify_token() should verify signed tokens.""" + token = await datasette.create_token("test_actor", handler="signed") + actor = await datasette.verify_token(token) + assert actor is not None + assert actor["id"] == "test_actor" + assert actor["token"] == "dstok" + + +@pytest.mark.asyncio +async def test_verify_token_unknown_returns_none(datasette): + """verify_token() should return None for unrecognized tokens.""" + result = await datasette.verify_token("unknown_token_format_xyz") + assert result is None + + +@pytest.mark.asyncio +async def test_verify_token_bad_signature_returns_none(datasette): + """verify_token() should return None for tokens with bad signatures.""" + result = await datasette.verify_token("dstok_tampered_data_here") + assert result is None + + +@pytest.mark.asyncio +async def test_create_token_with_named_handler(datasette): + """create_token(handler='signed') should select the signed handler.""" + token = await datasette.create_token("test_actor", handler="signed") + assert token.startswith("dstok_") + + +@pytest.mark.asyncio +async def test_create_token_unknown_handler_raises(datasette): + """create_token(handler='nonexistent') should raise ValueError.""" + with pytest.raises(ValueError, match="Token handler 'nonexistent' not found"): + await datasette.create_token("test_actor", handler="nonexistent") + + +@pytest.mark.asyncio +async def test_custom_token_handler(datasette): + """A custom token handler should be usable for both create and verify.""" + + class CustomHandler(TokenHandler): + name = "custom" + + async def create_token(self, datasette, actor_id, **kwargs): + return f"custom_{actor_id}" + + async def verify_token(self, datasette, token): + if token.startswith("custom_"): + return {"id": token[len("custom_") :], "token": "custom"} + return None + + class Plugin: + __name__ = "CustomTokenPlugin" + + @staticmethod + @hookimpl + def register_token_handler(datasette): + return CustomHandler() + + pm.register(Plugin(), name="test_custom_handler") + try: + handlers = datasette._token_handlers() + assert any(h.name == "custom" for h in handlers) + + # Create with custom handler + token = await datasette.create_token("alice", handler="custom") + assert token == "custom_alice" + + # Verify custom token + actor = await datasette.verify_token("custom_alice") + assert actor is not None + assert actor["id"] == "alice" + assert actor["token"] == "custom" + + # Signed tokens should still work + signed_token = await datasette.create_token("bob", handler="signed") + assert signed_token.startswith("dstok_") + actor = await datasette.verify_token(signed_token) + assert actor["id"] == "bob" + finally: + pm.unregister(name="test_custom_handler") + + +@pytest.mark.asyncio +async def test_verify_token_tries_all_handlers(datasette): + """verify_token() should try each handler until one matches.""" + + class HandlerA(TokenHandler): + name = "handler_a" + + async def create_token(self, datasette, actor_id, **kwargs): + return f"a_{actor_id}" + + async def verify_token(self, datasette, token): + if token.startswith("a_"): + return {"id": token[2:], "token": "handler_a"} + return None + + class HandlerB(TokenHandler): + name = "handler_b" + + async def create_token(self, datasette, actor_id, **kwargs): + return f"b_{actor_id}" + + async def verify_token(self, datasette, token): + if token.startswith("b_"): + return {"id": token[2:], "token": "handler_b"} + return None + + class PluginA: + __name__ = "PluginA" + + @staticmethod + @hookimpl + def register_token_handler(datasette): + return HandlerA() + + class PluginB: + __name__ = "PluginB" + + @staticmethod + @hookimpl + def register_token_handler(datasette): + return HandlerB() + + pm.register(PluginA(), name="test_handler_a") + pm.register(PluginB(), name="test_handler_b") + try: + # Both handler tokens should verify + actor_a = await datasette.verify_token("a_alice") + assert actor_a is not None + assert actor_a["id"] == "alice" + assert actor_a["token"] == "handler_a" + + actor_b = await datasette.verify_token("b_bob") + assert actor_b is not None + assert actor_b["id"] == "bob" + assert actor_b["token"] == "handler_b" + + # Unknown token should return None + assert await datasette.verify_token("c_charlie") is None + finally: + pm.unregister(name="test_handler_a") + pm.unregister(name="test_handler_b") + + +@pytest.mark.asyncio +async def test_token_handler_via_http(datasette): + """Default signed tokens should work through HTTP auth.""" + token = await datasette.create_token("http_user", handler="signed") + response = await datasette.client.get( + "/-/actor.json", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + actor = response.json()["actor"] + assert actor["id"] == "http_user" + assert actor["token"] == "dstok" + + +@pytest.mark.asyncio +async def test_custom_handler_via_http(datasette): + """Custom handler tokens should work through HTTP auth.""" + + class CustomHandler(TokenHandler): + name = "custom_http" + + async def create_token(self, datasette, actor_id, **kwargs): + return f"chttp_{actor_id}" + + async def verify_token(self, datasette, token): + if token.startswith("chttp_"): + return {"id": token[len("chttp_") :], "token": "custom_http"} + return None + + class Plugin: + __name__ = "CustomHTTPPlugin" + + @staticmethod + @hookimpl + def register_token_handler(datasette): + return CustomHandler() + + pm.register(Plugin(), name="test_custom_http") + try: + token = await datasette.create_token("web_user", handler="custom_http") + response = await datasette.client.get( + "/-/actor.json", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + actor = response.json()["actor"] + assert actor["id"] == "web_user" + assert actor["token"] == "custom_http" + finally: + pm.unregister(name="test_custom_http") + + +@pytest.mark.asyncio +async def test_token_handler_base_class_raises(): + """TokenHandler base class methods should raise NotImplementedError.""" + handler = TokenHandler() + ds = Datasette() + with pytest.raises(NotImplementedError): + await handler.create_token(ds, "test") + with pytest.raises(NotImplementedError): + await handler.verify_token(ds, "test") + + +@pytest.mark.asyncio +async def test_restrictions_round_trip(datasette): + """Tokens with database/resource restrictions should round-trip correctly.""" + restrictions = ( + TokenRestrictions() + .allow_all("view-instance") + .allow_database("docs", "view-query") + .allow_resource("docs", "attachments", "insert-row") + ) + token = await datasette.create_token( + "test_actor", handler="signed", restrictions=restrictions + ) + actor = await datasette.verify_token(token) + assert actor is not None + assert actor["id"] == "test_actor" + assert actor["_r"]["a"] == ["view-instance"] + assert actor["_r"]["d"] == {"docs": ["view-query"]} + assert actor["_r"]["r"] == {"docs": {"attachments": ["insert-row"]}} + + +@pytest.mark.asyncio +async def test_expires_after_round_trip(datasette): + """Tokens with expires_after should include token_expires in the actor.""" + token = await datasette.create_token( + "test_actor", handler="signed", expires_after=3600 + ) + actor = await datasette.verify_token(token) + assert actor is not None + assert actor["id"] == "test_actor" + assert "token_expires" in actor + + +@pytest.mark.asyncio +async def test_signed_tokens_disabled(): + """create_token and verify_token should fail/skip when signed tokens are disabled.""" + ds = Datasette(settings={"allow_signed_tokens": False}) + with pytest.raises(ValueError, match="Signed tokens are not enabled"): + await ds.create_token("test_actor", handler="signed") + # verify_token should return None rather than raising + assert await ds.verify_token("dstok_anything") is None From 24d801b7f799912cb4eb897a97e4f4a9fe76b966 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 25 Feb 2026 16:33:27 -0800 Subject: [PATCH 022/203] Respect metadata-defined facet ordering in sorted_facet_results (#2648) * Preserve metadata-defined facet ordering on table pages When facets are explicitly defined in table metadata/config, they now appear in the order specified in the configuration rather than being sorted by result count. Request-added facets still appear after metadata-defined facets, sorted by count as before. * Document metadata-defined facet ordering behavior * Apply black formatting https://claude.ai/code/session_01PbSHtjsUpNk3Fx7xjvVqDb --- datasette/views/table.py | 34 ++++++++++++++++++++++++++----- docs/facets.rst | 2 ++ tests/test_facets.py | 44 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 594e925e..e1e5507f 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1580,11 +1580,35 @@ async def table_view_data( ] async def extra_sorted_facet_results(extra_facet_results): - return sorted( - extra_facet_results["results"].values(), - key=lambda f: (len(f["results"]), f["name"]), - reverse=True, - ) + facet_configs = table_metadata.get("facets", []) + if facet_configs: + # Build ordered list of facet names from metadata config + metadata_facet_names = [] + for fc in facet_configs: + if isinstance(fc, str): + metadata_facet_names.append(fc) + elif isinstance(fc, dict): + metadata_facet_names.append(list(fc.values())[0]) + metadata_order = {name: i for i, name in enumerate(metadata_facet_names)} + metadata_facets = [] + request_facets = [] + for f in extra_facet_results["results"].values(): + if f["name"] in metadata_order: + metadata_facets.append(f) + else: + request_facets.append(f) + metadata_facets.sort(key=lambda f: metadata_order[f["name"]]) + request_facets.sort( + key=lambda f: (len(f["results"]), f["name"]), + reverse=True, + ) + return metadata_facets + request_facets + else: + return sorted( + extra_facet_results["results"].values(), + key=lambda f: (len(f["results"]), f["name"]), + reverse=True, + ) async def extra_table_definition(): return await db.get_table_definition(table_name) diff --git a/docs/facets.rst b/docs/facets.rst index 15fe7227..2a135b69 100644 --- a/docs/facets.rst +++ b/docs/facets.rst @@ -153,6 +153,8 @@ Here's an example that turns on faceting by default for the ``qLegalStatus`` col Facets defined in this way will always be shown in the interface and returned in the API, regardless of the ``_facet`` arguments passed to the view. +Facets defined in metadata will be displayed in the order they are listed in the configuration. Any additional facets added via query string parameters (e.g. ``?_facet=column_name``) will appear after the metadata-defined facets, sorted by the number of unique values. + You can specify :ref:`array ` or :ref:`date ` facets in metadata using JSON objects with a single key of ``array`` or ``date`` and a value specifying the column, like this: .. [[[cog diff --git a/tests/test_facets.py b/tests/test_facets.py index a2b505ec..8c22ffce 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -623,12 +623,48 @@ def test_other_types_of_facet_in_metadata(): } ) as client: response = client.get("/fixtures/facetable") - for fragment in ( - "created (date)\n", - "tags (array)\n", + fragments = ( "state\n", - ): + "tags (array)\n", + "created (date)\n", + ) + for fragment in fragments: assert fragment in response.text + # Verify they appear in the metadata-defined order + positions = [response.text.index(f) for f in fragments] + assert positions == sorted( + positions + ), "Facets should appear in metadata-defined order" + + +def test_metadata_facet_ordering(): + with make_app_client( + metadata={ + "databases": { + "fixtures": { + "tables": { + "facetable": { + "facets": ["state", {"array": "tags"}, {"date": "created"}] + } + } + } + } + } + ) as client: + # JSON response should have facets in the metadata-defined order + response = client.get("/fixtures/facetable.json?_extra=sorted_facet_results") + data = response.json + facet_names = [f["name"] for f in data["sorted_facet_results"]] + assert facet_names == ["state", "tags", "created"] + + # With an additional request-based facet, metadata facets come first + # in their defined order, followed by request-based facets + response2 = client.get( + "/fixtures/facetable.json?_extra=sorted_facet_results&_facet=_city_id" + ) + data2 = response2.json + facet_names2 = [f["name"] for f in data2["sorted_facet_results"]] + assert facet_names2 == ["state", "tags", "created", "_city_id"] @pytest.mark.asyncio From 2bc1dd2275978e75622c5764729a4273ebac957e Mon Sep 17 00:00:00 2001 From: Daniel Bates Date: Wed, 25 Feb 2026 16:46:29 -0800 Subject: [PATCH 023/203] Fix --reload interpreting 'serve' command as a file argument (#2646) When hupper spawns the worker process, it calls the function specified by worker_path directly. Using "datasette.cli.serve" causes Click to parse sys.argv without going through the CLI group, so the literal word "serve" from the original command gets treated as a positional file argument. Change the worker path to "datasette.cli.cli" so the worker process goes through the Click group dispatcher, which properly recognizes "serve" as a subcommand and strips it from the argument list. Closes #2123 Co-authored-by: Claude Opus 4.6 Co-authored-by: Simon Willison --- datasette/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/cli.py b/datasette/cli.py index b473fbb7..db777fe8 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -547,7 +547,7 @@ def serve( if reload: import hupper - reloader = hupper.start_reloader("datasette.cli.serve") + reloader = hupper.start_reloader("datasette.cli.cli") if immutable: reloader.watch_files(immutable) if config: From 1246c6576bb2f1ba9dc5c7d9811427d00d440976 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 25 Feb 2026 16:49:14 -0800 Subject: [PATCH 024/203] Release 1.0a25 Refs #2636, #2641, #2646, #2647, #2650 --- docs/changelog.rst | 41 +++++++++++++++++++++++++++++++++++++++++ docs/contributing.rst | 1 + docs/upgrade-1.0a20.md | 1 - docs/upgrade_guide.md | 1 + 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 67ceeece..c0467793 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,47 @@ Changelog ========= +.. _v1_0_a25: + +1.0a25 (2026-02-25) +------------------- + +``write_wrapper`` plugin hook for intercepting write operations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A new :ref:`write_wrapper() ` plugin hook allows plugins to intercept and wrap database write operations. (`#2636 `__) + +Plugins implement the hook as a generator-based context manager: + +.. code-block:: python + + @hookimpl + def write_wrapper(datasette, database, request): + def wrapper(conn): + # Setup code runs before the write + yield + # Cleanup code runs after the write + + return wrapper + +``register_token_handler()`` plugin hook for custom API token backends +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A new :ref:`register_token_handler() ` plugin hook allows plugins to provide custom token backends for API authentication. (`#2650 `__) + +This includes a **backwards incompatible change**: the ``datasette.create_token()`` internal method is now an ``async`` method. Consult the :ref:`upgrade guide ` for details on how to update your code. + +``render_cell()`` now receives a ``pks`` parameter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`render_cell() ` plugin hook now receives a ``pks`` parameter containing the list of primary key column names for the table being rendered. This avoids plugins needing to make redundant async calls to look up primary keys. (`#2641 `__) + +Other changes +~~~~~~~~~~~~~ + +- Facets defined in metadata now preserve their configured order, instead of being sorted by result count. Request-based facets added via the ``_facet`` parameter are still sorted by result count and appear after metadata-defined facets. (:issue:`2647`) +- Fixed ``--reload`` incorrectly interpreting the ``serve`` command as a file argument. Thanks, `Daniel Bates `__. (`#2646 `__) + .. _v1_0_a24: 1.0a24 (2026-01-29) diff --git a/docs/contributing.rst b/docs/contributing.rst index 3d41a125..635ca60e 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -90,6 +90,7 @@ If you want to change Datasette's Python code you can use the ``--reload`` optio You can also use the ``fixtures.py`` script to recreate the testing version of ``metadata.json`` used by the unit tests. To do that:: uv run python tests/fixtures.py fixtures.db fixtures-metadata.json + Or to output the plugins used by the tests, run this:: uv run python tests/fixtures.py fixtures.db fixtures-metadata.json fixtures-plugins diff --git a/docs/upgrade-1.0a20.md b/docs/upgrade-1.0a20.md index 749d383c..fbc3f4a8 100644 --- a/docs/upgrade-1.0a20.md +++ b/docs/upgrade-1.0a20.md @@ -2,7 +2,6 @@ orphan: true --- -(upgrade_guide_v1_a20)= # Datasette 1.0a20 plugin upgrade guide Datasette 1.0a20 makes some breaking changes to Datasette's permission system. Plugins need to be updated if they use **any of the following**: diff --git a/docs/upgrade_guide.md b/docs/upgrade_guide.md index 861a8795..b67eb054 100644 --- a/docs/upgrade_guide.md +++ b/docs/upgrade_guide.md @@ -111,6 +111,7 @@ Instead, one should use the following methods on a Datasette class: - {ref}`get_resource_metadata() ` - {ref}`get_column_metadata() ` +(upgrade_guide_v1_a20)= ```{include} upgrade-1.0a20.md :heading-offset: 1 ``` From e4ff5e27d356ca5b3c807e821acedf8c71c37e47 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 25 Feb 2026 16:54:51 -0800 Subject: [PATCH 025/203] Fix RST heading underlin --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c0467793..1e6a8e90 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,7 +35,7 @@ A new :ref:`register_token_handler() ` plugi This includes a **backwards incompatible change**: the ``datasette.create_token()`` internal method is now an ``async`` method. Consult the :ref:`upgrade guide ` for details on how to update your code. ``render_cell()`` now receives a ``pks`` parameter -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :ref:`render_cell() ` plugin hook now receives a ``pks`` parameter containing the list of primary key column names for the table being rendered. This avoids plugins needing to make redundant async calls to look up primary keys. (`#2641 `__) From 8f0d60236f844a6d12bd1439f57b1b3d65fcad36 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 25 Feb 2026 17:01:03 -0800 Subject: [PATCH 026/203] Bump version for 1.0a25 --- datasette/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/version.py b/datasette/version.py index de7585ca..2907e537 100644 --- a/datasette/version.py +++ b/datasette/version.py @@ -1,2 +1,2 @@ -__version__ = "1.0a24" +__version__ = "1.0a25" __version_info__ = tuple(__version__.split(".")) From 1263380ea6b138ac63683edfd525323c6fe8eef9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 25 Feb 2026 20:50:46 -0800 Subject: [PATCH 027/203] Better heading for write_wrapper() --- docs/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1e6a8e90..2c9b7170 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,8 +9,8 @@ Changelog 1.0a25 (2026-02-25) ------------------- -``write_wrapper`` plugin hook for intercepting write operations -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``write_wrapper()`` plugin hook for intercepting write operations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A new :ref:`write_wrapper() ` plugin hook allows plugins to intercept and wrap database write operations. (`#2636 `__) From 97201f067c4f64b00ccf7e02f787d65c767f9bc9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 6 Mar 2026 20:16:50 -0800 Subject: [PATCH 028/203] Row pages link to foreign keys from table display, closes #1592 https://gisthost.github.io/?40813f5b3e4d83c0efe1c09135f84290/index.html Also now shows primary key column first and in bold on that page. --- datasette/views/row.py | 64 ++++++++++++++++++++++++++++++++++++++++-- tests/test_html.py | 32 +++++++++++++++++---- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/datasette/views/row.py b/datasette/views/row.py index 9c59cd3b..7cc46368 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -5,12 +5,14 @@ from datasette.resources import TableResource from .base import DataView, BaseView, _error from datasette.utils import ( await_me_maybe, + CustomRow, make_slot_function, to_css_class, escape_sqlite, ) from datasette.plugins import pm import json +import markupsafe import sqlite_utils from .table import display_columns_and_rows, _get_extras @@ -42,13 +44,62 @@ class RowView(DataView): if not rows: raise NotFound(f"Record not found: {pk_values}") + pks = resolved.pks + async def template_data(): + # Reorder columns so primary keys come first + pk_set = set(pks) + pk_cols = [d for d in results.description if d[0] in pk_set] + non_pk_cols = [d for d in results.description if d[0] not in pk_set] + reordered_description = pk_cols + non_pk_cols + reordered_columns = [d[0] for d in reordered_description] + + # Reorder row data to match + reordered_rows = [] + for row in rows: + new_row = CustomRow(reordered_columns) + for col in reordered_columns: + new_row[col] = row[col] + reordered_rows.append(new_row) + + # Expand foreign key columns into dicts so display_columns_and_rows + # renders them as hyperlinks, matching the table view behavior + expanded_rows = reordered_rows + for fk in await db.foreign_keys_for_table(table): + column = fk["column"] + if column not in reordered_columns: + continue + column_index = reordered_columns.index(column) + values = [row[column_index] for row in expanded_rows] + expanded_labels = await self.ds.expand_foreign_keys( + request.actor, database, table, column, values + ) + if expanded_labels: + new_rows = [] + for row in expanded_rows: + new_row = CustomRow(reordered_columns) + for col in reordered_columns: + value = row[col] + if ( + col == column + and (col, value) in expanded_labels + and value is not None + ): + new_row[col] = { + "value": value, + "label": expanded_labels[(col, value)], + } + else: + new_row[col] = value + new_rows.append(new_row) + expanded_rows = new_rows + display_columns, display_rows = await display_columns_and_rows( self.ds, database, table, - results.description, - rows, + reordered_description, + expanded_rows, link_column=False, truncate_cells=0, request=request, @@ -56,6 +107,14 @@ class RowView(DataView): for column in display_columns: column["sortable"] = False + # Bold primary key cell values + for row in display_rows: + for cell in row: + if cell["column"] in pk_set: + cell["value"] = markupsafe.Markup( + "{}".format(cell["value"]) + ) + row_actions = [] for hook in pm.hook.row_actions( datasette=self.ds, @@ -71,6 +130,7 @@ class RowView(DataView): return { "private": private, + "columns": reordered_columns, "foreign_key_tables": await self.foreign_key_tables( database, table, pk_values ), diff --git a/tests/test_html.py b/tests/test_html.py index 757f3e6e..64ae7b2d 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -347,7 +347,7 @@ async def test_row_html_simple_primary_key(ds_client): assert ["id", "content"] == [th.string.strip() for th in table.select("thead th")] assert [ [ - '1', + '1', 'hello', ] ] == [[str(td) for td in tr.select("td")] for tr in table.select("tbody tr")] @@ -363,7 +363,7 @@ async def test_row_html_no_primary_key(ds_client): ] expected = [ [ - '1', + '1', '1', 'a1', 'b1', @@ -406,6 +406,26 @@ async def test_row_links_from_other_tables( assert link == expected_link +@pytest.mark.asyncio +async def test_row_foreign_key_links(ds_client): + # Row detail page should render foreign key values as hyperlinks + response = await ds_client.get("/fixtures/foreign_key_references/1") + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + # foreign_key_with_label=1 references simple_primary_key(id=1, content="hello") + td = soup.find("td", {"class": "col-foreign_key_with_label"}) + a = td.find("a") + assert a is not None, "Expected foreign key value to be a hyperlink" + assert a["href"] == "/fixtures/simple_primary_key/1" + assert a.text == "hello" + # Primary key column should be first and bold + table = soup.find("table") + headers = [th.text.strip() for th in table.select("thead th")] + assert headers[0] == "pk" + first_td = table.select("tbody tr td")[0] + assert first_td.find("strong") is not None, "PK value should be bold" + + @pytest.mark.asyncio @pytest.mark.parametrize( "path,expected", @@ -414,8 +434,8 @@ async def test_row_links_from_other_tables( "/fixtures/compound_primary_key/a,b", [ [ - 'a', - 'b', + 'a', + 'b', 'c', ] ], @@ -424,8 +444,8 @@ async def test_row_links_from_other_tables( "/fixtures/compound_primary_key/a~2Fb,~2Ec~2Dd", [ [ - 'a/b', - '.c-d', + 'a/b', + '.c-d', 'c', ] ], From e2c1e81ec9505f02566de840c1dba5ea7b0b121d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 9 Mar 2026 17:45:24 -0700 Subject: [PATCH 029/203] UI for selecting and re-ordering columns on the table page (#2662) New Web Component on table/view page with a dialog for selecting and re-ordering columns. Closes #2661 Refs #1298 --- datasette/static/app.css | 19 + datasette/static/column-chooser.js | 698 ++++++++++++++++++++++++++ datasette/static/navigation-search.js | 13 +- datasette/static/table.js | 58 +++ datasette/templates/table.html | 9 + datasette/views/table.py | 6 + tests/test_html.py | 23 +- tests/test_table_html.py | 63 +++ 8 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 datasette/static/column-chooser.js diff --git a/datasette/static/app.css b/datasette/static/app.css index a7fc7fa3..4183b58e 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -63,6 +63,14 @@ em { } /* end reset */ +/* Modal CSS variables (shared by web components via Shadow DOM) */ +:root { + --modal-backdrop-bg: rgba(0, 0, 0, 0.5); + --modal-backdrop-blur: blur(4px); + --modal-border-radius: 0.75rem; + --modal-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --modal-animation-duration: 0.2s; +} body { margin: 0; @@ -795,6 +803,17 @@ p.zero-results { .filters input.filter-value { width: 140px; } + button.choose-columns-mobile { + display: inline-block; + padding: 0.5rem 1rem; + margin-bottom: 1em; + font-size: 0.9rem; + font-family: inherit; + background: white; + border: 1px solid #ccc; + border-radius: 5px; + cursor: pointer; + } } svg.dropdown-menu-icon { diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js new file mode 100644 index 00000000..9680398c --- /dev/null +++ b/datasette/static/column-chooser.js @@ -0,0 +1,698 @@ +class ColumnChooser extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + // State + this._items = []; + this._checked = new Set(); + this._savedItems = null; + this._savedChecked = null; + this._onApply = null; + + // Drag state + this._ghost = null; + this._dragSrcIdx = null; + this._dropTargetIdx = null; + this._dropPosition = null; + this._ghostOffX = 0; + this._ghostOffY = 0; + this._autoScrollRAF = null; + this._lastPointerY = 0; + this._lastPointerX = 0; + this._SCROLL_ZONE = 72; + this._SCROLL_SPEED = 0.4; + + // Bound handlers + this._onMove = this._onMove.bind(this); + this._onUp = this._onUp.bind(this); + + this.shadowRoot.innerHTML = ` + + + + +
    + + +
    +
    +
    +
    +
      +
      + +
      + `; + + // DOM refs + this._dialog = this.shadowRoot.querySelector("dialog"); + this._listWrap = this.shadowRoot.getElementById("listWrap"); + this._dragList = this.shadowRoot.getElementById("dragList"); + this._pulseTop = this.shadowRoot.getElementById("pulseTop"); + this._pulseBot = this.shadowRoot.getElementById("pulseBot"); + this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn"); + this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn"); + this._cancelBtn = this.shadowRoot.getElementById("cancelBtn"); + this._applyBtn = this.shadowRoot.getElementById("applyBtn"); + this._countEl = this.shadowRoot.getElementById("selectedCount"); + this._footerEl = this.shadowRoot.getElementById("footerInfo"); + + // Event listeners + this._selectAllBtn.addEventListener("click", () => this._selectAll()); + this._deselectAllBtn.addEventListener("click", () => this._deselectAll()); + this._cancelBtn.addEventListener("click", () => this._close()); + this._applyBtn.addEventListener("click", () => this._apply()); + this._dialog.addEventListener("click", (e) => { + if (e.target === this._dialog) this._close(); + }); + this._dialog.addEventListener("cancel", (e) => { + e.preventDefault(); + this._close(); + }); + } + + /** + * Open the column chooser dialog. + * @param {Object} opts + * @param {string[]} opts.columns - All available column names, in display order. + * @param {string[]} opts.selected - Column names that should be pre-checked. + * @param {function(string[]): void} opts.onApply - Called with the selected columns in order when Apply is clicked. + */ + open({ columns, selected = [], onApply }) { + this._items = [...columns]; + this._checked = new Set(selected); + this._onApply = onApply || null; + + // Save state for cancel/restore + this._savedItems = [...this._items]; + this._savedChecked = new Set(this._checked); + + this._render(); + this._dialog.showModal(); + } + + // ── Internal methods ── + + _close() { + this._items = this._savedItems ? [...this._savedItems] : this._items; + this._checked = this._savedChecked + ? new Set(this._savedChecked) + : this._checked; + this._dialog.close(); + } + + _selectAll() { + this._items.forEach((col) => this._checked.add(col)); + this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + cb.checked = true; + }); + this._updateCounts(); + } + + _deselectAll() { + this._checked.clear(); + this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + cb.checked = false; + }); + this._updateCounts(); + } + + _apply() { + const selected = this._items.filter((col) => this._checked.has(col)); + this._dialog.close(); + if (this._onApply) { + this._onApply(selected); + } + } + + _render() { + this._dragList.innerHTML = ""; + this._items.forEach((col, i) => { + const li = document.createElement("li"); + li.className = "drag-item"; + li.dataset.idx = i; + li.innerHTML = ` + + + + + + + + + + + +
      + `; + + li.querySelector("input").addEventListener("change", (e) => { + e.target.checked ? this._checked.add(col) : this._checked.delete(col); + this._updateCounts(); + }); + + li.querySelector(".drag-handle").addEventListener("pointerdown", (e) => + this._startDrag(e, i), + ); + this._dragList.appendChild(li); + }); + + this._updateCounts(); + } + + _updateCounts() { + const n = this._checked.size; + this._countEl.textContent = `${n} of ${this._items.length} selected`; + this._footerEl.textContent = `${this._items.length} columns`; + } + + // ── Drag engine ── + + _startDrag(e, idx) { + e.preventDefault(); + this._dragSrcIdx = idx; + + const srcEl = this._dragList.children[idx]; + const rect = srcEl.getBoundingClientRect(); + + this._ghostOffX = e.clientX - rect.left; + this._ghostOffY = e.clientY - rect.top; + + // Build ghost inside shadow DOM + this._ghost = document.createElement("div"); + this._ghost.className = "drag-ghost"; + this._ghost.style.width = rect.width + "px"; + this._ghost.style.height = rect.height + "px"; + this._ghost.innerHTML = srcEl.innerHTML; + this._ghost.querySelector(".drop-indicator")?.remove(); + const h = this._ghost.querySelector(".drag-handle"); + if (h) h.style.color = "var(--accent)"; + this.shadowRoot.appendChild(this._ghost); + + srcEl.classList.add("is-dragging"); + this._positionGhost(e.clientX, e.clientY); + + document.addEventListener("pointermove", this._onMove); + document.addEventListener("pointerup", this._onUp); + document.addEventListener("pointercancel", this._onUp); + } + + _positionGhost(cx, cy) { + this._ghost.style.left = cx - this._ghostOffX + "px"; + this._ghost.style.top = cy - this._ghostOffY + "px"; + } + + _onMove(e) { + this._lastPointerX = e.clientX; + this._lastPointerY = e.clientY; + this._positionGhost(e.clientX, e.clientY); + this._updateDropTarget(e.clientY); + this._updateAutoScroll(e.clientY); + } + + _onUp() { + document.removeEventListener("pointermove", this._onMove); + document.removeEventListener("pointerup", this._onUp); + document.removeEventListener("pointercancel", this._onUp); + + this._stopAutoScroll(); + + const noMove = + this._dropTargetIdx === null || this._dropTargetIdx === this._dragSrcIdx; + this._clearDropIndicators(); + + let dest = null; + if (!noMove) { + const moved = this._items.splice(this._dragSrcIdx, 1)[0]; + dest = this._dropTargetIdx; + if (this._dropPosition === "after") dest++; + if (dest > this._dragSrcIdx) dest--; + this._items.splice(dest, 0, moved); + } + + this._dragSrcIdx = null; + this._dropTargetIdx = null; + this._dropPosition = null; + + const g = this._ghost; + this._ghost = null; + + if (noMove) { + if (g) g.remove(); + this._render(); + return; + } + + this._render(); + + if (g && dest !== null) { + const landedEl = this._dragList.children[dest]; + if (landedEl) { + landedEl.style.opacity = "0"; + const r = landedEl.getBoundingClientRect(); + g.getBoundingClientRect(); + g.style.transition = + "left 0.15s cubic-bezier(0.22, 1, 0.36, 1), top 0.15s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.15s, opacity 0.1s 0.1s"; + g.style.left = r.left + "px"; + g.style.top = r.top + "px"; + g.style.boxShadow = "0 1px 4px rgba(0,0,0,0.08)"; + g.style.opacity = "0"; + setTimeout(() => { + g.remove(); + if (landedEl) landedEl.style.opacity = ""; + }, 160); + } else { + g.remove(); + } + } else if (g) { + g.remove(); + } + } + + _updateDropTarget(clientY) { + this._clearDropIndicators(); + const listItems = [ + ...this._dragList.querySelectorAll(".drag-item:not(.is-dragging)"), + ]; + if (!listItems.length) return; + + let best = null, + bestDist = Infinity; + listItems.forEach((li) => { + const r = li.getBoundingClientRect(); + const mid = r.top + r.height / 2; + const dist = Math.abs(clientY - mid); + if (dist < bestDist) { + bestDist = dist; + best = li; + } + }); + + if (!best) return; + const r = best.getBoundingClientRect(); + const mid = r.top + r.height / 2; + const above = clientY < mid; + const indic = best.querySelector(".drop-indicator"); + + this._dropTargetIdx = parseInt(best.dataset.idx); + this._dropPosition = above ? "before" : "after"; + + if (indic) { + indic.className = "drop-indicator " + (above ? "top" : "bottom"); + } + } + + _clearDropIndicators() { + this._dragList.querySelectorAll(".drop-indicator").forEach((el) => { + el.className = "drop-indicator"; + }); + } + + _updateAutoScroll(clientY) { + const rect = this._listWrap.getBoundingClientRect(); + const relY = clientY - rect.top; + const distTop = relY; + const distBot = rect.height - relY; + + const inTop = distTop < this._SCROLL_ZONE && distTop >= 0; + const inBot = distBot < this._SCROLL_ZONE && distBot >= 0; + + this._pulseTop.classList.toggle("active", inTop); + this._pulseBot.classList.toggle("active", inBot); + + if ((inTop || inBot) && !this._autoScrollRAF) { + let lastTime = null; + const loop = (ts) => { + if (!this._ghost) { + this._stopAutoScroll(); + return; + } + if (lastTime !== null) { + const dt = ts - lastTime; + const rect2 = this._listWrap.getBoundingClientRect(); + const relY2 = this._lastPointerY - rect2.top; + const dTop = relY2; + const dBot = rect2.height - relY2; + + if (dTop < this._SCROLL_ZONE && dTop >= 0) { + const factor = 1 - dTop / this._SCROLL_ZONE; + this._listWrap.scrollTop -= this._SCROLL_SPEED * dt * factor * 2.5; + } else if (dBot < this._SCROLL_ZONE && dBot >= 0) { + const factor = 1 - dBot / this._SCROLL_ZONE; + this._listWrap.scrollTop += this._SCROLL_SPEED * dt * factor * 2.5; + } else { + this._stopAutoScroll(); + return; + } + this._updateDropTarget(this._lastPointerY); + } + lastTime = ts; + this._autoScrollRAF = requestAnimationFrame(loop); + }; + this._autoScrollRAF = requestAnimationFrame(loop); + } + + if (!inTop && !inBot) this._stopAutoScroll(); + } + + _stopAutoScroll() { + if (this._autoScrollRAF) { + cancelAnimationFrame(this._autoScrollRAF); + this._autoScrollRAF = null; + } + this._pulseTop.classList.remove("active"); + this._pulseBot.classList.remove("active"); + } +} + +customElements.define("column-chooser", ColumnChooser); diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 48de5c4f..95e7dfc5 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -19,19 +19,20 @@ class NavigationSearch extends HTMLElement { dialog { border: none; - border-radius: 0.75rem; + border-radius: var(--modal-border-radius, 0.75rem); padding: 0; max-width: 90vw; width: 600px; max-height: 80vh; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); - animation: slideIn 0.2s ease-out; + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: slideIn var(--modal-animation-duration, 0.2s) ease-out; } dialog::backdrop { - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - animation: fadeIn 0.2s ease-out; + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out; } @keyframes slideIn { diff --git a/datasette/static/table.js b/datasette/static/table.js index 0caeeb91..c26dda5a 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -4,6 +4,7 @@ var DROPDOWN_HTML = `
    • Sort ascending
    • Sort descending
    • Facet by this
    • +
    • Choose columns
    • Hide this column
    • Show all columns
    • Show not-blank rows
    • @@ -104,6 +105,7 @@ const initDatasetteTable = function (manager) { var notBlank = menu.querySelector("a.dropdown-not-blank"); var hideColumn = menu.querySelector("a.dropdown-hide-column"); var showAllColumns = menu.querySelector("a.dropdown-show-all-columns"); + var selectColumns = menu.querySelector("a.dropdown-choose-columns"); if (params.get("_sort") == column) { sort.parentNode.style.display = "none"; } else { @@ -129,6 +131,18 @@ const initDatasetteTable = function (manager) { } else { hideColumn.parentNode.style.display = "none"; } + /* Choose columns - show if web component exists */ + var columnChooser = document.querySelector("column-chooser"); + if (columnChooser && window._columnChooserData) { + selectColumns.parentNode.style.display = "block"; + selectColumns.addEventListener("click", function (ev) { + ev.preventDefault(); + closeMenu(); + openColumnChooser(); + }); + } else { + selectColumns.parentNode.style.display = "none"; + } /* Only show "Facet by this" if it's not the first column, not selected, not a single PK and the Datasette allow_facet setting is True */ var displayedFacets = Array.from( @@ -330,6 +344,49 @@ function initAutocompleteForFilterValues(manager) { }); } +/** Open the column-chooser web component */ +function openColumnChooser() { + var chooser = document.querySelector("column-chooser"); + var data = window._columnChooserData; + if (!chooser || !data) return; + + var nonPkColumns = data.allColumns.filter(function (col) { + return data.primaryKeys.indexOf(col) === -1; + }); + var selected = data.selectedColumns.filter(function (col) { + return data.primaryKeys.indexOf(col) === -1; + }); + + chooser.open({ + columns: nonPkColumns, + selected: selected, + onApply: function (cols) { + var params = new URLSearchParams(location.search); + params.delete("_col"); + params.delete("_nocol"); + params.delete("_next"); + + if (cols.length === nonPkColumns.length) { + // Check if order matches original - if so, no params needed + var orderMatches = cols.every(function (col, i) { + return col === nonPkColumns[i]; + }); + if (!orderMatches) { + cols.forEach(function (col) { + params.append("_col", col); + }); + } + } else { + cols.forEach(function (col) { + params.append("_col", col); + }); + } + var qs = params.toString(); + location.href = qs ? "?" + qs : location.pathname; + } + }); +} + // Ensures Table UI is initialized only after the Manager is ready. document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; @@ -340,4 +397,5 @@ document.addEventListener("datasette_init", function (evt) { // Other UI functions with interactive JS needs addButtonsToFilterRows(manager); initAutocompleteForFilterValues(manager); + }); diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 25ff31ef..9c930918 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -4,6 +4,7 @@ {% block extra_head %} {{- super() -}} + - +
      +

      Jump to

      +

      Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

      +
      +
      -
      +
      ↑ ↓ Navigate Enter Select @@ -253,6 +316,7 @@ class NavigationSearch extends HTMLElement { setupEventListeners() { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); + const closeButton = this.shadowRoot.querySelector(".close-search"); const resultsContainer = this.shadowRoot.querySelector(".results-container"); @@ -268,8 +332,10 @@ class NavigationSearch extends HTMLElement { const trigger = e.target.closest("[data-navigation-search-open]"); if (trigger) { e.preventDefault(); - trigger.closest("details")?.removeAttribute("open"); - this.openMenu(); + const details = trigger.closest("details"); + const restoreTarget = details?.querySelector("summary") || trigger; + details?.removeAttribute("open"); + this.openMenu(restoreTarget); } }); @@ -294,6 +360,10 @@ class NavigationSearch extends HTMLElement { } }); + closeButton.addEventListener("click", () => { + this.closeMenu(); + }); + // Click on result item resultsContainer.addEventListener("click", (e) => { const clearRecent = e.target.closest("[data-clear-recent-items]"); @@ -317,6 +387,15 @@ class NavigationSearch extends HTMLElement { } }); + dialog.addEventListener("cancel", (e) => { + e.preventDefault(); + this.closeMenu(); + }); + + dialog.addEventListener("close", () => { + this.onMenuClosed(); + }); + // Initial load this.loadInitialData(); } @@ -331,6 +410,106 @@ class NavigationSearch extends HTMLElement { ); } + setElementAttribute(element, name, value) { + if (!element) { + return; + } + if (typeof element.setAttribute === "function") { + element.setAttribute(name, value); + } else { + element[name] = String(value); + } + } + + removeElementAttribute(element, name) { + if (!element) { + return; + } + if (typeof element.removeAttribute === "function") { + element.removeAttribute(name); + } else { + delete element[name]; + } + } + + focusRestoreTarget(trigger) { + if (trigger && typeof trigger.focus === "function") { + return trigger; + } + if ( + document.activeElement && + typeof document.activeElement.focus === "function" + ) { + return document.activeElement; + } + return null; + } + + setNavigationTriggersExpanded(expanded) { + if (typeof document.querySelectorAll !== "function") { + return; + } + document + .querySelectorAll("[data-navigation-search-open]") + .forEach((trigger) => { + this.setElementAttribute( + trigger, + "aria-expanded", + expanded ? "true" : "false", + ); + }); + } + + resultOptionId(index) { + return `${this.listboxId}-option-${index}`; + } + + updateComboboxState() { + const dialog = this.shadowRoot.querySelector("dialog"); + const input = this.shadowRoot.querySelector(".search-input"); + const matches = this.renderedMatches || []; + this.setElementAttribute( + input, + "aria-expanded", + dialog && dialog.open && matches.length > 0 ? "true" : "false", + ); + + if ( + dialog && + dialog.open && + this.selectedIndex >= 0 && + this.selectedIndex < matches.length + ) { + this.setElementAttribute( + input, + "aria-activedescendant", + this.resultOptionId(this.selectedIndex), + ); + } else { + this.removeElementAttribute(input, "aria-activedescendant"); + } + } + + setStatus(message) { + const status = this.shadowRoot.querySelector(`#${this.statusId}`); + if (status) { + status.textContent = message || ""; + } + } + + resultsStatus(count, truncated) { + if (truncated) { + return "More than 100 results. Keep typing to narrow the list."; + } + if (count === 0) { + return "No results found."; + } + if (count === 1) { + return "1 result."; + } + return `${count} results.`; + } + loadInitialData() { const itemsAttr = this.getAttribute("items"); if (itemsAttr) { @@ -347,6 +526,11 @@ class NavigationSearch extends HTMLElement { handleSearch(query) { clearTimeout(this.debounceTimer); + if (query.trim()) { + this.setStatus("Searching..."); + } else { + this.setStatus(""); + } this.debounceTimer = setTimeout(() => { const url = this.getAttribute("url"); @@ -369,10 +553,16 @@ class NavigationSearch extends HTMLElement { this.matches = data.matches || []; this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); + if (query.trim()) { + this.setStatus(this.resultsStatus(this.matches.length, data.truncated)); + } else { + this.setStatus(""); + } } catch (e) { console.error("Failed to fetch search results:", e); this.matches = []; this.renderResults(); + this.setStatus("Search failed."); } } @@ -390,6 +580,11 @@ class NavigationSearch extends HTMLElement { } this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); + if (query.trim()) { + this.setStatus(this.resultsStatus(this.matches.length, false)); + } else { + this.setStatus(""); + } } recentItemsStorageKey() { @@ -466,6 +661,7 @@ class NavigationSearch extends HTMLElement { localStorage.setItem(this.recentItemsStorageKey(), "[]"); } this.renderResults(); + this.setStatus("Recent items cleared."); } jumpSections() { @@ -526,6 +722,7 @@ class NavigationSearch extends HTMLElement { : ""; return `
      `; if (renderedMatches.length) { if ( @@ -568,33 +766,43 @@ class NavigationSearch extends HTMLElement { if (renderedMatches.length === 0) { if (startBlock) { - container.innerHTML = startBlock; + container.innerHTML = startBlock + emptyListbox; this.renderJumpSections(container, jumpSections); } else if (showStartContent) { - container.innerHTML = ""; + container.innerHTML = emptyListbox; } else { const message = input.value.trim() ? "No results found" : "Start typing to search..."; - container.innerHTML = `
      ${message}
      `; + container.innerHTML = `${emptyListbox}
      ${message}
      `; } + this.updateComboboxState(); return; } - const recentHtml = recentItems.length - ? `
      Recent
      ${recentItems + const recentHeading = recentItems.length + ? `
      Recent
      ` + : ""; + const recentGroup = recentItems.length + ? `
      ${recentItems .map((match, index) => this.resultItemHtml(match, index)) - .join( - "", - )}
      ` + .join("")}
      ` + : ""; + const recentActions = recentItems.length + ? `
      ` : ""; const defaultHtml = defaultMatches .map((match, index) => this.resultItemHtml(match, recentItems.length + index), ) .join(""); - container.innerHTML = startBlock + recentHtml + defaultHtml; + container.innerHTML = + startBlock + + recentHeading + + `
      ${recentGroup}${defaultHtml}
      ` + + recentActions; this.renderJumpSections(container, jumpSections); + this.updateComboboxState(); // Scroll selected item into view if (this.selectedIndex >= 0) { @@ -641,17 +849,20 @@ class NavigationSearch extends HTMLElement { // Navigate to URL window.location.href = match.url; - this.closeMenu(); + this.closeMenu({ restoreFocus: false }); } } - openMenu() { + openMenu(trigger) { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); + this.restoreFocusTarget = this.focusRestoreTarget(trigger); + this.shouldRestoreFocus = true; if (!dialog.open) { dialog.showModal(); } + this.setNavigationTriggersExpanded(true); input.value = ""; input.focus(); @@ -659,11 +870,33 @@ class NavigationSearch extends HTMLElement { this.matches = []; this.selectedIndex = -1; this.renderResults(); + this.setStatus(""); } - closeMenu() { + closeMenu(options = {}) { const dialog = this.shadowRoot.querySelector("dialog"); - dialog.close(); + this.shouldRestoreFocus = options.restoreFocus !== false; + if (dialog.open) { + dialog.close(); + } else { + this.onMenuClosed(); + } + } + + onMenuClosed() { + const input = this.shadowRoot.querySelector(".search-input"); + this.setElementAttribute(input, "aria-expanded", "false"); + this.removeElementAttribute(input, "aria-activedescendant"); + this.setNavigationTriggersExpanded(false); + this.setStatus(""); + if ( + this.shouldRestoreFocus && + this.restoreFocusTarget && + typeof this.restoreFocusTarget.focus === "function" + ) { + this.restoreFocusTarget.focus(); + } + this.restoreFocusTarget = null; } escapeHtml(text) { diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 819715ba..e1767deb 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -30,7 +30,7 @@ + {% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index de02cd0f..3c660bc7 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -487,9 +487,9 @@ def _as_optional_bool(value, name): raise QueryValidationError("{} must be 0 or 1".format(name)) -def _query_list_limit(value): +def _query_list_limit(value, default=50): if value in (None, ""): - return 50 + return default try: return min(max(1, int(value)), 1000) except ValueError as ex: @@ -1136,7 +1136,10 @@ class QueryListView(BaseView): database = await self.database_name(request) format_ = request.url_vars.get("format") or "html" try: - limit = _query_list_limit(request.args.get("_size")) + limit = _query_list_limit( + request.args.get("_size"), + default=20 if format_ == "html" else 50, + ) is_write = _as_optional_bool(request.args.get("is_write"), "is_write") is_published = _as_optional_bool( request.args.get("is_published"), "is_published" @@ -1175,6 +1178,9 @@ class QueryListView(BaseView): data = { "ok": True, "database": database, + "database_color": ( + self.ds.get_database(database).color if database is not None else None + ), "queries": page["queries"], "next": page["next"], "next_url": next_url, diff --git a/tests/test_queries.py b/tests/test_queries.py index c31d7205..b7416ac7 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -451,12 +451,34 @@ async def test_query_list_search_filter_and_html(): assert html_response.status_code == 200 assert "Demo query 02" in html_response.text assert "Demo query 01" not in html_response.text + assert 'class="query-list-results"' in html_response.text + assert "Mode" in html_response.text + assert 'type="radio" name="is_published" value="1"' in html_response.text assert json_response.json()["queries"][0]["name"] == "demo_query_02" assert [query["name"] for query in filtered_response.json()["queries"]] == [ "private_query" ] +@pytest.mark.asyncio +async def test_query_list_html_defaults_to_twenty_and_shows_pagination(): + ds = Datasette(memory=True) + ds.root_enabled = True + ds.add_memory_database("query_list_html_pagination", name="data") + await ds.invoke_startup() + await add_numbered_queries(ds, "data", 25) + + response = await ds.client.get("/data/-/queries", actor={"id": "root"}) + json_response = await ds.client.get("/data/-/queries.json", actor={"id": "root"}) + + assert response.status_code == 200 + assert response.text.count('aria-label="Query pagination"') == 1 + assert "Demo query 20" in response.text + assert "Demo query 21" not in response.text + assert 'href="/data/-/queries?_next=' in response.text + assert len(json_response.json()["queries"]) == 25 + + @pytest.mark.asyncio async def test_global_query_list_api_and_html(): ds = Datasette(memory=True) @@ -519,7 +541,8 @@ async def test_global_query_list_api_and_html(): ("beta", "beta_first"), ] assert html_response.status_code == 200 - assert 'href="/beta">beta:' in html_response.text + assert 'Database' in html_response.text + assert 'class="query-list-database" href="/beta">beta' in html_response.text assert "Beta first" in html_response.text assert "Alpha first" not in html_response.text From f1dd86ebfb01644fead19f9f007b9b76f863d72e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 25 May 2026 14:05:26 -0700 Subject: [PATCH 166/203] Tweak URL designs of new endpoints --- datasette/app.py | 6 +++--- datasette/templates/database.html | 2 +- datasette/templates/execute_write.html | 2 +- datasette/templates/query.html | 2 +- datasette/templates/query_create.html | 2 +- docs/json_api.rst | 6 +++--- queries-plan.md | 4 ++-- tests/test_html.py | 4 ++-- tests/test_queries.py | 22 +++++++++++----------- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 90e41521..232aa0cf 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2745,11 +2745,11 @@ class Datasette: ) add_route( QueryInsertView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/-/insert$", + r"/(?P[^\/\.]+)/-/queries/insert$", ) add_route( ExecuteWriteAnalyzeView.as_view(self), - r"/(?P[^\/\.]+)/-/execute-write/-/analyze$", + r"/(?P[^\/\.]+)/-/execute-write/analyze$", ) add_route( ExecuteWriteView.as_view(self), @@ -2761,7 +2761,7 @@ class Datasette: ) add_route( QueryParametersView.as_view(self), - r"/(?P[^\/\.]+)/-/query/-/parameters$", + r"/(?P[^\/\.]+)/-/query/parameters$", ) add_route( wrap_view(QueryView, self), diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 0c9ec94c..62f9c620 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -26,7 +26,7 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} -
      +

      Custom SQL query

      {% set parameter_names = [] %} diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html index 9b522f66..46f58c3b 100644 --- a/datasette/templates/execute_write.html +++ b/datasette/templates/execute_write.html @@ -95,7 +95,7 @@

      {{ execution_message }}{% for link in execution_links %} {{ link.label }}{% endfor %}

      {% endif %} - + {% if write_template_tables %}
      diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 3bcc7178..f74d21f1 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -37,7 +37,7 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} - +

      Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} ({{ show_hide_text }}) {% endif %}

      diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index fb2599d2..3c027def 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -17,7 +17,7 @@

      Create query

      - +


      diff --git a/docs/json_api.rst b/docs/json_api.rst index 91ed5306..dd54c459 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -525,7 +525,7 @@ Creating saved queries in the UI Creating saved queries ~~~~~~~~~~~~~~~~~~~~~~ -``POST //-/queries/-/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database. +``POST //-/queries/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database. .. _QueryParametersView: .. _ExecuteWriteView: @@ -534,13 +534,13 @@ Creating saved queries Executing write SQL ~~~~~~~~~~~~~~~~~~~ -``GET //-/query/-/parameters?sql=...`` returns the named parameters used by a SQL query. This requires ``execute-sql`` for the database. +``GET //-/query/parameters?sql=...`` returns the named parameters used by a SQL query. This requires ``execute-sql`` for the database. ``GET //-/execute-write`` displays a form for executing writable SQL. A ``?sql=`` query string pre-populates the form without executing it. ``POST //-/execute-write`` executes writable SQL. This requires ``execute-write-sql`` for the database plus the relevant table-level write permissions. -``GET //-/execute-write/-/analyze?sql=...`` returns the derived parameters plus the write operations that SQL would need in order to execute. +``GET //-/execute-write/analyze?sql=...`` returns the derived parameters plus the write operations that SQL would need in order to execute. .. _QueryDefinitionView: diff --git a/queries-plan.md b/queries-plan.md index a708e887..72427df2 100644 --- a/queries-plan.md +++ b/queries-plan.md @@ -211,7 +211,7 @@ JSON endpoints should follow Datasette's existing write API style: use `POST` pl Endpoints: - `GET /-/queries` and `GET /{database}/-/queries` show searchable HTML query browsers. `GET /-/queries.json` lists query definitions across every database the actor can view; `GET /{database}/-/queries.json` scopes that list to one database. Both JSON endpoints use cursor pagination with `_next` and `_size`. -- `POST /{database}/-/queries/-/insert` creates a query. +- `POST /{database}/-/queries/insert` creates a query. - `GET /{database}/{query}/-/definition` returns one query definition without executing it. - `POST /{database}/{query}/-/update` updates one query. - `POST /{database}/{query}/-/delete` deletes one query. @@ -388,7 +388,7 @@ The read methods should reconstruct the existing dictionary shape used by query On `/{database}/-/query`, if the actor has both `execute-sql` and `insert-query`, show a save control for valid read-only SQL. That page already executes read-only arbitrary SQL, so the first UI can stay read-only even though the JSON API can accept writable SQL after `Database.analyze_sql()` validation. -The save form should call `POST /{database}/-/queries/-/insert` and default to `is_published=false`. +The save form should call `POST /{database}/-/queries/insert` and default to `is_published=false`. If the actor also has `publish-query`, include a publish control. The UI copy should make it clear that publishing allows people without arbitrary SQL permission to run this query. diff --git a/tests/test_html.py b/tests/test_html.py index b49391a6..8cda6dba 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -329,7 +329,7 @@ async def test_query_parameter_form_fields(ds_client): ' ' in response.text ) - assert 'data-parameters-url="/fixtures/-/query/-/parameters"' in response.text + assert 'data-parameters-url="/fixtures/-/query/parameters"' in response.text assert 'id="sql-parameters-section"' in response.text assert "setupSqlParameterRefresh" in response.text response2 = await ds_client.get("/fixtures/-/query?sql=select+:name&name=hello") @@ -344,7 +344,7 @@ async def test_query_parameter_form_fields(ds_client): async def test_database_page_sql_parameter_refresh_markup(ds_client): response = await ds_client.get("/fixtures") assert response.status_code == 200 - assert 'data-parameters-url="/fixtures/-/query/-/parameters"' in response.text + assert 'data-parameters-url="/fixtures/-/query/parameters"' in response.text assert 'id="sql-parameters-section"' in response.text assert "setupSqlParameterRefresh" in response.text diff --git a/tests/test_queries.py b/tests/test_queries.py index b7416ac7..57920584 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -356,7 +356,7 @@ async def test_query_insert_api_creates_read_only_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={ "query": { @@ -568,7 +568,7 @@ async def test_query_insert_api_publish_requires_publish_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "writer"}, json={"query": {"name": "public", "sql": "select 1", "is_published": True}}, ) @@ -586,7 +586,7 @@ async def test_query_insert_api_creates_writable_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={ "query": { @@ -603,7 +603,7 @@ async def test_query_insert_api_creates_writable_query(): assert query["parameters"] == ["name"] bad_response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={ "query": { @@ -671,7 +671,7 @@ async def test_query_insert_api_rejects_magic_parameters(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={"query": {"name": "magic", "sql": "select :_actor_id"}}, ) @@ -742,7 +742,7 @@ async def test_execute_write_get_prepopulates_without_executing(): assert 'data-sql-template="insert"' in response.text assert 'data-sql-template="update"' in response.text assert 'data-sql-template="delete"' in response.text - assert 'data-analyze-url="/data/-/execute-write/-/analyze"' in response.text + assert 'data-analyze-url="/data/-/execute-write/analyze"' in response.text assert 'addEventListener("paste"' in response.text assert "setupSqlParameterRefresh" in response.text assert '' in response.text @@ -771,12 +771,12 @@ async def test_execute_write_analyze_endpoint_uses_sql_only(): await ds.invoke_startup() response = await ds.client.get( - "/data/-/execute-write/-/analyze", + "/data/-/execute-write/analyze", actor={"id": "root"}, params={"sql": "insert into dogs (name) values (:name)"}, ) read_only_response = await ds.client.get( - "/data/-/execute-write/-/analyze", + "/data/-/execute-write/analyze", actor={"id": "root"}, params={"sql": "select * from dogs where name = :name"}, ) @@ -818,19 +818,19 @@ async def test_query_parameters_endpoint_uses_get_sql_only(): await ds.invoke_startup() response = await ds.client.get( - "/data/-/query/-/parameters", + "/data/-/query/parameters", actor={"id": "root"}, params={ "sql": "select * from dogs where name = :name and id = :id", }, ) permission_denied_response = await ds.client.get( - "/data/-/query/-/parameters", + "/data/-/query/parameters", actor={"id": "not-root"}, params={"sql": "select * from dogs where name = :name"}, ) magic_parameter_response = await ds.client.get( - "/data/-/query/-/parameters", + "/data/-/query/parameters", actor={"id": "root"}, params={"sql": "select :_actor_id"}, ) From 4a1a4d7807fb99203b9053b6d270b265df61f0af Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 11:59:49 -0700 Subject: [PATCH 167/203] Query is_trusted and is_private properties Refs https://github.com/simonw/datasette/issues/2735#issuecomment-4547270516 Diff explanation: https://gist.github.com/simonw/1e4de6c4b041a51968eb273ee96dec1f --- datasette/app.py | 39 ++-- datasette/default_actions.py | 7 - datasette/default_permissions/defaults.py | 100 +++++---- datasette/templates/query_create.html | 4 +- datasette/templates/query_list.html | 65 +++++- datasette/utils/internal_db.py | 3 +- datasette/views/database.py | 79 ++++--- docs/authentication.rst | 10 - docs/internals.rst | 3 +- queries-plan.md | 84 ++++---- tests/test_queries.py | 245 ++++++++++++++++++---- 11 files changed, 421 insertions(+), 218 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 232aa0cf..3329ee7e 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -618,7 +618,8 @@ class Datasette: fragment=query_config.get("fragment"), parameters=query_config.get("params"), is_write=bool(query_config.get("write")), - is_published=bool(query_config.get("is_published")), + is_private=bool(query_config.get("is_private")), + is_trusted=bool(query_config.get("is_trusted", True)), source="config", on_success_message=query_config.get("on_success_message"), on_success_message_sql=query_config.get("on_success_message_sql"), @@ -1084,7 +1085,8 @@ class Datasette: "parameters": parameters, "is_write": is_write, "write": is_write, - "is_published": bool(row["is_published"]), + "is_private": bool(row["is_private"]), + "is_trusted": bool(row["is_trusted"]), "source": row["source"], "owner_id": row["owner_id"], "on_success_message": options.get("on_success_message"), @@ -1119,7 +1121,8 @@ class Datasette: fragment=None, parameters=None, is_write=False, - is_published=False, + is_private=False, + is_trusted=False, source="plugin", owner_id=None, on_success_message=None, @@ -1144,8 +1147,8 @@ class Datasette: sql_statement = """ INSERT INTO queries ( database_name, name, sql, title, description, description_html, - options, parameters, is_write, is_published, source, owner_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + options, parameters, is_write, is_private, is_trusted, source, owner_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ if replace: sql_statement += """ @@ -1157,7 +1160,8 @@ class Datasette: options = excluded.options, parameters = excluded.parameters, is_write = excluded.is_write, - is_published = excluded.is_published, + is_private = excluded.is_private, + is_trusted = excluded.is_trusted, source = excluded.source, owner_id = excluded.owner_id, updated_at = CURRENT_TIMESTAMP @@ -1174,7 +1178,8 @@ class Datasette: options_json, parameters_json, int(bool(is_write)), - int(bool(is_published)), + int(bool(is_private)), + int(bool(is_trusted)), source, owner_id, ], @@ -1193,7 +1198,8 @@ class Datasette: fragment=UNCHANGED, parameters=UNCHANGED, is_write=UNCHANGED, - is_published=UNCHANGED, + is_private=UNCHANGED, + is_trusted=UNCHANGED, source=UNCHANGED, owner_id=UNCHANGED, on_success_message=UNCHANGED, @@ -1209,7 +1215,8 @@ class Datasette: "description_html": description_html, "parameters": parameters, "is_write": is_write, - "is_published": is_published, + "is_private": is_private, + "is_trusted": is_trusted, "source": source, "owner_id": owner_id, } @@ -1227,7 +1234,7 @@ class Datasette: for field, value in fields.items(): if value is UNCHANGED: continue - if field in {"is_write", "is_published"}: + if field in {"is_write", "is_private", "is_trusted"}: value = int(bool(value)) elif field == "parameters": value = json.dumps(list(value or [])) @@ -1300,7 +1307,8 @@ class Datasette: cursor=None, q=None, is_write=None, - is_published=None, + is_private=None, + is_trusted=None, source=None, owner_id=None, include_private=False, @@ -1372,9 +1380,12 @@ class Datasette: if is_write is not None: where_clauses.append("q.is_write = :query_is_write") params["query_is_write"] = int(bool(is_write)) - if is_published is not None: - where_clauses.append("q.is_published = :query_is_published") - params["query_is_published"] = int(bool(is_published)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) if source is not None: where_clauses.append("q.source = :query_source") params["query_source"] = source diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 6787b80e..6a1f77b8 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -68,13 +68,6 @@ def register_actions(): resource_class=DatabaseResource, also_requires="execute-sql", ), - Action( - name="publish-query", - abbr="pq", - description="Publish saved queries for actors without execute-sql", - resource_class=DatabaseResource, - also_requires="insert-query", - ), # Table-level actions (child-level) Action( name="view-table", diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 58deea01..dfd8d3e9 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -26,6 +26,32 @@ DEFAULT_ALLOW_ACTIONS = frozenset( ) +def _configured_query_restriction_selects(datasette: "Datasette") -> tuple[list[str], dict]: + selects = [] + params = {} + for index, (database_name, db_config) in enumerate( + ((datasette.config or {}).get("databases") or {}).items() + ): + for query_name, query_config in (db_config.get("queries") or {}).items(): + if isinstance(query_config, dict) and query_config.get("is_private"): + continue + parent_param = f"query_config_parent_{index}_{len(selects)}" + child_param = f"query_config_child_{index}_{len(selects)}" + selects.append( + f""" + SELECT :{parent_param} AS parent, :{child_param} AS child + WHERE NOT EXISTS ( + SELECT 1 FROM queries + WHERE database_name = :{parent_param} + AND name = :{child_param} + ) + """ + ) + params[parent_param] = database_name + params[child_param] = query_name + return selects, params + + @hookimpl(specname="permission_resources_sql") async def default_allow_sql_check( datasette: "Datasette", @@ -93,61 +119,45 @@ async def default_query_permissions_sql( if action != "view-query": return None - execute_sql = await datasette.allowed_resources_sql( - action="execute-sql", actor=actor - ) - sql = execute_sql.sql - params = {} - for key, value in execute_sql.params.items(): - new_key = f"query_execute_sql_{key}" - sql = sql.replace(f":{key}", f":{new_key}") - params[new_key] = value - - trusted_writable_sql = "" + params = {"query_owner_id": actor_id} + rule_sqls = [] if not datasette.default_deny: - trusted_writable_sql = """ - UNION ALL + rule_sqls.append( + """ SELECT database_name AS parent, name AS child, 1 AS allow, - 'trusted writable query' AS reason + 'non-private query' AS reason FROM queries - WHERE is_write = 1 - AND source IN ('config', 'plugin') - """ + WHERE is_private = 0 + """ + ) - user_writable_sql = "" if actor_id is not None: - params["query_owner_id"] = actor_id - user_writable_sql = """ - UNION ALL + rule_sqls.append( + """ SELECT database_name AS parent, name AS child, 1 AS allow, 'query owner' AS reason FROM queries - WHERE is_write = 1 - AND source = 'user' - AND owner_id = :query_owner_id + WHERE owner_id = :query_owner_id + """ + ) + + config_restriction_selects, config_restriction_params = ( + _configured_query_restriction_selects(datasette) + ) + + restriction_sqls = [ """ + SELECT database_name AS parent, name AS child + FROM queries + WHERE is_private = 0 + OR owner_id = :query_owner_id + """ + ] + restriction_sqls.extend(config_restriction_selects) + params.update(config_restriction_params) return PermissionSQL( - sql=f""" - WITH execute_sql_allowed AS ( - {sql} - ) - SELECT database_name AS parent, name AS child, 1 AS allow, - 'published query' AS reason - FROM queries - WHERE is_write = 0 - AND is_published = 1 - UNION ALL - SELECT q.database_name AS parent, q.name AS child, 1 AS allow, - 'execute-sql allows query' AS reason - FROM queries q - JOIN execute_sql_allowed es - ON es.parent = q.database_name - AND es.child IS NULL - WHERE q.is_write = 0 - AND q.is_published = 0 - {trusted_writable_sql} - {user_writable_sql} - """, + sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, + restriction_sql="\nUNION ALL\n".join(restriction_sqls), params=params, ) diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index 3c027def..686d971e 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -27,9 +27,7 @@

      - {% if can_publish %} -

      - {% endif %} +

      {% if sql and analysis_is_write %}

      Execute write SQL

      {% endif %} diff --git a/datasette/templates/query_list.html b/datasette/templates/query_list.html index dbd607ab..25259b3d 100644 --- a/datasette/templates/query_list.html +++ b/datasette/templates/query_list.html @@ -73,7 +73,7 @@ border-collapse: collapse; font-size: 0.9rem; margin: 0.25rem 0 1rem; - min-width: 36rem; + min-width: 42rem; width: 100%; } .query-list-results th, @@ -100,6 +100,16 @@ font-size: 0.78rem; margin: 0.15rem 0 0; } +.query-list-owner { + color: #39445a; + font-family: var(--font-monospace, monospace); + white-space: nowrap; +} +.query-list-flags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} .query-list-pill { background-color: #eef1f5; border: 1px solid #d7dde5; @@ -116,15 +126,36 @@ background-color: #fff4db; border-color: #e2b64e; } -.query-list-pill-published { +.query-list-pill-public { background-color: #e7f5ec; border-color: #9ecfab; color: #267a3e; } -.query-list-pill-unpublished { +.query-list-pill-private { background-color: #f7edf0; border-color: #dbb8c1; } +.query-list-pill-trusted { + background-color: #e7f5ec; + border-color: #9ecfab; + color: #267a3e; +} +.query-list-empty { + color: #6b7280; +} +.query-list-footnotes { + border-top: 1px solid #d7dde5; + color: #4f5b6d; + font-size: 0.82rem; + margin: 0.35rem 0 1rem; + padding-top: 0.55rem; +} +.query-list-footnotes p { + margin: 0.25rem 0; +} +.query-list-footnotes .query-list-pill { + margin-right: 0.35rem; +} .query-list-pagination a { border: 1px solid #007bff; border-radius: 0.25rem; @@ -177,10 +208,10 @@
      - Publication - - - + Visibility + + +
      @@ -191,8 +222,8 @@
      {% if show_database %}{% endif %} - - + + @@ -205,12 +236,24 @@ {{ query.title or query.name }}{% if query.private %} 🔒{% endif %} {% if query.description %}

      {{ query.description }}

      {% endif %} - - + + {% endfor %}
      DatabaseQueryModePublicationOwnerFlags
      {% if query.is_write %}Writable{% else %}Read-only{% endif %}{% if query.is_published %}Published{% else %}Unpublished{% endif %}{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}-{% endif %} + + {% if query.is_write %}Writable{% else %}Read-only{% endif %} + {% if query.is_private %}Private{% endif %} + {% if query.is_trusted %}Trusted{% endif %} + +
      + {% if show_private_note or show_trusted_note %} +
      + {% if show_private_note %}

      PrivateOnly the owning actor can view this query.

      {% endif %} + {% if show_trusted_note %}

      TrustedExecution skips the usual SQL and write permission checks after view-query allows access.

      {% endif %} +
      + {% endif %} {% else %}

      No queries found.

      {% endif %} diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index 9c693b0a..bf172667 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -123,7 +123,8 @@ async def initialize_metadata_tables(db): options TEXT NOT NULL DEFAULT '{}', parameters TEXT NOT NULL DEFAULT '[]', is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), + is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), source TEXT NOT NULL DEFAULT 'user', owner_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/datasette/views/database.py b/datasette/views/database.py index 3c660bc7..91e9c350 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -428,7 +428,7 @@ _query_fields = { "fragment", "parameters", "params", - "is_published", + "is_private", "on_success_message", "on_success_message_sql", "on_success_redirect", @@ -571,7 +571,7 @@ async def _check_query_name(db, name, *, existing=False): raise QueryValidationError("Query name conflicts with a table or view") -async def _analyze_user_query(datasette, db, sql, *, actor, is_published): +async def _analyze_user_query(datasette, db, sql, *, actor): if not sql or not isinstance(sql, str): raise QueryValidationError("SQL is required") derived = _derived_query_parameters(sql) @@ -583,8 +583,6 @@ async def _analyze_user_query(datasette, db, sql, *, actor, is_published): is_write = _analysis_is_write(analysis) if is_write: - if is_published: - raise QueryValidationError("Writable queries cannot be published") try: await datasette.ensure_query_write_permissions( db.name, sql, actor=actor, analysis=analysis @@ -680,6 +678,26 @@ async def _prepare_execute_write(datasette, db, sql, params, actor): return parameter_names, params, analysis +async def _ensure_stored_query_execution_permissions(datasette, db, query, actor): + if query.get("is_trusted"): + return + if query.get("write"): + await datasette.ensure_permission( + action="execute-write-sql", + resource=DatabaseResource(db.name), + actor=actor, + ) + await datasette.ensure_query_write_permissions( + db.name, query["sql"], actor=actor + ) + else: + await datasette.ensure_permission( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=actor, + ) + + async def _execute_write_analysis_data(datasette, db, sql, actor): parameter_names = [] analysis_rows = [] @@ -752,7 +770,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor): def _apply_query_data_types(data): typed = dict(data) - for key in ("hide_sql", "is_published"): + for key in ("hide_sql", "is_private"): if key in typed: typed[key] = _as_bool(typed[key]) return typed @@ -769,20 +787,12 @@ async def _prepare_query_create(datasette, request, db, data): if await datasette.get_query(db.name, name) is not None: raise QueryValidationError("Query already exists") - is_published = _as_bool(data.get("is_published")) is_write, derived, analysis = await _analyze_user_query( datasette, db, data.get("sql"), actor=request.actor, - is_published=is_published, ) - if is_published and not await datasette.allowed( - action="publish-query", - resource=DatabaseResource(db.name), - actor=request.actor, - ): - raise QueryValidationError("Permission denied: need publish-query", status=403) if not is_write and any(data.get(field) for field in _query_write_fields): raise QueryValidationError("Writable query fields require writable SQL") @@ -800,7 +810,8 @@ async def _prepare_query_create(datasette, request, db, data): "fragment": data.get("fragment"), "parameters": parameters, "is_write": is_write, - "is_published": is_published, + "is_private": _as_bool(data.get("is_private", True)), + "is_trusted": False, "source": "user", "owner_id": _actor_id(request.actor), "on_success_message": data.get("on_success_message"), @@ -819,7 +830,6 @@ async def _prepare_query_update(datasette, request, db, existing, update): update = _apply_query_data_types(update) sql = update.get("sql", existing["sql"]) - is_published = update.get("is_published", existing["is_published"]) query_is_write = existing["is_write"] derived = _derived_query_parameters(sql) parameters = None @@ -830,19 +840,7 @@ async def _prepare_query_update(datasette, request, db, existing, update): db, sql, actor=request.actor, - is_published=is_published, ) - elif is_published and query_is_write: - raise QueryValidationError("Writable queries cannot be published") - if is_published and not existing["is_published"]: - if not await datasette.allowed( - action="publish-query", - resource=DatabaseResource(db.name), - actor=request.actor, - ): - raise QueryValidationError( - "Permission denied: need publish-query", status=403 - ) if "parameters" in update or "params" in update: parameters = _coerce_query_parameters( @@ -864,7 +862,7 @@ async def _prepare_query_update(datasette, request, db, existing, update): "fragment": update.get("fragment"), "parameters": parameters, "is_write": query_is_write, - "is_published": is_published, + "is_private": update.get("is_private"), "on_success_message": update.get("on_success_message"), "on_success_message_sql": update.get("on_success_message_sql"), "on_success_redirect": update.get("on_success_redirect"), @@ -1141,8 +1139,8 @@ class QueryListView(BaseView): default=20 if format_ == "html" else 50, ) is_write = _as_optional_bool(request.args.get("is_write"), "is_write") - is_published = _as_optional_bool( - request.args.get("is_published"), "is_published" + is_private = _as_optional_bool( + request.args.get("is_private"), "is_private" ) except QueryValidationError as ex: return _error([ex.message], ex.status) @@ -1154,7 +1152,7 @@ class QueryListView(BaseView): cursor=request.args.get("_next"), q=request.args.get("q") or None, is_write=is_write, - is_published=is_published, + is_private=is_private, source=request.args.get("source") or None, owner_id=request.args.get("owner_id") or None, include_private=True, @@ -1186,12 +1184,14 @@ class QueryListView(BaseView): "next_url": next_url, "has_more": page["has_more"], "limit": page["limit"], + "show_private_note": any(query["is_private"] for query in page["queries"]), + "show_trusted_note": any(query["is_trusted"] for query in page["queries"]), "query_list_path": query_list_path, "show_database": database is None, "filters": { "q": request.args.get("q") or "", "is_write": request.args.get("is_write") or "", - "is_published": request.args.get("is_published") or "", + "is_private": request.args.get("is_private") or "", "source": request.args.get("source") or "", "owner_id": request.args.get("owner_id") or "", }, @@ -1255,11 +1255,6 @@ class QueryCreateView(BaseView): "database_color": db.color, "sql": sql, "parameter_names": parameter_names, - "can_publish": await self.ds.allowed( - action="publish-query", - resource=DatabaseResource(db.name), - actor=request.actor, - ), "analysis_error": analysis_error, "analysis_rows": analysis_rows, "analysis_is_write": bool( @@ -1435,9 +1430,9 @@ class QueryView(View): ): raise Forbidden("You do not have permission to view this query") - if canned_query.get("write") and canned_query.get("source") == "user": - await datasette.ensure_query_write_permissions( - db.name, canned_query["sql"], actor=request.actor + if canned_query.get("write"): + await _ensure_stored_query_execution_permissions( + datasette, db, canned_query, request.actor ) # If database is immutable, return an error @@ -1558,6 +1553,10 @@ class QueryView(View): ) if not visible: raise Forbidden("You do not have permission to view this query") + if not canned_query_write: + await _ensure_stored_query_execution_permissions( + datasette, db, canned_query, request.actor + ) else: await datasette.ensure_permission( diff --git a/docs/authentication.rst b/docs/authentication.rst index b6a4cb7e..6e835c8d 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1299,16 +1299,6 @@ insert-query Actor is allowed to create saved queries in a database. -``resource`` - ``datasette.resources.DatabaseResource(database)`` - ``database`` is the name of the database (string) - -.. _actions_publish_query: - -publish-query -------------- - -Actor is allowed to publish a saved read-only query so actors without ``execute-sql`` can run it. - ``resource`` - ``datasette.resources.DatabaseResource(database)`` ``database`` is the name of the database (string) diff --git a/docs/internals.rst b/docs/internals.rst index b5da7cbf..c76de487 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -2158,7 +2158,8 @@ The internal database schema is as follows: options TEXT NOT NULL DEFAULT '{}', parameters TEXT NOT NULL DEFAULT '[]', is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), + is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), source TEXT NOT NULL DEFAULT 'user', owner_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/queries-plan.md b/queries-plan.md index 72427df2..f4b8049c 100644 --- a/queries-plan.md +++ b/queries-plan.md @@ -13,9 +13,9 @@ Terminology change: these are now "queries", not "canned queries". Legacy code a - Internal table name: `queries`. - Query definitions should use real columns, not a JSON blob for all options. - Query parameter names live in a `parameters` text column as a JSON array. No default values for parameters in this pass. -- No `queries_database_is_published_idx` index. -- User-created queries require `execute-sql` and `insert-query` on the database. Writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`. -- `publish-query` is the permission for creating or updating a query so users without `execute-sql` can execute it. +- No separate index is needed for the privacy/trust flags yet. +- User-created queries require `execute-sql` and `insert-query` on the database. They default to private, and writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`. +- Configured queries default to trusted, which means actors who can view them can execute them without also holding `execute-sql` or the relevant write permissions. Config can opt out with `is_trusted: false`. - Add `update-query` and `delete-query`, so administrators can manage queries created by other users. - Remove the old `canned_queries()` hook from core. If we want compatibility later, build a separate `datasette-old-canned-queries` plugin. - Writable user-created queries can be supported using `Database.analyze_sql()`, provided we fail closed when analysis cannot prove the required permissions. @@ -45,7 +45,8 @@ CREATE TABLE IF NOT EXISTS queries ( options TEXT NOT NULL DEFAULT '{}', parameters TEXT NOT NULL DEFAULT '[]', is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), + is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), source TEXT NOT NULL DEFAULT 'user', owner_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -64,11 +65,12 @@ Column notes: - Less common presentation and writable-query behavior lives in `options`, stored as a JSON object. That covers `hide_sql`, `fragment`, `on_success_message`, `on_success_message_sql`, `on_success_redirect`, `on_error_message`, and `on_error_redirect`. - `parameters` is a JSON array of parameter names, stored as text. This preserves explicit parameter order, but does not support labels or default values. - Existing writable query behavior gets `is_write` as a column. Success/error messages, success/error redirects, and `on_success_message_sql` are stored in `options`. -- `is_published` only applies to read-only queries. A writable query can still be public through explicit `view-query` permissions, but the "publish for users without execute-sql" shortcut should be read-only. +- `is_private` means the query is only visible to its owning actor. This is enforced as a permission restriction, so broader `view-query` grants do not expose private rows. +- `is_trusted` means execution skips the usual `execute-sql` or write-permission checks after `view-query` has allowed access. - `source` distinguishes `user`, `config`, and `plugin` rows. - `owner_id` is the actor id for user-created rows. It is `NULL` for config/plugin rows. -No separate index is needed on `(database_name, name)` because the primary key already creates one. Do not add a `queries_database_is_published_idx` index for now. +No separate index is needed on `(database_name, name)` because the primary key already creates one. `QueryResource.resources_sql()` can become: @@ -104,7 +106,6 @@ Remove the old `canned_queries()` hookspec and all core calls to it. If compatib Add core actions: - `insert-query`, database-level, for creating queries in a database. -- `publish-query`, database-level, for marking read-only queries as executable by actors who lack `execute-sql`. - `update-query`, query-level, for modifying existing query definitions. - `delete-query`, query-level, for deleting existing query definitions. @@ -114,17 +115,11 @@ User-created query creation requires: - `insert-query` on `DatabaseResource(database)` - If analysis shows the query is writable, the table-level write permissions described in the writable query section. -Setting `is_published=1` requires: - -- `publish-query` on `DatabaseResource(database)` -- The query must be read-only according to `Database.analyze_sql()`. - Updating an existing query requires: - `update-query` on `QueryResource(database, query)` or default owner permission for a user-owned row. - If the SQL changes, also require `execute-sql` on the database. - If the changed SQL is writable, also require the table-level write permissions described in the writable query section. -- If `is_published` changes from `0` to `1`, also require `publish-query` on the database. Deleting an existing query requires: @@ -133,18 +128,18 @@ Deleting an existing query requires: Default owner permissions: - For `source='user' AND owner_id = actor.id`, grant `update-query` and `delete-query`. -- Do not automatically grant execution if the user no longer has the execution permission described below. +- For `source='user' AND owner_id = actor.id`, grant `view-query`. If the query is private, restriction SQL ensures no other actor sees it through a broader grant. ## Executing queries Default execution rule for read-only queries: -- If `is_published=0`, the actor needs `execute-sql` on the database. -- If `is_published=1`, the actor can execute the query without `execute-sql`. +- If `is_trusted=0`, the actor needs `execute-sql` on the database. +- If `is_trusted=1`, the actor can execute the query without `execute-sql`, provided `view-query` allows access. Default execution rule for user-created writable queries: -- `is_published` must be `0`. +- `is_trusted` must be `0`. - The actor must have `view-query`. - The actor must currently have every write permission required by fresh `Database.analyze_sql()` results for the query SQL. @@ -152,14 +147,14 @@ Implementation: - Remove `view-query` from the broad `DEFAULT_ALLOW_ACTIONS` set. - Replace it with query-aware default `view-query` permission SQL. -- For `is_published=1 AND is_write=0`, emit a child-level `view-query` allow. -- For `is_published=0 AND is_write=0`, emit child-level `view-query` allows for queries whose parent database is in the actor's `execute-sql` allowed resources. -- For `is_write=1 AND source='user'`, emit `view-query` only for the owner or actors with explicit `view-query` permission, then have `QueryView` perform the fresh analysis/table-permission check before execution. -- For trusted writable queries, preserve current behavior by emitting child-level `view-query` allows for `is_write=1 AND source IN ('config', 'plugin')` when Datasette is not running with `--default-deny`. +- Emit default `view-query` allows for non-private rows when Datasette is not running with `--default-deny`. +- Emit default `view-query` allows for the owning actor. +- Use `restriction_sql` to limit private rows to their owner even when broader `view-query` permissions exist. +- Have `QueryView` perform the fresh `execute-sql` or table-permission check before execution unless the row has `is_trusted=1`. -For read-only queries this keeps `QueryView` simple: it checks `view-query` for the query resource, and the default permission hook encodes the relationship with `execute-sql`. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis. +For read-only queries this keeps `QueryView` explicit: it checks `view-query` for the query resource, then checks `execute-sql` unless the row is trusted. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis. -Explicit deny rules should still be able to block a published query. +Explicit deny rules should still be able to block a query, and `--default-deny` still blocks trusted queries unless something grants `view-query`. ## Writable queries @@ -180,7 +175,7 @@ Validation flow for user-created queries: 1. Derive named parameters from the SQL and pass harmless placeholder values into `db.analyze_sql()` so SQLite can prepare statements with bindings. 2. If analysis raises a SQLite error, reject the query. 3. If every table access is `read`, treat the query as read-only and require `execute-sql` plus `insert-query`/`update-query` as described above. -4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_published=0`. +4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_trusted=0`. 5. Reject writable user-created queries that access a database other than the database they are being saved against, until `analyze_sql()` can reliably map attached SQLite schemas back to Datasette database names. 6. For every write access returned by analysis, require the corresponding permission on `TableResource(access.database, access.table)`: - `insert` -> `insert-row` @@ -200,7 +195,7 @@ Fail closed cases for user-created writable queries: - Analysis reports any write operation that cannot be mapped to a Datasette table resource. - Analysis reports writes outside the target database. - The actor lacks any required table write permission. -- `is_published=1` is requested. +- `is_trusted=1` is requested through the user-facing API. This gives us writable user-created queries without letting `execute-sql` alone become a path to create arbitrary write endpoints. @@ -225,7 +220,7 @@ Create request: "sql": "select * from customers order by revenue desc limit 20", "title": "Top customers", "description": "Highest revenue customers", - "is_published": false, + "is_private": true, "parameters": ["region"] } } @@ -242,7 +237,8 @@ Successful create returns `201` and the created query definition: "sql": "select * from customers order by revenue desc limit 20", "title": "Top customers", "description": "Highest revenue customers", - "is_published": false, + "is_private": true, + "is_trusted": false, "parameters": ["region"] } } @@ -254,7 +250,7 @@ Update request, imitating `RowUpdateView`: { "update": { "title": "Top customers by revenue", - "is_published": true + "is_private": false }, "return": true } @@ -270,7 +266,8 @@ Successful update returns `{"ok": true}` by default. With `"return": true`, retu "name": "top_customers", "sql": "select * from customers order by revenue desc limit 20", "title": "Top customers by revenue", - "is_published": true + "is_private": false, + "is_trusted": false } } ``` @@ -317,7 +314,8 @@ await datasette.add_query( fragment=None, parameters=None, is_write=False, - is_published=False, + is_private=False, + is_trusted=False, source="plugin", owner_id=None, on_success_message=None, @@ -340,7 +338,8 @@ await datasette.update_query( fragment=UNCHANGED, parameters=UNCHANGED, is_write=UNCHANGED, - is_published=UNCHANGED, + is_private=UNCHANGED, + is_trusted=UNCHANGED, source=UNCHANGED, owner_id=UNCHANGED, on_success_message=UNCHANGED, @@ -360,7 +359,8 @@ await datasette.list_queries( cursor=None, q=None, is_write=None, - is_published=None, + is_private=None, + is_trusted=None, source=None, owner_id=None, ) @@ -382,15 +382,13 @@ For column-backed fields, `None` should write SQL `NULL`. For option fields, `No Implementation detail: build the `UPDATE` statement dynamically from fields whose value is not `UNCHANGED`, validate non-nullable fields before writing, and update `updated_at` whenever at least one field changes. -The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_published`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`. +The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_private`, `is_trusted`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`. ## Query page save UI On `/{database}/-/query`, if the actor has both `execute-sql` and `insert-query`, show a save control for valid read-only SQL. That page already executes read-only arbitrary SQL, so the first UI can stay read-only even though the JSON API can accept writable SQL after `Database.analyze_sql()` validation. -The save form should call `POST /{database}/-/queries/insert` and default to `is_published=false`. - -If the actor also has `publish-query`, include a publish control. The UI copy should make it clear that publishing allows people without arbitrary SQL permission to run this query. +The save form should call `POST /{database}/-/queries/insert` and default to `is_private=true`. On `/{database}`, show a preview of the first 5 visible queries using `list_queries(..., limit=5)`. If the page has `has_more`, show a link to `/{database}/-/queries` rather than rendering hundreds or thousands of query links inline. The full `/{database}/-/queries` page provides search, filters, and cursor pagination. The global `/-/queries` page reuses the same interface and shows the database for each query. @@ -403,7 +401,7 @@ This page should require `execute-sql` and `insert-query` to access. It should p - Read-only - Writable -Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and optional published status if the actor has `publish-query`. +Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and privacy status. Writable mode should always run `Database.analyze_sql()` and show an analysis panel before saving: @@ -413,7 +411,7 @@ Writable mode should always run `Database.analyze_sql()` and show an analysis pa - whether the actor has that permission - source, when the operation comes from a trigger or view -The Save button should be disabled until analysis succeeds and every required table write permission is allowed. Writable mode should not show a publish control, because user-created writable queries cannot be published. +The Save button should be disabled until analysis succeeds and every required table write permission is allowed. The existing edit-SQL flow from query pages can continue to point back to arbitrary SQL. A later enhancement can add "update this query" when the actor owns it or has `update-query`. @@ -427,14 +425,16 @@ The existing edit-SQL flow from query pages can continue to point back to arbitr - `QueryResource.resources_sql()` returns rows from `queries`. - Database page and `/-/jump` list queries from the internal DB. - `view-query` is no longer globally default-allowed; default query permissions come from the query-aware hook. -- Unpublished read-only query requires `execute-sql` to execute. -- Published read-only query can be executed without `execute-sql`. -- Setting `is_published=true` requires `publish-query`. +- Private query is only visible to its owner, even when a broader `view-query` rule applies. +- Non-trusted read-only query requires `execute-sql` to execute. +- Trusted read-only query can be executed without `execute-sql` after `view-query` passes. +- Config queries default to trusted and can opt out with `is_trusted: false`. +- User API rejects client-supplied `is_trusted`. - User-created query requires both `execute-sql` and `insert-query`. - User-created writable query creation uses `Database.analyze_sql()` and requires matching `insert-row`, `update-row`, and/or `delete-row` permissions for every reported write access. - `/{database}/-/queries/-/create` provides the writable-query authoring UI with an analysis panel and disabled save until all required write permissions pass. - User-created writable query execution re-runs `Database.analyze_sql()` and re-checks table write permissions. -- User-created writable query cannot be published. +- User-created writable query cannot be trusted through the user API. - Query update uses `POST /{database}/{query}/-/update` with an `{"update": {...}}` body. - Query delete uses `POST /{database}/{query}/-/delete`. - There are no `PATCH` or HTTP `DELETE` routes for query management. diff --git a/tests/test_queries.py b/tests/test_queries.py index 57920584..c97b5733 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -15,7 +15,6 @@ async def add_numbered_queries(ds, database, count): "select {} as query_number".format(i), title="Demo query {:02d}".format(i), description="Seeded demo query number {:02d}".format(i), - is_published=True, source="user", owner_id="root", ) @@ -44,7 +43,8 @@ async def test_queries_internal_table_schema(): "options", "parameters", "is_write", - "is_published", + "is_private", + "is_trusted", "source", "owner_id", "created_at", @@ -67,7 +67,7 @@ async def test_add_get_and_remove_query(): hide_sql=True, fragment="chart", parameters=["region"], - is_published=True, + is_trusted=True, source="user", owner_id="alice", ) @@ -100,7 +100,8 @@ async def test_add_get_and_remove_query(): "parameters": ["region"], "is_write": False, "write": False, - "is_published": True, + "is_private": False, + "is_trusted": True, "source": "user", "owner_id": "alice", "on_success_message": None, @@ -161,7 +162,8 @@ async def test_update_query_only_updates_provided_fields(): assert query["params"] == [] assert query["on_success_redirect"] is None assert query["sql"] == "select 1" - assert query["is_published"] is False + assert query["is_private"] is False + assert query["is_trusted"] is False options_row = ( await ds.get_internal_database().execute( """ @@ -208,7 +210,8 @@ async def test_config_queries_imported_to_internal_table(): "parameters": ["name"], "is_write": False, "write": False, - "is_published": False, + "is_private": False, + "is_trusted": True, "source": "config", "owner_id": None, "on_success_message": None, @@ -232,30 +235,171 @@ async def test_query_resources_come_from_internal_table(): @pytest.mark.asyncio -async def test_unpublished_query_requires_execute_sql_but_published_does_not(): - ds = Datasette(memory=True, settings={"default_allow_sql": False}) +async def test_default_deny_blocks_view_query_even_for_trusted_query(): + ds = Datasette(memory=True, default_deny=True) ds.add_memory_database("query_permissions", name="data") await ds.invoke_startup() - await ds.add_query("data", "unpublished", "select 1", is_published=False) - await ds.add_query("data", "published", "select 1", is_published=True) + await ds.add_query("data", "trusted", "select 1", is_trusted=True) assert not await ds.allowed( - action="execute-sql", - resource=DatabaseResource("data"), + action="view-query", + resource=QueryResource("data", "trusted"), actor=None, ) + + +@pytest.mark.asyncio +async def test_private_query_restriction_blocks_broad_view_query_permission(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "view-query": {"id": "*"}, + } + } + } + }, + ) + ds.add_memory_database("private_query_permissions", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "private_report", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + await ds.add_query( + "data", + "shared_report", + "select 2", + is_private=False, + source="user", + owner_id="alice", + ) + + assert await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "alice"}, + ) assert not await ds.allowed( action="view-query", - resource=QueryResource("data", "unpublished"), - actor=None, + resource=QueryResource("data", "private_report"), + actor={"id": "bob"}, ) assert await ds.allowed( action="view-query", - resource=QueryResource("data", "published"), - actor=None, + resource=QueryResource("data", "shared_report"), + actor={"id": "bob"}, ) +@pytest.mark.asyncio +async def test_config_query_restriction_does_not_override_private_internal_query(): + ds = Datasette(memory=True, default_deny=True) + ds.add_memory_database("private_query_with_config_name", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "private_report", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + ds.config = { + "databases": { + "data": { + "permissions": {"view-query": {"id": "*"}}, + "queries": {"private_report": {"sql": "select 2"}}, + } + } + } + + assert not await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "bob"}, + ) + + +@pytest.mark.asyncio +async def test_untrusted_shared_query_execution_requires_execute_sql(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "view-database": {"id": "viewer"}, + "view-query": {"id": "viewer"}, + } + } + } + }, + ) + ds.add_memory_database("untrusted_query_execution", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "shared_report", + "select 1 as one", + is_private=False, + is_trusted=False, + source="user", + owner_id="alice", + ) + + denied = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) + assert denied.status_code == 403 + + ds.config["databases"]["data"]["permissions"]["execute-sql"] = {"id": "viewer"} + allowed = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) + assert allowed.status_code == 200 + assert allowed.json()["rows"] == [{"one": 1}] + + +@pytest.mark.asyncio +async def test_config_queries_are_trusted_by_default_but_can_opt_out(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "view-query": {"id": "viewer"}, + }, + "queries": { + "trusted_report": {"sql": "select 1 as one"}, + "untrusted_report": { + "sql": "select 2 as two", + "is_trusted": False, + }, + }, + } + } + }, + ) + ds.add_memory_database("trusted_query_config", name="data") + await ds.invoke_startup() + + trusted = await ds.client.get("/data/trusted_report.json", actor={"id": "viewer"}) + untrusted = await ds.client.get( + "/data/untrusted_report.json", actor={"id": "viewer"} + ) + + assert trusted.status_code == 200 + assert trusted.json()["rows"] == [{"one": 1}] + assert untrusted.status_code == 403 + + @pytest.mark.asyncio async def test_database_page_query_preview_is_limited(): ds = Datasette(memory=True) @@ -281,7 +425,6 @@ async def test_query_actions_are_registered(): assert ds.get_action("execute-write-sql").resource_class is DatabaseResource assert ds.get_action("insert-query").resource_class is DatabaseResource - assert ds.get_action("publish-query").resource_class is DatabaseResource assert ds.get_action("update-query").resource_class is QueryResource assert ds.get_action("delete-query").resource_class is QueryResource @@ -430,21 +573,33 @@ async def test_query_list_search_filter_and_html(): "private_query", "select 'private'", title="Private query", - is_published=False, + is_private=True, source="user", owner_id="root", ) + await ds.add_query( + "data", + "trusted_query", + "select 'trusted'", + title="Trusted query", + is_trusted=True, + source="config", + ) html_response = await ds.client.get( "/data/-/queries?q=02", actor={"id": "root"}, ) + flags_response = await ds.client.get( + "/data/-/queries", + actor={"id": "root"}, + ) json_response = await ds.client.get( "/data/-/queries.json?q=02", actor={"id": "root"}, ) filtered_response = await ds.client.get( - "/data/-/queries.json?is_published=0", + "/data/-/queries.json?is_private=1", actor={"id": "root"}, ) @@ -453,7 +608,22 @@ async def test_query_list_search_filter_and_html(): assert "Demo query 01" not in html_response.text assert 'class="query-list-results"' in html_response.text assert "Mode" in html_response.text - assert 'type="radio" name="is_published" value="1"' in html_response.text + assert 'type="radio" name="is_private" value="1"' in html_response.text + assert "Only the owning actor can view this query." not in html_response.text + assert ( + "Execution skips the usual SQL and write permission checks" + not in html_response.text + ) + assert flags_response.status_code == 200 + assert 'Owner' in flags_response.text + assert 'Flags' in flags_response.text + assert 'Mode' not in flags_response.text + assert 'class="query-list-owner">root' in flags_response.text + assert 'class="query-list-pill">Read-only' in flags_response.text + assert 'class="query-list-pill query-list-pill-private">Private' in flags_response.text + assert 'class="query-list-pill query-list-pill-trusted">Trusted' in flags_response.text + assert "Only the owning actor can view this query." in flags_response.text + assert "Execution skips the usual SQL and write permission checks" in flags_response.text assert json_response.json()["queries"][0]["name"] == "demo_query_02" assert [query["name"] for query in filtered_response.json()["queries"]] == [ "private_query" @@ -491,7 +661,6 @@ async def test_global_query_list_api_and_html(): "alpha_first", "select 1", title="Alpha first", - is_published=True, source="user", owner_id="root", ) @@ -500,7 +669,6 @@ async def test_global_query_list_api_and_html(): "alpha_second", "select 2", title="Alpha second", - is_published=True, source="user", owner_id="root", ) @@ -509,7 +677,6 @@ async def test_global_query_list_api_and_html(): "beta_first", "select 3", title="Beta first", - is_published=True, source="user", owner_id="root", ) @@ -548,7 +715,7 @@ async def test_global_query_list_api_and_html(): @pytest.mark.asyncio -async def test_query_insert_api_publish_requires_publish_query(): +async def test_query_insert_api_rejects_is_trusted(): ds = Datasette( memory=True, default_deny=True, @@ -564,17 +731,17 @@ async def test_query_insert_api_publish_requires_publish_query(): } }, ) - ds.add_memory_database("query_publish_api", name="data") + ds.add_memory_database("query_trusted_api", name="data") await ds.invoke_startup() response = await ds.client.post( "/data/-/queries/insert", actor={"id": "writer"}, - json={"query": {"name": "public", "sql": "select 1", "is_published": True}}, + json={"query": {"name": "trusted", "sql": "select 1", "is_trusted": True}}, ) - assert response.status_code == 403 - assert response.json()["errors"] == ["Permission denied: need publish-query"] + assert response.status_code == 400 + assert response.json()["errors"] == ["Invalid keys: is_trusted"] @pytest.mark.asyncio @@ -599,24 +766,10 @@ async def test_query_insert_api_creates_writable_query(): assert response.status_code == 201 query = response.json()["query"] assert query["is_write"] is True - assert query["is_published"] is False + assert query["is_private"] is True + assert query["is_trusted"] is False assert query["parameters"] == ["name"] - bad_response = await ds.client.post( - "/data/-/queries/insert", - actor={"id": "root"}, - json={ - "query": { - "name": "published_insert", - "sql": "insert into dogs (name) values (:name)", - "is_published": True, - } - }, - ) - - assert bad_response.status_code == 400 - assert bad_response.json()["errors"] == ["Writable queries cannot be published"] - @pytest.mark.asyncio async def test_query_update_and_delete_api(): @@ -1103,6 +1256,10 @@ async def test_user_writable_query_execution_rechecks_table_permissions(): config={ "databases": { "data": { + "permissions": { + "view-database": {"id": ["alice", "bob"]}, + "execute-write-sql": {"id": ["alice", "bob"]}, + }, "tables": { "dogs": { "permissions": { From 1cd162e9da48b924c289ec9343e9d801b51a89f9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 12:07:30 -0700 Subject: [PATCH 168/203] Removed some no-longer-necessary code, simplified view-query is back in the default allow actions now. We have other mechanisms that work for controlling visibility, and the fact that queries default to running with the permissions of the actor makes this safe. --- datasette/default_permissions/defaults.py | 55 +++-------------------- tests/test_permissions.py | 9 +++- tests/test_queries.py | 39 ++++++++++++++++ 3 files changed, 51 insertions(+), 52 deletions(-) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index dfd8d3e9..ed0a6d66 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -21,37 +21,12 @@ DEFAULT_ALLOW_ACTIONS = frozenset( "view-database", "view-database-download", "view-table", + "view-query", "execute-sql", } ) -def _configured_query_restriction_selects(datasette: "Datasette") -> tuple[list[str], dict]: - selects = [] - params = {} - for index, (database_name, db_config) in enumerate( - ((datasette.config or {}).get("databases") or {}).items() - ): - for query_name, query_config in (db_config.get("queries") or {}).items(): - if isinstance(query_config, dict) and query_config.get("is_private"): - continue - parent_param = f"query_config_parent_{index}_{len(selects)}" - child_param = f"query_config_child_{index}_{len(selects)}" - selects.append( - f""" - SELECT :{parent_param} AS parent, :{child_param} AS child - WHERE NOT EXISTS ( - SELECT 1 FROM queries - WHERE database_name = :{parent_param} - AND name = :{child_param} - ) - """ - ) - params[parent_param] = database_name - params[child_param] = query_name - return selects, params - - @hookimpl(specname="permission_resources_sql") async def default_allow_sql_check( datasette: "Datasette", @@ -121,16 +96,6 @@ async def default_query_permissions_sql( params = {"query_owner_id": actor_id} rule_sqls = [] - if not datasette.default_deny: - rule_sqls.append( - """ - SELECT database_name AS parent, name AS child, 1 AS allow, - 'non-private query' AS reason - FROM queries - WHERE is_private = 0 - """ - ) - if actor_id is not None: rule_sqls.append( """ @@ -141,23 +106,13 @@ async def default_query_permissions_sql( """ ) - config_restriction_selects, config_restriction_params = ( - _configured_query_restriction_selects(datasette) - ) - - restriction_sqls = [ - """ + return PermissionSQL( + sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, + restriction_sql=""" SELECT database_name AS parent, name AS child FROM queries WHERE is_private = 0 OR owner_id = :query_owner_id - """ - ] - restriction_sqls.extend(config_restriction_selects) - params.update(config_restriction_params) - - return PermissionSQL( - sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, - restriction_sql="\nUNION ALL\n".join(restriction_sqls), + """, params=params, ) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 22f294bb..4f342d8f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -937,16 +937,20 @@ async def test_permissions_in_config( updated_config = copy.deepcopy(previous_config) updated_config.update(config) perms_ds.config = updated_config + await perms_ds.apply_queries_config() try: # Convert old-style resource to Resource object - from datasette.resources import DatabaseResource, TableResource + from datasette.resources import DatabaseResource, QueryResource, TableResource resource_obj = None if resource: if isinstance(resource, str): resource_obj = DatabaseResource(database=resource) elif isinstance(resource, tuple) and len(resource) == 2: - resource_obj = TableResource(database=resource[0], table=resource[1]) + if action == "view-query": + resource_obj = QueryResource(database=resource[0], query=resource[1]) + else: + resource_obj = TableResource(database=resource[0], table=resource[1]) result = await perms_ds.allowed( action=action, resource=resource_obj, actor=actor @@ -956,6 +960,7 @@ async def test_permissions_in_config( assert result == expected_result finally: perms_ds.config = previous_config + await perms_ds.apply_queries_config() @pytest.mark.asyncio diff --git a/tests/test_queries.py b/tests/test_queries.py index c97b5733..dde57dea 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -248,6 +248,45 @@ async def test_default_deny_blocks_view_query_even_for_trusted_query(): ) +@pytest.mark.asyncio +async def test_view_query_default_allow_still_respects_private_restriction(): + ds = Datasette(memory=True) + ds.add_memory_database("default_view_query_permissions", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "private_report", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + await ds.add_query( + "data", + "shared_report", + "select 2", + is_private=False, + source="user", + owner_id="alice", + ) + + assert await ds.allowed( + action="view-query", + resource=QueryResource("data", "shared_report"), + actor=None, + ) + assert await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "alice"}, + ) + assert not await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "bob"}, + ) + + @pytest.mark.asyncio async def test_private_query_restriction_blocks_broad_view_query_permission(): ds = Datasette( From 1ac4265ffd295ea62008b13b3e37af96f5450be4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 12:12:59 -0700 Subject: [PATCH 169/203] Require permissions for untrusted stored query execution, refs #2735 --- datasette/views/database.py | 7 +++---- docs/authentication.rst | 2 +- queries-plan.md | 8 +++----- tests/test_queries.py | 12 ++++++++++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 91e9c350..bd939d87 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1430,10 +1430,9 @@ class QueryView(View): ): raise Forbidden("You do not have permission to view this query") - if canned_query.get("write"): - await _ensure_stored_query_execution_permissions( - datasette, db, canned_query, request.actor - ) + await _ensure_stored_query_execution_permissions( + datasette, db, canned_query, request.actor + ) # If database is immutable, return an error if not db.is_mutable: diff --git a/docs/authentication.rst b/docs/authentication.rst index 6e835c8d..453aaa19 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1285,7 +1285,7 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i view-query ---------- -Actor is allowed to view (and execute) a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`. +Actor is allowed to view a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted saved query also requires ``execute-sql`` or the relevant write permissions; trusted saved queries can execute with ``view-query`` alone. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) diff --git a/queries-plan.md b/queries-plan.md index f4b8049c..da6b7c92 100644 --- a/queries-plan.md +++ b/queries-plan.md @@ -25,7 +25,7 @@ Terminology change: these are now "queries", not "canned queries". Legacy code a - Query definitions currently come from `datasette.yaml` or the `canned_queries()` plugin hook. - `Datasette.get_canned_queries(database_name, actor)` calls that hook every time it needs query definitions. - `QueryResource.resources_sql()` currently enumerates databases and calls the hook for each one, because permissions and `/-/jump` need query resources. -- Query pages execute if the actor has `view-query` for `QueryResource(database, query)`. +- Query pages are visible if the actor has `view-query` for `QueryResource(database, query)`. Executing an untrusted stored query also checks `execute-sql` or the relevant write permissions. - Arbitrary SQL executes if the actor has `execute-sql` for `DatabaseResource(database)`. The main performance and architecture win is making query resource enumeration a direct SQL query against the internal database. @@ -145,9 +145,7 @@ Default execution rule for user-created writable queries: Implementation: -- Remove `view-query` from the broad `DEFAULT_ALLOW_ACTIONS` set. -- Replace it with query-aware default `view-query` permission SQL. -- Emit default `view-query` allows for non-private rows when Datasette is not running with `--default-deny`. +- Keep `view-query` in the broad `DEFAULT_ALLOW_ACTIONS` set, so saved queries remain visible by default in all-public Datasette. - Emit default `view-query` allows for the owning actor. - Use `restriction_sql` to limit private rows to their owner even when broader `view-query` permissions exist. - Have `QueryView` perform the fresh `execute-sql` or table-permission check before execution unless the row has `is_trusted=1`. @@ -424,7 +422,7 @@ The existing edit-SQL flow from query pages can continue to point back to arbitr - The old `canned_queries()` hook is no longer called by core. - `QueryResource.resources_sql()` returns rows from `queries`. - Database page and `/-/jump` list queries from the internal DB. -- `view-query` is no longer globally default-allowed; default query permissions come from the query-aware hook. +- `view-query` remains globally default-allowed, with `restriction_sql` narrowing private queries to their owner. - Private query is only visible to its owner, even when a broader `view-query` rule applies. - Non-trusted read-only query requires `execute-sql` to execute. - Trusted read-only query can be executed without `execute-sql` after `view-query` passes. diff --git a/tests/test_queries.py b/tests/test_queries.py index dde57dea..997f8b39 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -395,8 +395,16 @@ async def test_untrusted_shared_query_execution_requires_execute_sql(): owner_id="alice", ) - denied = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) - assert denied.status_code == 403 + denied_get = await ds.client.get( + "/data/shared_report.json", actor={"id": "viewer"} + ) + denied_post = await ds.client.post( + "/data/shared_report", + actor={"id": "viewer"}, + data={}, + ) + assert denied_get.status_code == 403 + assert denied_post.status_code == 403 ds.config["databases"]["data"]["permissions"]["execute-sql"] = {"id": "viewer"} allowed = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) From 866852eff603c219b8bf7d13f2a69b5ff032fa67 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 12:46:18 -0700 Subject: [PATCH 170/203] Clarifying comments --- datasette/default_permissions/defaults.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index ed0a6d66..32ad4ef1 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -80,6 +80,7 @@ async def default_query_permissions_sql( if action in {"update-query", "delete-query"}: if actor_id is None: return None + # Query owner can update/delete query return PermissionSQL( sql=""" SELECT database_name AS parent, name AS child, 1 AS allow, @@ -97,15 +98,15 @@ async def default_query_permissions_sql( params = {"query_owner_id": actor_id} rule_sqls = [] if actor_id is not None: - rule_sqls.append( - """ + # Query owner can view-query + rule_sqls.append(""" SELECT database_name AS parent, name AS child, 1 AS allow, 'query owner' AS reason FROM queries WHERE owner_id = :query_owner_id - """ - ) + """) + # restriction_sql enforces private queries ONLY visible to owner return PermissionSQL( sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, restriction_sql=""" From 71c76e38534378cbce8576771238a788feccf3ad Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:08:19 -0700 Subject: [PATCH 171/203] Better faceting on /-/queries Ref https://github.com/simonw/datasette/pull/2741#issuecomment-4548321815 --- datasette/app.py | 69 +++++++++++++++++ datasette/templates/query_list.html | 94 +++++++++++++---------- datasette/views/database.py | 99 +++++++++++++++++++++++- tests/test_permissions.py | 8 +- tests/test_queries.py | 115 +++++++++++++++++++++++++--- 5 files changed, 330 insertions(+), 55 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 3329ee7e..1acdfcd8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1298,6 +1298,75 @@ class Datasette: ) return self._query_row_to_dict(rows.first()) + async def count_queries( + self, + database=None, + *, + actor=None, + q=None, + is_write=None, + is_private=None, + is_trusted=None, + source=None, + owner_id=None, + ): + allowed_sql, allowed_params = await self.allowed_resources_sql( + action="view-query", + actor=actor, + parent=database, + ) + params = dict(allowed_params) + where_clauses = [] + if database is not None: + params["query_database"] = database + where_clauses.append("q.database_name = :query_database") + + if q: + where_clauses.append(""" + ( + q.name LIKE :query_search + OR q.title LIKE :query_search + OR q.description LIKE :query_search + OR q.sql LIKE :query_search + ) + """) + params["query_search"] = "%{}%".format(q) + if is_write is not None: + where_clauses.append("q.is_write = :query_is_write") + params["query_is_write"] = int(bool(is_write)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) + if source is not None: + where_clauses.append("q.source = :query_source") + params["query_source"] = source + if owner_id is not None: + where_clauses.append("q.owner_id = :query_owner_id") + params["query_owner_id"] = owner_id + + row = ( + await self.get_internal_database().execute( + """ + SELECT count(*) AS count + FROM queries q + JOIN ( + {allowed_sql} + ) allowed + ON allowed.parent = q.database_name + AND allowed.child = q.name + WHERE {where} + """.format( + allowed_sql=allowed_sql, + where=" AND ".join(where_clauses) or "1 = 1", + ), + params, + ) + ).first() + return row["count"] + async def list_queries( self, database=None, diff --git a/datasette/templates/query_list.html b/datasette/templates/query_list.html index 25259b3d..fa4859b1 100644 --- a/datasette/templates/query_list.html +++ b/datasette/templates/query_list.html @@ -9,7 +9,7 @@ max-width: 64rem; } .query-list-filters { - margin: 0.5rem 0 1rem; + margin: 0.5rem 0 0.75rem; } .query-list-search { align-items: center; @@ -32,43 +32,63 @@ line-height: 1.1; padding: 0.35rem 0.65rem; } -.query-list-filter-groups { +.query-list-facets { align-items: flex-start; display: flex; flex-wrap: wrap; - gap: 0.8rem 1.4rem; + gap: 1rem 1.6rem; + margin: 0 0 1rem; } -.query-list-filter-group { - border: 0; +.query-list-facet { + margin: 0; +} +.query-list-facet h2 { + font-size: 0.9rem; + line-height: 1.2; + margin: 0 0 0.35rem; +} +.query-list-facet ul { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0; - min-width: 0; padding: 0; + list-style: none; } -.query-list-filter-group legend { - font-weight: 700; - margin: 0 0.45rem 0 0; - padding: 0; -} -.query-list-filter-group label { +.query-list-facet-link, +.query-list-facet-link:link, +.query-list-facet-link:visited, +.query-list-facet-link:hover, +.query-list-facet-link:focus, +.query-list-facet-link:active { align-items: center; border: 1px solid #c8d1dc; border-radius: 0.25rem; - cursor: pointer; + color: #39445a; display: inline-flex; font-size: 0.82rem; - gap: 0.3rem; + gap: 0.4rem; line-height: 1.1; padding: 0.35rem 0.55rem; + text-decoration: none; } -.query-list-filter-group input { - margin: 0; +.query-list-facet-link:hover { + border-color: #7ca5c8; + color: #1f5d85; } -.query-list-filter-group input:checked + span { +.query-list-facet-link-active { + background-color: #edf6fb; + border-color: #6d9fc0; font-weight: 700; } +.query-list-facet-disabled { + color: #7b8794; + cursor: default; +} +.query-list-facet-count { + color: #4f5b6d; + font-variant-numeric: tabular-nums; +} .query-list-results { border-collapse: collapse; font-size: 0.9rem; @@ -169,15 +189,6 @@ .query-list-search input[type=search] { max-width: none; } - .query-list-filter-group { - display: block; - } - .query-list-filter-group legend { - margin-bottom: 0.3rem; - } - .query-list-filter-group label { - margin: 0 0.25rem 0.35rem 0; - } } {% endblock %} @@ -198,24 +209,27 @@ -
      -
      - Mode - - - -
      -
      - Visibility - - - -
      -
      + + {% if queries %}
      diff --git a/datasette/views/database.py b/datasette/views/database.py index bd939d87..2e77d36b 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1121,6 +1121,21 @@ class QueryParametersView(BaseView): return _block_framing(Response.json({"ok": True, "parameters": parameters})) +def _query_list_url(path, query_string, *, set_args=None, remove_args=None): + set_args = set_args or {} + remove_args = set(remove_args or ()) + skip = set(set_args) | remove_args | {"_next"} + pairs = [ + (key, value) + for key, value in parse_qsl(query_string, keep_blank_values=True) + if key not in skip + ] + for key, value in set_args.items(): + if value not in (None, ""): + pairs.append((key, value)) + return path + (("?" + urlencode(pairs)) if pairs else "") + + class QueryListView(BaseView): name = "query-list" @@ -1139,9 +1154,7 @@ class QueryListView(BaseView): default=20 if format_ == "html" else 50, ) is_write = _as_optional_bool(request.args.get("is_write"), "is_write") - is_private = _as_optional_bool( - request.args.get("is_private"), "is_private" - ) + is_private = _as_optional_bool(request.args.get("is_private"), "is_private") except QueryValidationError as ex: return _error([ex.message], ex.status) @@ -1173,6 +1186,80 @@ class QueryListView(BaseView): urlencode(pairs), ) + current_filters = { + "actor": request.actor, + "q": request.args.get("q") or None, + "is_write": is_write, + "is_private": is_private, + "source": request.args.get("source") or None, + "owner_id": request.args.get("owner_id") or None, + } + + async def facet_count(field, value): + if current_filters[field] is not None and current_filters[field] != value: + return 0 + filters = dict(current_filters) + filters[field] = value + return await self.ds.count_queries(database, **filters) + + def facet_href(field, value): + if current_filters[field] == value: + return _query_list_url( + query_list_path, + request.query_string, + remove_args=[field], + ) + if current_filters[field] is not None: + return None + return _query_list_url( + query_list_path, + request.query_string, + set_args={field: str(int(value))}, + ) + + async def facet_item(label, field, value): + count = await facet_count(field, value) + active = current_filters[field] == value + if not active and not count: + return None + return { + "label": label, + "count": count, + "href": facet_href(field, value) if active or count else None, + "active": active, + } + + async def facet_items(items): + return [ + item + for item in [ + await facet_item(label, field, value) + for label, field, value in items + ] + if item is not None + ] + + facets = [ + { + "title": "Mode", + "items": await facet_items( + [ + ("Read-only", "is_write", False), + ("Writable", "is_write", True), + ] + ), + }, + { + "title": "Visibility", + "items": await facet_items( + [ + ("Not private", "is_private", False), + ("Private", "is_private", True), + ] + ), + }, + ] + data = { "ok": True, "database": database, @@ -1188,6 +1275,7 @@ class QueryListView(BaseView): "show_trusted_note": any(query["is_trusted"] for query in page["queries"]), "query_list_path": query_list_path, "show_database": database is None, + "facets": facets, "filters": { "q": request.args.get("q") or "", "is_write": request.args.get("is_write") or "", @@ -1715,6 +1803,9 @@ class QueryView(View): } ) metadata = await datasette.get_database_metadata(database) + if canned_query: + metadata = dict(canned_query) + metadata.pop("source", None) renderers = {} for key, (_, can_render) in datasette.renderers.items(): @@ -1865,7 +1956,7 @@ class QueryView(View): ) ), show_hide_hidden=markupsafe.Markup(show_hide_hidden), - metadata=canned_query or metadata, + metadata=metadata, alternate_url_json=alternate_url_json, select_templates=[ f"{'*' if template_name == template.name else ''}{template_name}" diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 4f342d8f..eb6cee9f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -948,9 +948,13 @@ async def test_permissions_in_config( resource_obj = DatabaseResource(database=resource) elif isinstance(resource, tuple) and len(resource) == 2: if action == "view-query": - resource_obj = QueryResource(database=resource[0], query=resource[1]) + resource_obj = QueryResource( + database=resource[0], query=resource[1] + ) else: - resource_obj = TableResource(database=resource[0], table=resource[1]) + resource_obj = TableResource( + database=resource[0], table=resource[1] + ) result = await perms_ds.allowed( action=action, resource=resource_obj, actor=actor diff --git a/tests/test_queries.py b/tests/test_queries.py index 997f8b39..36f7107a 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -395,9 +395,7 @@ async def test_untrusted_shared_query_execution_requires_execute_sql(): owner_id="alice", ) - denied_get = await ds.client.get( - "/data/shared_report.json", actor={"id": "viewer"} - ) + denied_get = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) denied_post = await ds.client.post( "/data/shared_report", actor={"id": "viewer"}, @@ -608,6 +606,27 @@ async def test_query_list_and_definition_api(): assert definition_response.json()["query"]["title"] == "Demo query 01" +@pytest.mark.asyncio +async def test_query_page_does_not_show_internal_source(): + ds = Datasette(memory=True) + ds.add_memory_database("query_page_source", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "stored_report", + "select 1 as one", + title="Stored report", + source="user", + owner_id="root", + ) + + response = await ds.client.get("/data/stored_report", actor={"id": "root"}) + + assert response.status_code == 200 + assert "Stored report" in response.text + assert "Data source:" not in response.text + + @pytest.mark.asyncio async def test_query_list_search_filter_and_html(): ds = Datasette(memory=True) @@ -632,6 +651,15 @@ async def test_query_list_search_filter_and_html(): is_trusted=True, source="config", ) + await ds.add_query( + "data", + "writable_query", + "insert into dogs (name) values (:name)", + title="Writable query", + is_write=True, + source="user", + owner_id="root", + ) html_response = await ds.client.get( "/data/-/queries?q=02", @@ -649,13 +677,21 @@ async def test_query_list_search_filter_and_html(): "/data/-/queries.json?is_private=1", actor={"id": "root"}, ) + filtered_write_response = await ds.client.get( + "/data/-/queries?is_write=1", + actor={"id": "root"}, + ) + filtered_private_response = await ds.client.get( + "/data/-/queries?is_private=1", + actor={"id": "root"}, + ) assert html_response.status_code == 200 assert "Demo query 02" in html_response.text assert "Demo query 01" not in html_response.text assert 'class="query-list-results"' in html_response.text - assert "Mode" in html_response.text - assert 'type="radio" name="is_private" value="1"' in html_response.text + assert 'class="query-list-facets"' in html_response.text + assert 'type="radio"' not in html_response.text assert "Only the owning actor can view this query." not in html_response.text assert ( "Execution skips the usual SQL and write permission checks" @@ -667,14 +703,75 @@ async def test_query_list_search_filter_and_html(): assert '' not in flags_response.text assert 'class="query-list-owner">root' in flags_response.text assert 'class="query-list-pill">Read-only' in flags_response.text - assert 'class="query-list-pill query-list-pill-private">Private' in flags_response.text - assert 'class="query-list-pill query-list-pill-trusted">Trusted' in flags_response.text + assert ( + 'class="query-list-pill query-list-pill-write">Writable' + in flags_response.text + ) + assert ( + 'class="query-list-pill query-list-pill-private">Private' + in flags_response.text + ) + assert ( + 'class="query-list-pill query-list-pill-trusted">Trusted' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_write=0">Read-only5' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_write=1">Writable1' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_private=0">Not private5' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_private=1">Private1' + in flags_response.text + ) assert "Only the owning actor can view this query." in flags_response.text - assert "Execution skips the usual SQL and write permission checks" in flags_response.text + assert ( + "Execution skips the usual SQL and write permission checks" + in flags_response.text + ) assert json_response.json()["queries"][0]["name"] == "demo_query_02" assert [query["name"] for query in filtered_response.json()["queries"]] == [ "private_query" ] + assert "Writable query" in filtered_write_response.text + assert "Demo query 01" not in filtered_write_response.text + assert ( + 'query-list-facet-link query-list-facet-link-active" href="/data/-/queries"' + in filtered_write_response.text + ) + assert ( + 'Read-only0' + not in filtered_write_response.text + ) + assert ( + 'href="/data/-/queries?is_write=1&is_private=0">Not private1' + in filtered_write_response.text + ) + assert ( + 'Private0' + not in filtered_write_response.text + ) + assert "Private query" in filtered_private_response.text + assert "Demo query 01" not in filtered_private_response.text + assert ( + 'href="/data/-/queries?is_private=1&is_write=0">Read-only1' + in filtered_private_response.text + ) + assert ( + 'Writable0' + not in filtered_private_response.text + ) + assert ( + 'Not private0' + not in filtered_private_response.text + ) @pytest.mark.asyncio @@ -1313,7 +1410,7 @@ async def test_user_writable_query_execution_rechecks_table_permissions(): "insert-row": {"id": "alice"}, } } - } + }, } } }, From 0fcaa5792ba73143661515af0088d7e5d968e96c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:12:07 -0700 Subject: [PATCH 172/203] Style query operations on create query Made it consistent with the SQL write page. --- .../_execute_write_analysis_styles.html | 37 +++++++++++++++++++ datasette/templates/execute_write.html | 36 +----------------- datasette/templates/query_create.html | 19 +++++----- tests/test_queries.py | 6 ++- 4 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 datasette/templates/_execute_write_analysis_styles.html diff --git a/datasette/templates/_execute_write_analysis_styles.html b/datasette/templates/_execute_write_analysis_styles.html new file mode 100644 index 00000000..f20e67b2 --- /dev/null +++ b/datasette/templates/_execute_write_analysis_styles.html @@ -0,0 +1,37 @@ + diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html index 46f58c3b..414d4af7 100644 --- a/datasette/templates/execute_write.html +++ b/datasette/templates/execute_write.html @@ -40,42 +40,8 @@ border-radius: 0.25rem; min-width: 13rem; } -.execute-write-analysis { - border-collapse: collapse; - font-size: 0.9rem; - margin: 0.25rem 0 1rem; - min-width: 44rem; -} -.execute-write-analysis th, -.execute-write-analysis td { - border-bottom: 1px solid #d7dde5; - padding: 0.45rem 0.7rem; - text-align: left; - vertical-align: top; -} -.execute-write-analysis th { - background-color: #edf6fb; - border-top: 1px solid #d7dde5; - color: #39445a; - font-weight: 700; -} -.execute-write-analysis tbody tr:nth-child(even) { - background-color: rgba(39, 104, 144, 0.05); -} -.execute-write-analysis code { - background: transparent; - font-size: 0.9em; - white-space: nowrap; -} -.execute-write-analysis-allowed { - color: #267a3e; - font-weight: 700; -} -.execute-write-analysis-denied { - color: #b00020; - font-weight: 700; -} +{% include "_execute_write_analysis_styles.html" %} {% include "_sql_parameter_styles.html" %} {% endblock %} diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index 686d971e..2d8a9122 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -5,6 +5,7 @@ {% block extra_head %} {{- super() -}} {% include "_codemirror.html" %} +{% include "_execute_write_analysis_styles.html" %} {% endblock %} {% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %} @@ -32,30 +33,28 @@

      Execute write SQL

      {% endif %} -

      Analysis

      +

      Query operations

      {% if analysis_error %}

      {{ analysis_error }}

      {% elif analysis_rows %} -
      Mode
      +
      - + - {% for row in analysis_rows %} - - - - - - + + + + + {% endfor %} diff --git a/tests/test_queries.py b/tests/test_queries.py index 36f7107a..c27c23da 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -998,7 +998,11 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): assert "Create query" in create_response.text assert "Read-only" in create_response.text assert "Writable" in create_response.text - assert "required permission" in create_response.text + assert "

      Query operations

      " in create_response.text + assert '
      Operation Database Tablerequired permissionRequired permission AllowedSource
      {{ row.operation }}{{ row.database }}{{ row.table }}{{ row.required_permission }}{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}{{ row.source or "" }}{{ row.operation }}{{ row.database }}{{ row.table }}{% if row.required_permission %}{{ row.required_permission }}{% endif %}{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}
      ' in create_response.text + assert '' in create_response.text + assert '' not in create_response.text + assert "" in create_response.text assert query_response.status_code == 200 assert "Save query" in query_response.text assert "/data/-/queries/-/create?sql=select+%2A+from+dogs" in query_response.text From 70b23ff4a55528083512fab96aa50725f415cbe4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:47:24 -0700 Subject: [PATCH 173/203] Tweaked save query link --- datasette/templates/query.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index f74d21f1..1900bd31 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -66,7 +66,7 @@ {% if not hide_sql %}{% endif %} {{ show_hide_hidden }} - {% if save_query_url %}Save query{% endif %} + {% if save_query_url %}Save this query{% endif %} {% if canned_query and edit_sql_url %}Edit SQL{% endif %}

      From eb7c25c57cf914629c08eaa477d0709b0f41efeb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:48:40 -0700 Subject: [PATCH 174/203] Major redesign of create saved query UI https://github.com/simonw/datasette/pull/2741#issuecomment-4548707129 --- datasette/app.py | 6 +- datasette/static/app.css | 4 + .../_execute_write_analysis_scripts.html | 111 +++++++ .../_execute_write_analysis_styles.html | 4 + .../templates/_sql_parameter_scripts.html | 17 +- datasette/templates/execute_write.html | 88 +----- datasette/templates/query_create.html | 296 +++++++++++++++--- datasette/views/database.py | 181 ++++++++--- tests/test_queries.py | 170 +++++++++- 9 files changed, 705 insertions(+), 172 deletions(-) create mode 100644 datasette/templates/_execute_write_analysis_scripts.html diff --git a/datasette/app.py b/datasette/app.py index 1acdfcd8..8936b099 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -50,7 +50,7 @@ from .views.database import ( ExecuteWriteView, TableCreateView, QueryView, - QueryCreateView, + QueryCreateAnalyzeView, QueryDeleteView, QueryDefinitionView, GlobalQueryListView, @@ -2820,8 +2820,8 @@ class Datasette: r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", ) add_route( - QueryCreateView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/-/create$", + QueryCreateAnalyzeView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/analyze$", ) add_route( QueryInsertView.as_view(self), diff --git a/datasette/static/app.css b/datasette/static/app.css index c21d0dc4..4f4db133 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1414,6 +1414,10 @@ svg.dropdown-menu-icon { position: relative; top: 1px; } +.save-query { + display: inline-block; + margin-left: 0.45em; +} .blob-download { display: block; diff --git a/datasette/templates/_execute_write_analysis_scripts.html b/datasette/templates/_execute_write_analysis_scripts.html new file mode 100644 index 00000000..a19bae13 --- /dev/null +++ b/datasette/templates/_execute_write_analysis_scripts.html @@ -0,0 +1,111 @@ + diff --git a/datasette/templates/_execute_write_analysis_styles.html b/datasette/templates/_execute_write_analysis_styles.html index f20e67b2..165cfe9f 100644 --- a/datasette/templates/_execute_write_analysis_styles.html +++ b/datasette/templates/_execute_write_analysis_styles.html @@ -34,4 +34,8 @@ color: #b00020; font-weight: 700; } +.execute-write-analysis-na { + color: #687386; + font-style: italic; +} diff --git a/datasette/templates/_sql_parameter_scripts.html b/datasette/templates/_sql_parameter_scripts.html index 68e46069..159a141c 100644 --- a/datasette/templates/_sql_parameter_scripts.html +++ b/datasette/templates/_sql_parameter_scripts.html @@ -215,9 +215,10 @@ window.datasetteSqlParameters = (() => { if (!form) { return null; } + const shouldRenderParameters = options.renderParameters !== false; const section = options.section || form.querySelector("[data-sql-parameters-section]"); - if (!section) { + if (shouldRenderParameters && !section) { return null; } const manager = { @@ -225,12 +226,16 @@ window.datasetteSqlParameters = (() => { section, allowExpand: options.allowExpand === undefined - ? section.dataset.allowExpand === "1" + ? section + ? section.dataset.allowExpand === "1" + : false : options.allowExpand, parameterState: new Map(), }; - bindParameterControls(manager); - syncParameterState(manager); + if (section) { + bindParameterControls(manager); + syncParameterState(manager); + } const url = options.url || form.dataset.parametersUrl; let refreshTimer = null; @@ -254,7 +259,9 @@ window.datasetteSqlParameters = (() => { if (!response.ok) { throw new Error((data.errors || [response.statusText]).join("; ")); } - renderParameters(manager, data.parameters || []); + if (shouldRenderParameters) { + renderParameters(manager, data.parameters || []); + } if (options.onData) { options.onData(data, manager); } diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html index 414d4af7..7a627a7a 100644 --- a/datasette/templates/execute_write.html +++ b/datasette/templates/execute_write.html @@ -131,6 +131,7 @@ if (executeWriteSqlInput && !executeWriteSqlInput.value) { {% include "_codemirror_foot.html" %} {% include "_sql_parameter_scripts.html" %} +{% include "_execute_write_analysis_scripts.html" %} + + {% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 2e77d36b..aafcf40b 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -551,6 +551,17 @@ def _wants_json(request, is_json, data): ) +def _query_create_form_error_message(message): + return { + "Query name is required": "URL is required", + "Invalid query name": "Invalid URL", + "Query name conflicts with a table or view": ( + "URL conflicts with an existing table or view" + ), + "Query already exists": "A query already exists at that URL", + }.get(message, message) + + async def _json_or_form_payload(request): content_type = request.headers.get("content-type", "") if content_type.startswith("application/json"): @@ -731,6 +742,54 @@ async def _execute_write_analysis_data(datasette, db, sql, actor): } +async def _query_create_analysis_data(datasette, db, sql, actor): + has_sql = bool(sql and sql.strip()) + parameter_names = [] + analysis_rows = [] + analysis_error = None + if has_sql: + try: + parameter_names = _derived_query_parameters(sql) + params = {parameter: "" for parameter in parameter_names} + analysis = await db.analyze_sql(sql, params) + analysis_rows = await _analysis_rows_with_permissions( + datasette, analysis, actor + ) + except (QueryValidationError, sqlite3.DatabaseError) as ex: + analysis_error = getattr(ex, "message", str(ex)) + return { + "ok": analysis_error is None, + "parameters": parameter_names, + "analysis_error": analysis_error, + "analysis_rows": analysis_rows, + "has_sql": has_sql, + "analysis_is_write": bool( + analysis_rows and any(row["required_permission"] for row in analysis_rows) + ), + "save_disabled": bool( + (not has_sql) + or analysis_error + or any(row["allowed"] is False for row in analysis_rows) + ), + } + + +async def _query_create_form_context( + datasette, request, db, *, sql="", name="", title="", description="", is_private=True +): + analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor) + return { + "database": db.name, + "database_color": db.color, + "sql": sql, + "name": name, + "title": title, + "description": description, + "is_private": is_private, + **analysis_data, + } + + async def _inserted_row_url(datasette, db, analysis, cursor): if cursor.rowcount != 1: return None @@ -1307,6 +1366,35 @@ class QueryCreateView(BaseView): name = "query-create" has_json_alternate = False + async def _render_form( + self, + request, + db, + *, + sql="", + name="", + title="", + description="", + is_private=True, + status=200, + ): + response = await self.render( + ["query_create.html"], + request, + await _query_create_form_context( + self.ds, + request, + db, + sql=sql, + name=name, + title=title, + description=description, + is_private=is_private, + ), + ) + response.status = status + return response + async def get(self, request): db = await self.ds.resolve_database(request) await self.ds.ensure_permission( @@ -1320,46 +1408,61 @@ class QueryCreateView(BaseView): actor=request.actor, ) - sql = request.args.get("sql") or "" - analysis_error = None - analysis_rows = [] - parameter_names = [] - if sql: - try: - parameter_names = _derived_query_parameters(sql) - params = {parameter: "" for parameter in parameter_names} - analysis = await db.analyze_sql(sql, params) - analysis_rows = await _analysis_rows_with_permissions( - self.ds, analysis, request.actor - ) - except (QueryValidationError, sqlite3.DatabaseError) as ex: - analysis_error = getattr(ex, "message", str(ex)) + return await self._render_form(request, db, sql=request.args.get("sql") or "") - return await self.render( - ["query_create.html"], - request, - { - "database": db.name, - "database_color": db.color, - "sql": sql, - "parameter_names": parameter_names, - "analysis_error": analysis_error, - "analysis_rows": analysis_rows, - "analysis_is_write": bool( - analysis_rows - and any(row["required_permission"] for row in analysis_rows) - ), - "save_disabled": bool( - analysis_error - or any(row["allowed"] is False for row in analysis_rows) - ), - }, + +class QueryCreateAnalyzeView(BaseView): + name = "query-create-analyze" + has_json_alternate = False + + async def get(self, request): + db = await self.ds.resolve_database(request) + if not await self.ds.allowed( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _block_framing(_error(["Permission denied: need execute-sql"], 403)) + if not await self.ds.allowed( + action="insert-query", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _block_framing(_error(["Permission denied: need insert-query"], 403)) + + invalid_keys = set(request.args) - {"sql"} + if invalid_keys: + return _block_framing( + _error( + ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], + 400, + ) + ) + sql = request.args.get("sql") or "" + return _block_framing( + Response.json( + await _query_create_analysis_data(self.ds, db, sql, request.actor) + ) ) -class QueryInsertView(BaseView): +class QueryInsertView(QueryCreateView): name = "query-insert" + async def _error_response(self, request, db, query_data, message, status): + message = _query_create_form_error_message(message) + self.ds.add_message(request, message, self.ds.ERROR) + return await self._render_form( + request, + db, + sql=query_data.get("sql") or "", + name=query_data.get("name") or "", + title=query_data.get("title") or "", + description=query_data.get("description") or "", + is_private=_as_bool(query_data.get("is_private", True)), + status=status, + ) + async def post(self, request): db = await self.ds.resolve_database(request) if not await self.ds.allowed( @@ -1375,6 +1478,8 @@ class QueryInsertView(BaseView): ): return _error(["Permission denied: need insert-query"], 403) + is_json = False + query_data = {} try: data, is_json = await _json_or_form_payload(request) if not isinstance(data, dict): @@ -1384,6 +1489,10 @@ class QueryInsertView(BaseView): raise QueryValidationError("JSON must contain a query dictionary") prepared = await _prepare_query_create(self.ds, request, db, query_data) except QueryValidationError as ex: + if not is_json and isinstance(query_data, dict): + return await self._error_response( + request, db, query_data, ex.message, ex.status + ) return _error([ex.message], ex.status) prepared.pop("analysis") @@ -1391,6 +1500,8 @@ class QueryInsertView(BaseView): try: await self.ds.add_query(db.name, name, replace=False, **prepared) except sqlite3.IntegrityError as ex: + if not is_json and isinstance(query_data, dict): + return await self._error_response(request, db, query_data, str(ex), 400) return _error([str(ex)], 400) query = await self.ds.get_query(db.name, name) @@ -1896,7 +2007,7 @@ class QueryView(View): ): save_query_url = ( datasette.urls.database(database) - + "/-/queries/-/create?" + + "/-/queries/insert?" + urlencode({"sql": sql}) ) diff --git a/tests/test_queries.py b/tests/test_queries.py index c27c23da..32cdfae3 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -986,6 +986,14 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): await ds.invoke_startup() create_response = await ds.client.get( + "/data/-/queries/insert?sql=select+*+from+dogs", + actor={"id": "root"}, + ) + blank_create_response = await ds.client.get( + "/data/-/queries/insert", + actor={"id": "root"}, + ) + old_create_response = await ds.client.get( "/data/-/queries/-/create?sql=select+*+from+dogs", actor={"id": "root"}, ) @@ -996,16 +1004,171 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): assert create_response.status_code == 200 assert "Create query" in create_response.text - assert "Read-only" in create_response.text assert "Writable" in create_response.text + assert 'type="radio"' not in create_response.text + assert 'name="parameters"' not in create_response.text + assert 'id="query-parameters"' not in create_response.text + assert 'class="query-create-field"' in create_response.text + assert '' not in create_response.text + assert '' in create_response.text + assert '' in create_response.text + assert '/data/' in create_response.text + assert ( + '' + in create_response.text + ) + assert 'function slugify(value)' in create_response.text + assert 'data-analyze-url="/data/-/queries/analyze"' in create_response.text + assert "setupSqlParameterRefresh" in create_response.text + assert "renderParameters: false" in create_response.text + assert "datasetteSqlAnalysis.renderAnalysis" in create_response.text + assert "data-query-create-submit" in create_response.text + assert "data-query-create-writable" in create_response.text + assert ( + "Queries marked private can only be seen by you, their creator." + in create_response.text + ) assert "

      Query operations

      " in create_response.text assert '
      Required permissionSourceread
      ' in create_response.text assert '' in create_response.text assert '' not in create_response.text assert "" in create_response.text + assert ( + create_response.text.count( + '' + ) + == 2 + ) + assert create_response.text.index('value="Save query"') < create_response.text.index( + "

      Query operations

      " + ) + assert blank_create_response.status_code == 200 + assert ( + '
      Required permissionSourcereadn/a
      ' in response.text assert '' in response.text assert "" in response.text From 5dca2dc9beea96c52e6a9c806df66c9a1f2f7874 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:54:47 -0700 Subject: [PATCH 175/203] Show query count on database page --- datasette/templates/database.html | 2 +- datasette/views/database.py | 18 +++++++++++++++++- tests/test_queries.py | 11 ++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 62f9c620..371f6a22 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -59,7 +59,7 @@ {% endfor %} {% if queries_more %} -

      View all queries

      +

      View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}

      {% endif %} {% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index feb38619..d40d69d1 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -102,6 +102,11 @@ class DatabaseView(View): ) canned_queries = queries_page["queries"] queries_more = queries_page["has_more"] + queries_count = ( + await datasette.count_queries(database, actor=request.actor) + if queries_more + else len(canned_queries) + ) async def database_actions(): links = [] @@ -134,6 +139,7 @@ class DatabaseView(View): "views": sql_views, "queries": canned_queries, "queries_more": queries_more, + "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, "table_columns": ( await _table_columns(datasette, database) if allow_execute_sql else {} @@ -168,6 +174,7 @@ class DatabaseView(View): views=sql_views, queries=canned_queries, queries_more=queries_more, + queries_count=queries_count, allow_execute_sql=allow_execute_sql, table_columns=( await _table_columns(datasette, database) @@ -219,6 +226,7 @@ class DatabaseContext(Context): queries_more: bool = field( metadata={"help": "Boolean indicating if more saved queries are available"} ) + queries_count: int = field(metadata={"help": "Count of visible saved queries"}) allow_execute_sql: bool = field( metadata={"help": "Boolean indicating if custom SQL can be executed"} ) @@ -775,7 +783,15 @@ async def _query_create_analysis_data(datasette, db, sql, actor): async def _query_create_form_context( - datasette, request, db, *, sql="", name="", title="", description="", is_private=True + datasette, + request, + db, + *, + sql="", + name="", + title="", + description="", + is_private=True, ): analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor) return { diff --git a/tests/test_queries.py b/tests/test_queries.py index 32cdfae3..09b41645 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -458,9 +458,10 @@ async def test_database_page_query_preview_is_limited(): assert html_response.status_code == 200 assert "Demo query 05" in html_response.text assert "Demo query 06" not in html_response.text - assert 'href="/data/-/queries"' in html_response.text + assert 'View 25 queries' in html_response.text assert len(json_response.json()["queries"]) == 5 assert json_response.json()["queries_more"] is True + assert json_response.json()["queries_count"] == 25 @pytest.mark.asyncio @@ -1017,7 +1018,7 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): '' in create_response.text ) - assert 'function slugify(value)' in create_response.text + assert "function slugify(value)" in create_response.text assert 'data-analyze-url="/data/-/queries/analyze"' in create_response.text assert "setupSqlParameterRefresh" in create_response.text assert "renderParameters: false" in create_response.text @@ -1039,9 +1040,9 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): ) == 2 ) - assert create_response.text.index('value="Save query"') < create_response.text.index( - "

      Query operations

      " - ) + assert create_response.text.index( + 'value="Save query"' + ) < create_response.text.index("

      Query operations

      ") assert blank_create_response.status_code == 200 assert ( '
      Required permissioninsert
      ' in create_response.text assert '' in create_response.text @@ -1053,6 +1067,12 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): "

      Analysis will show each affected table and required permission.

      " not in blank_create_response.text ) + assert "Enter SQL to analyze this query." in blank_create_response.text + assert write_create_response.status_code == 200 + assert ( + 'This query updates data in the database.' + in write_create_response.text + ) assert query_response.status_code == 200 assert "Save this query" in query_response.text assert "/data/-/queries/insert?sql=select+%2A+from+dogs" in query_response.text From 024b9117725bbed17396a5a4b3f48663c23337f5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:09:53 -0700 Subject: [PATCH 177/203] Clarifying comment https://github.com/simonw/datasette/pull/2741/changes#r3306856046 --- datasette/default_permissions/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index a9f2d8bd..6cd46f04 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -26,6 +26,7 @@ from .restrictions import ( from .root import root_user_permissions_sql as root_user_permissions_sql from .config import config_permissions_sql as config_permissions_sql from .defaults import ( + # Avoid "datasette.default_permissions" does not explicitly export attribute default_allow_sql_check as default_allow_sql_check, default_action_permissions_sql as default_action_permissions_sql, default_query_permissions_sql as default_query_permissions_sql, From ac6ee097dd06050188d44c6d4b17a98a12c7b481 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:10:48 -0700 Subject: [PATCH 178/203] Disallow update/delete of private queries If a user does not own a private query they cannot update or delete it either, even if they have global update-query. https://github.com/simonw/datasette/pull/2741/changes#r3306417463 --- datasette/default_permissions/defaults.py | 33 ++++----- tests/test_queries.py | 81 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 32ad4ef1..5bc74425 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -77,36 +77,31 @@ async def default_query_permissions_sql( ) -> Optional[PermissionSQL]: actor_id = actor.get("id") if isinstance(actor, dict) else None - if action in {"update-query", "delete-query"}: - if actor_id is None: - return None - # Query owner can update/delete query - return PermissionSQL( - sql=""" - SELECT database_name AS parent, name AS child, 1 AS allow, - 'query owner' AS reason - FROM queries - WHERE source = 'user' - AND owner_id = :query_owner_id - """, - params={"query_owner_id": actor_id}, - ) - - if action != "view-query": + if action not in {"view-query", "update-query", "delete-query"}: return None params = {"query_owner_id": actor_id} rule_sqls = [] if actor_id is not None: - # Query owner can view-query - rule_sqls.append(""" + if action in {"update-query", "delete-query"}: + # Query owner can update/delete query + rule_sqls.append(""" + SELECT database_name AS parent, name AS child, 1 AS allow, + 'query owner' AS reason + FROM queries + WHERE source = 'user' + AND owner_id = :query_owner_id + """) + else: + # Query owner can view-query + rule_sqls.append(""" SELECT database_name AS parent, name AS child, 1 AS allow, 'query owner' AS reason FROM queries WHERE owner_id = :query_owner_id """) - # restriction_sql enforces private queries ONLY visible to owner + # restriction_sql enforces private queries ONLY visible/mutable by owner return PermissionSQL( sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, restriction_sql=""" diff --git a/tests/test_queries.py b/tests/test_queries.py index f888dda0..26a0748c 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1581,6 +1581,87 @@ async def test_query_owner_gets_update_delete_and_writable_view_defaults(): ) +@pytest.mark.asyncio +async def test_private_query_restricts_broad_update_delete_permissions(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "update-query": {"id": "bob"}, + "delete-query": {"id": "bob"}, + }, + }, + }, + }, + ) + ds.add_memory_database("query_broad_update_delete", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "alice_private", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + await ds.add_query( + "data", + "alice_public", + "select 2", + is_private=False, + source="user", + owner_id="alice", + ) + + for action in ("update-query", "delete-query"): + assert await ds.allowed( + action=action, + resource=QueryResource("data", "alice_private"), + actor={"id": "alice"}, + ) + assert not await ds.allowed( + action=action, + resource=QueryResource("data", "alice_private"), + actor={"id": "bob"}, + ) + assert await ds.allowed( + action=action, + resource=QueryResource("data", "alice_public"), + actor={"id": "bob"}, + ) + + private_update_response = await ds.client.post( + "/data/alice_private/-/update", + actor={"id": "bob"}, + json={"update": {"title": "Nope"}}, + ) + private_delete_response = await ds.client.post( + "/data/alice_private/-/delete", + actor={"id": "bob"}, + json={}, + ) + public_update_response = await ds.client.post( + "/data/alice_public/-/update", + actor={"id": "bob"}, + json={"update": {"title": "Bob can edit public queries"}}, + ) + public_delete_response = await ds.client.post( + "/data/alice_public/-/delete", + actor={"id": "bob"}, + json={}, + ) + + assert private_update_response.status_code == 403 + assert private_delete_response.status_code == 403 + assert public_update_response.status_code == 200 + assert public_delete_response.status_code == 200 + assert await ds.get_query("data", "alice_private") is not None + assert await ds.get_query("data", "alice_public") is None + + @pytest.mark.asyncio async def test_user_writable_query_execution_rechecks_table_permissions(): ds = Datasette( From 180a6a86fd77ac43f6cf3bfb7d7f9150003da419 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:16:10 -0700 Subject: [PATCH 179/203] Remove queries-plan.md We do not need this any more. It can live forever in Git history. --- queries-plan.md | 446 ------------------------------------------------ 1 file changed, 446 deletions(-) delete mode 100644 queries-plan.md diff --git a/queries-plan.md b/queries-plan.md deleted file mode 100644 index da6b7c92..00000000 --- a/queries-plan.md +++ /dev/null @@ -1,446 +0,0 @@ -# Queries in the internal database - -Plan for . - -## Goal - -Move named query definitions into Datasette's internal database, so hundreds or thousands of queries can be listed, searched, permission-filtered, managed, and executed efficiently. - -Terminology change: these are now "queries", not "canned queries". Legacy code and documentation can mention the old name only when describing compatibility or migration. - -## Decisions so far - -- Internal table name: `queries`. -- Query definitions should use real columns, not a JSON blob for all options. -- Query parameter names live in a `parameters` text column as a JSON array. No default values for parameters in this pass. -- No separate index is needed for the privacy/trust flags yet. -- User-created queries require `execute-sql` and `insert-query` on the database. They default to private, and writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`. -- Configured queries default to trusted, which means actors who can view them can execute them without also holding `execute-sql` or the relevant write permissions. Config can opt out with `is_trusted: false`. -- Add `update-query` and `delete-query`, so administrators can manage queries created by other users. -- Remove the old `canned_queries()` hook from core. If we want compatibility later, build a separate `datasette-old-canned-queries` plugin. -- Writable user-created queries can be supported using `Database.analyze_sql()`, provided we fail closed when analysis cannot prove the required permissions. - -## Current shape - -- Query definitions currently come from `datasette.yaml` or the `canned_queries()` plugin hook. -- `Datasette.get_canned_queries(database_name, actor)` calls that hook every time it needs query definitions. -- `QueryResource.resources_sql()` currently enumerates databases and calls the hook for each one, because permissions and `/-/jump` need query resources. -- Query pages are visible if the actor has `view-query` for `QueryResource(database, query)`. Executing an untrusted stored query also checks `execute-sql` or the relevant write permissions. -- Arbitrary SQL executes if the actor has `execute-sql` for `DatabaseResource(database)`. - -The main performance and architecture win is making query resource enumeration a direct SQL query against the internal database. - -## Proposed internal schema - -Start with one `queries` table. - -```sql -CREATE TABLE IF NOT EXISTS queries ( - database_name TEXT NOT NULL, - name TEXT NOT NULL, - sql TEXT NOT NULL, - title TEXT, - description TEXT, - description_html TEXT, - options TEXT NOT NULL DEFAULT '{}', - parameters TEXT NOT NULL DEFAULT '[]', - is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), - is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), - source TEXT NOT NULL DEFAULT 'user', - owner_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (database_name, name) -); - -CREATE INDEX IF NOT EXISTS queries_owner_idx - ON queries(owner_id); -``` - -Column notes: - -- `database_name`, `name`, and `sql` are the routing and execution core. -- Display fields become columns: `title`, `description`, and `description_html`. -- Less common presentation and writable-query behavior lives in `options`, stored as a JSON object. That covers `hide_sql`, `fragment`, `on_success_message`, `on_success_message_sql`, `on_success_redirect`, `on_error_message`, and `on_error_redirect`. -- `parameters` is a JSON array of parameter names, stored as text. This preserves explicit parameter order, but does not support labels or default values. -- Existing writable query behavior gets `is_write` as a column. Success/error messages, success/error redirects, and `on_success_message_sql` are stored in `options`. -- `is_private` means the query is only visible to its owning actor. This is enforced as a permission restriction, so broader `view-query` grants do not expose private rows. -- `is_trusted` means execution skips the usual `execute-sql` or write-permission checks after `view-query` has allowed access. -- `source` distinguishes `user`, `config`, and `plugin` rows. -- `owner_id` is the actor id for user-created rows. It is `NULL` for config/plugin rows. - -No separate index is needed on `(database_name, name)` because the primary key already creates one. - -`QueryResource.resources_sql()` can become: - -```sql -SELECT q.database_name AS parent, q.name AS child -FROM queries q -JOIN catalog_databases cd ON cd.database_name = q.database_name -``` - -The join keeps persisted queries for detached databases from appearing as live resources. - -## Config and plugin migration - -`datasette.yaml` can continue to support `databases: {db}: queries:` blocks, but core should import them directly into the internal `queries` tables at startup: - -1. Ensure the internal schema exists. -2. Delete previous `source='config'` rows. -3. Read configured query blocks for each live database. -4. Normalize string definitions to `{"sql": ...}`. -5. Insert rows into `queries`, storing explicit `params` as JSON in `parameters`. - -Plugins should move to: - -```python -await datasette.add_query(...) -await datasette.remove_query(...) -``` - -Remove the old `canned_queries()` hookspec and all core calls to it. If compatibility is needed, build `datasette-old-canned-queries` later as a plugin that restores the hook and imports old hook results using `datasette.add_query()`. - -## Permission model - -Add core actions: - -- `insert-query`, database-level, for creating queries in a database. -- `update-query`, query-level, for modifying existing query definitions. -- `delete-query`, query-level, for deleting existing query definitions. - -User-created query creation requires: - -- `execute-sql` on `DatabaseResource(database)` -- `insert-query` on `DatabaseResource(database)` -- If analysis shows the query is writable, the table-level write permissions described in the writable query section. - -Updating an existing query requires: - -- `update-query` on `QueryResource(database, query)` or default owner permission for a user-owned row. -- If the SQL changes, also require `execute-sql` on the database. -- If the changed SQL is writable, also require the table-level write permissions described in the writable query section. - -Deleting an existing query requires: - -- `delete-query` on `QueryResource(database, query)` or default owner permission for a user-owned row. - -Default owner permissions: - -- For `source='user' AND owner_id = actor.id`, grant `update-query` and `delete-query`. -- For `source='user' AND owner_id = actor.id`, grant `view-query`. If the query is private, restriction SQL ensures no other actor sees it through a broader grant. - -## Executing queries - -Default execution rule for read-only queries: - -- If `is_trusted=0`, the actor needs `execute-sql` on the database. -- If `is_trusted=1`, the actor can execute the query without `execute-sql`, provided `view-query` allows access. - -Default execution rule for user-created writable queries: - -- `is_trusted` must be `0`. -- The actor must have `view-query`. -- The actor must currently have every write permission required by fresh `Database.analyze_sql()` results for the query SQL. - -Implementation: - -- Keep `view-query` in the broad `DEFAULT_ALLOW_ACTIONS` set, so saved queries remain visible by default in all-public Datasette. -- Emit default `view-query` allows for the owning actor. -- Use `restriction_sql` to limit private rows to their owner even when broader `view-query` permissions exist. -- Have `QueryView` perform the fresh `execute-sql` or table-permission check before execution unless the row has `is_trusted=1`. - -For read-only queries this keeps `QueryView` explicit: it checks `view-query` for the query resource, then checks `execute-sql` unless the row is trusted. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis. - -Explicit deny rules should still be able to block a query, and `--default-deny` still blocks trusted queries unless something grants `view-query`. - -## Writable queries - -Writable user-created queries should be in scope, guarded by `Database.analyze_sql()`. - -The secure rule: a user can create, update, or execute a writable user-created query only if they currently have the corresponding write permissions for every table the SQL can affect. - -`Database.analyze_sql(sql, params=None)` runs the SQL through SQLite's authorizer on an isolated connection and returns a `SQLAnalysis` object containing `SQLTableAccess` rows: - -- `operation`: `read`, `insert`, `update`, or `delete` -- `database`: Datasette database name for `main`, or SQLite schema name where no Datasette mapping exists -- `table`: affected table or view -- `columns`: read/updated columns where SQLite reports them -- `source`: trigger/view/CTE source when SQLite reports one - -Validation flow for user-created queries: - -1. Derive named parameters from the SQL and pass harmless placeholder values into `db.analyze_sql()` so SQLite can prepare statements with bindings. -2. If analysis raises a SQLite error, reject the query. -3. If every table access is `read`, treat the query as read-only and require `execute-sql` plus `insert-query`/`update-query` as described above. -4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_trusted=0`. -5. Reject writable user-created queries that access a database other than the database they are being saved against, until `analyze_sql()` can reliably map attached SQLite schemas back to Datasette database names. -6. For every write access returned by analysis, require the corresponding permission on `TableResource(access.database, access.table)`: - - `insert` -> `insert-row` - - `update` -> `update-row` - - `delete` -> `delete-row` -7. Include write accesses reported from triggers and views, since those are real side effects. -8. Re-run the same analysis and permission checks when SQL changes through `update_query()` or `POST .../-/update`. -9. Re-run analysis before executing user-created writable queries, so schema or trigger changes cannot leave a previously saved query with stale permission assumptions. - -The user-facing API should not trust a submitted `is_write` value. It should derive `is_write` from analysis. - -Trusted configuration and plugin code can still call `datasette.add_query(..., is_write=True, ...)`. Those are treated as deployment/admin-authored queries. They keep the existing execution model: they require `view-query`, and the default `view-query` hook should preserve current default-open behavior for trusted writable queries while still respecting `--default-deny`. - -Fail closed cases for user-created writable queries: - -- Analysis fails. -- Analysis reports any write operation that cannot be mapped to a Datasette table resource. -- Analysis reports writes outside the target database. -- The actor lacks any required table write permission. -- `is_trusted=1` is requested through the user-facing API. - -This gives us writable user-created queries without letting `execute-sql` alone become a path to create arbitrary write endpoints. - -## HTTP API sketch - -JSON endpoints should follow Datasette's existing write API style: use `POST` plus action paths such as `/-/insert`, `/-/update`, and `/-/delete`, not HTTP `PATCH` or `DELETE`. - -Endpoints: - -- `GET /-/queries` and `GET /{database}/-/queries` show searchable HTML query browsers. `GET /-/queries.json` lists query definitions across every database the actor can view; `GET /{database}/-/queries.json` scopes that list to one database. Both JSON endpoints use cursor pagination with `_next` and `_size`. -- `POST /{database}/-/queries/insert` creates a query. -- `GET /{database}/{query}/-/definition` returns one query definition without executing it. -- `POST /{database}/{query}/-/update` updates one query. -- `POST /{database}/{query}/-/delete` deletes one query. - -Create request: - -```json -{ - "query": { - "name": "top_customers", - "sql": "select * from customers order by revenue desc limit 20", - "title": "Top customers", - "description": "Highest revenue customers", - "is_private": true, - "parameters": ["region"] - } -} -``` - -Successful create returns `201` and the created query definition: - -```json -{ - "ok": true, - "query": { - "database": "fixtures", - "name": "top_customers", - "sql": "select * from customers order by revenue desc limit 20", - "title": "Top customers", - "description": "Highest revenue customers", - "is_private": true, - "is_trusted": false, - "parameters": ["region"] - } -} -``` - -Update request, imitating `RowUpdateView`: - -```json -{ - "update": { - "title": "Top customers by revenue", - "is_private": false - }, - "return": true -} -``` - -Successful update returns `{"ok": true}` by default. With `"return": true`, return the updated query definition: - -```json -{ - "ok": true, - "query": { - "database": "fixtures", - "name": "top_customers", - "sql": "select * from customers order by revenue desc limit 20", - "title": "Top customers by revenue", - "is_private": false, - "is_trusted": false - } -} -``` - -Delete request: - -```http -POST /{database}/{query}/-/delete -Content-Type: application/json -``` - -Successful delete returns: - -```json -{ - "ok": true -} -``` - -Validation: - -- Update bodies must be dictionaries containing an `update` dictionary, with optional `return`; invalid keys return `{"ok": false, "errors": [...]}`. -- Validate route-safe query names. -- Reject names that collide with a table or view in the same database, since table routes currently win over query routes. -- Analyze user-created SQL with `Database.analyze_sql()`. -- Use `validate_sql_select(sql)` as the read-only fast path when analysis shows only reads, but do not require it for writable queries that pass analysis and permission checks. -- Reject magic parameters such as `:_actor_id`, `:_cookie_*`, and `:_header_*` for user-created queries. -- Reject client-supplied `is_write`; derive it from analysis. -- Reject writable-only success/error fields for read-only queries. - -## Python API sketch - -Add methods on `Datasette`: - -```python -await datasette.add_query( - database, - name, - sql, - title=None, - description=None, - description_html=None, - hide_sql=False, - fragment=None, - parameters=None, - is_write=False, - is_private=False, - is_trusted=False, - source="plugin", - owner_id=None, - on_success_message=None, - on_success_message_sql=None, - on_success_redirect=None, - on_error_message=None, - on_error_redirect=None, - replace=True, -) - -await datasette.update_query( - database, - name, - *, - sql=UNCHANGED, - title=UNCHANGED, - description=UNCHANGED, - description_html=UNCHANGED, - hide_sql=UNCHANGED, - fragment=UNCHANGED, - parameters=UNCHANGED, - is_write=UNCHANGED, - is_private=UNCHANGED, - is_trusted=UNCHANGED, - source=UNCHANGED, - owner_id=UNCHANGED, - on_success_message=UNCHANGED, - on_success_message_sql=UNCHANGED, - on_success_redirect=UNCHANGED, - on_error_message=UNCHANGED, - on_error_redirect=UNCHANGED, -) - -await datasette.remove_query(database, name, source=None) - -await datasette.get_query(database, name) -await datasette.list_queries( - database, - actor=None, - limit=50, - cursor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, -) -``` - -`list_queries()` should return a bounded page shaped like `{"queries": [...], "next": "...", "has_more": true, "limit": 50}`. The `next` value is an opaque cursor token, not an offset. Passing `database=None` lists visible queries across all live databases, still filtered through `view-query` permission SQL. - -`update_query()` should use an internal sentinel default such as `UNCHANGED = object()` so callers can distinguish "leave this column alone" from "set this column to `NULL`": - -```python -await datasette.update_query( - "fixtures", - "top_customers", - on_success_redirect=None, -) -``` - -For column-backed fields, `None` should write SQL `NULL`. For option fields, `None` should remove that key from the JSON object so `get_query()` returns `None`; omitting the field should leave the existing option unchanged. - -Implementation detail: build the `UPDATE` statement dynamically from fields whose value is not `UNCHANGED`, validate non-nullable fields before writing, and update `updated_at` whenever at least one field changes. - -The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_private`, `is_trusted`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`. - -## Query page save UI - -On `/{database}/-/query`, if the actor has both `execute-sql` and `insert-query`, show a save control for valid read-only SQL. That page already executes read-only arbitrary SQL, so the first UI can stay read-only even though the JSON API can accept writable SQL after `Database.analyze_sql()` validation. - -The save form should call `POST /{database}/-/queries/insert` and default to `is_private=true`. - -On `/{database}`, show a preview of the first 5 visible queries using `list_queries(..., limit=5)`. If the page has `has_more`, show a link to `/{database}/-/queries` rather than rendering hundreds or thousands of query links inline. The full `/{database}/-/queries` page provides search, filters, and cursor pagination. The global `/-/queries` page reuses the same interface and shows the database for each query. - -## Dedicated create query UI - -Add `/{database}/-/queries/-/create` for the fuller query authoring flow, including writable queries. - -This page should require `execute-sql` and `insert-query` to access. It should provide a SQL editor and a mode control: - -- Read-only -- Writable - -Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and privacy status. - -Writable mode should always run `Database.analyze_sql()` and show an analysis panel before saving: - -- detected operation -- database and table -- required permission -- whether the actor has that permission -- source, when the operation comes from a trigger or view - -The Save button should be disabled until analysis succeeds and every required table write permission is allowed. - -The existing edit-SQL flow from query pages can continue to point back to arbitrary SQL. A later enhancement can add "update this query" when the actor owns it or has `update-query`. - -## Test plan - -- Internal schema creates `queries`. -- Query parameters are stored in the `queries.parameters` text column as a JSON array of names. -- Config `queries:` blocks import into internal tables. -- Legacy string query definitions normalize to SQL rows. -- The old `canned_queries()` hook is no longer called by core. -- `QueryResource.resources_sql()` returns rows from `queries`. -- Database page and `/-/jump` list queries from the internal DB. -- `view-query` remains globally default-allowed, with `restriction_sql` narrowing private queries to their owner. -- Private query is only visible to its owner, even when a broader `view-query` rule applies. -- Non-trusted read-only query requires `execute-sql` to execute. -- Trusted read-only query can be executed without `execute-sql` after `view-query` passes. -- Config queries default to trusted and can opt out with `is_trusted: false`. -- User API rejects client-supplied `is_trusted`. -- User-created query requires both `execute-sql` and `insert-query`. -- User-created writable query creation uses `Database.analyze_sql()` and requires matching `insert-row`, `update-row`, and/or `delete-row` permissions for every reported write access. -- `/{database}/-/queries/-/create` provides the writable-query authoring UI with an analysis panel and disabled save until all required write permissions pass. -- User-created writable query execution re-runs `Database.analyze_sql()` and re-checks table write permissions. -- User-created writable query cannot be trusted through the user API. -- Query update uses `POST /{database}/{query}/-/update` with an `{"update": {...}}` body. -- Query delete uses `POST /{database}/{query}/-/delete`. -- There are no `PATCH` or HTTP `DELETE` routes for query management. -- `datasette.update_query(..., field=None)` writes `NULL` for column-backed fields and removes JSON keys for option fields, while omitted fields are left unchanged. -- Owner gets default `update-query` and `delete-query` for their own user-created rows. -- Admin can manage other users' queries with `update-query` and `delete-query`. -- User API rejects magic parameters. -- User API rejects writable queries if analysis fails, reports writes outside the target database, or reports writes the actor is not allowed to perform. -- Trusted config/plugin writable queries still execute through `view-query`. -- Trusted config/plugin writable queries are not default-allowed under `--default-deny`. -- Persisted internal DB does not expose queries for detached databases. From 24887004cffd52fe801ecd73da78e13b246ddede Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:51:57 -0700 Subject: [PATCH 180/203] Rename insert-query to store-query Also queries/insert to queries/store Refs https://github.com/simonw/datasette/pull/2741#issuecomment-4549103663 --- datasette/app.py | 6 ++--- datasette/default_actions.py | 6 ++--- datasette/templates/query_create.html | 2 +- datasette/views/database.py | 22 +++++++-------- docs/authentication.rst | 7 ++--- docs/json_api.rst | 5 ++-- tests/test_queries.py | 39 +++++++++++++++------------ 7 files changed, 47 insertions(+), 40 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 8936b099..42a2d27d 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -54,9 +54,9 @@ from .views.database import ( QueryDeleteView, QueryDefinitionView, GlobalQueryListView, - QueryInsertView, QueryListView, QueryParametersView, + QueryStoreView, QueryUpdateView, ) from .views.index import IndexView @@ -2824,8 +2824,8 @@ class Datasette: r"/(?P[^\/\.]+)/-/queries/analyze$", ) add_route( - QueryInsertView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/insert$", + QueryStoreView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/store$", ) add_route( ExecuteWriteAnalyzeView.as_view(self), diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 6a1f77b8..0f4c25fa 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -62,9 +62,9 @@ def register_actions(): resource_class=DatabaseResource, ), Action( - name="insert-query", - abbr="iq", - description="Create saved queries", + name="store-query", + abbr="sq", + description="Create stored queries", resource_class=DatabaseResource, also_requires="execute-sql", ), diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index cb14ada4..f5dadbff 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -156,7 +156,7 @@ form.sql .query-create-sql textarea#sql-editor {

      Create query

      -
      +

      {{ urls.database(database) }}/

      diff --git a/datasette/views/database.py b/datasette/views/database.py index d40d69d1..900b94ba 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1419,7 +1419,7 @@ class QueryCreateView(BaseView): actor=request.actor, ) await self.ds.ensure_permission( - action="insert-query", + action="store-query", resource=DatabaseResource(db.name), actor=request.actor, ) @@ -1440,11 +1440,11 @@ class QueryCreateAnalyzeView(BaseView): ): return _block_framing(_error(["Permission denied: need execute-sql"], 403)) if not await self.ds.allowed( - action="insert-query", + action="store-query", resource=DatabaseResource(db.name), actor=request.actor, ): - return _block_framing(_error(["Permission denied: need insert-query"], 403)) + return _block_framing(_error(["Permission denied: need store-query"], 403)) invalid_keys = set(request.args) - {"sql"} if invalid_keys: @@ -1462,8 +1462,8 @@ class QueryCreateAnalyzeView(BaseView): ) -class QueryInsertView(QueryCreateView): - name = "query-insert" +class QueryStoreView(QueryCreateView): + name = "query-store" async def _error_response(self, request, db, query_data, message, status): message = _query_create_form_error_message(message) @@ -1488,11 +1488,11 @@ class QueryInsertView(QueryCreateView): ): return _error(["Permission denied: need execute-sql"], 403) if not await self.ds.allowed( - action="insert-query", + action="store-query", resource=DatabaseResource(db.name), actor=request.actor, ): - return _error(["Permission denied: need insert-query"], 403) + return _error(["Permission denied: need store-query"], 403) is_json = False query_data = {} @@ -1961,8 +1961,8 @@ class QueryView(View): resource=DatabaseResource(database=database), actor=request.actor, ) - allow_insert_query = await datasette.allowed( - action="insert-query", + allow_store_query = await datasette.allowed( + action="store-query", resource=DatabaseResource(database=database), actor=request.actor, ) @@ -2020,13 +2020,13 @@ class QueryView(View): if ( not canned_query and allow_execute_sql - and allow_insert_query + and allow_store_query and is_validated_sql and ":_" not in sql ): save_query_url = ( datasette.urls.database(database) - + "/-/queries/insert?" + + "/-/queries/store?" + urlencode({"sql": sql}) ) diff --git a/docs/authentication.rst b/docs/authentication.rst index 453aaa19..184fec5e 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1293,11 +1293,12 @@ Actor is allowed to view a saved query page, e.g. https://latest.datasette.io/fi ``query`` is the name of the query (string) .. _actions_insert_query: +.. _actions_store_query: -insert-query ------------- +store-query +----------- -Actor is allowed to create saved queries in a database. +Actor is allowed to create stored queries in a database. ``resource`` - ``datasette.resources.DatabaseResource(database)`` ``database`` is the name of the database (string) diff --git a/docs/json_api.rst b/docs/json_api.rst index dd54c459..1a6c7021 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -518,14 +518,15 @@ Listing saved queries Creating saved queries in the UI ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``GET //-/queries/-/create`` provides a form for creating saved queries. +``GET //-/queries/store`` provides a form for creating stored queries. +.. _QueryStoreView: .. _QueryInsertView: Creating saved queries ~~~~~~~~~~~~~~~~~~~~~~ -``POST //-/queries/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database. +``POST //-/queries/store`` creates a stored query. This requires ``execute-sql`` and ``store-query`` for the database. .. _QueryParametersView: .. _ExecuteWriteView: diff --git a/tests/test_queries.py b/tests/test_queries.py index 26a0748c..5d4da9bb 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -470,7 +470,7 @@ async def test_query_actions_are_registered(): await ds.invoke_startup() assert ds.get_action("execute-write-sql").resource_class is DatabaseResource - assert ds.get_action("insert-query").resource_class is DatabaseResource + assert ds.get_action("store-query").resource_class is DatabaseResource assert ds.get_action("update-query").resource_class is QueryResource assert ds.get_action("delete-query").resource_class is QueryResource @@ -537,15 +537,15 @@ async def test_analyze_write_query_rejects_writes_to_attached_databases(): @pytest.mark.asyncio -async def test_query_insert_api_creates_read_only_query(): +async def test_query_store_api_creates_read_only_query(): ds = Datasette(memory=True, default_deny=True) ds.root_enabled = True - db = ds.add_memory_database("query_insert_api", name="data") + db = ds.add_memory_database("query_store_api", 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/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, json={ "query": { @@ -860,7 +860,7 @@ async def test_global_query_list_api_and_html(): @pytest.mark.asyncio -async def test_query_insert_api_rejects_is_trusted(): +async def test_query_store_api_rejects_is_trusted(): ds = Datasette( memory=True, default_deny=True, @@ -870,7 +870,7 @@ async def test_query_insert_api_rejects_is_trusted(): "permissions": { "view-database": {"id": "writer"}, "execute-sql": {"id": "writer"}, - "insert-query": {"id": "writer"}, + "store-query": {"id": "writer"}, } } } @@ -880,7 +880,7 @@ async def test_query_insert_api_rejects_is_trusted(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "writer"}, json={"query": {"name": "trusted", "sql": "select 1", "is_trusted": True}}, ) @@ -890,7 +890,7 @@ async def test_query_insert_api_rejects_is_trusted(): @pytest.mark.asyncio -async def test_query_insert_api_creates_writable_query(): +async def test_query_store_api_creates_writable_query(): ds = Datasette(memory=True, default_deny=True) ds.root_enabled = True db = ds.add_memory_database("query_write_api", name="data") @@ -898,7 +898,7 @@ async def test_query_insert_api_creates_writable_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, json={ "query": { @@ -962,14 +962,14 @@ async def test_query_update_and_delete_api(): @pytest.mark.asyncio -async def test_query_insert_api_rejects_magic_parameters(): +async def test_query_store_api_rejects_magic_parameters(): ds = Datasette(memory=True, default_deny=True) ds.root_enabled = True ds.add_memory_database("query_magic_api", name="data") await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, json={"query": {"name": "magic", "sql": "select :_actor_id"}}, ) @@ -987,15 +987,19 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): await ds.invoke_startup() create_response = await ds.client.get( - "/data/-/queries/insert?sql=select+*+from+dogs", + "/data/-/queries/store?sql=select+*+from+dogs", actor={"id": "root"}, ) write_create_response = await ds.client.get( - "/data/-/queries/insert?sql=insert+into+dogs+(name)+values+('Cleo')", + "/data/-/queries/store?sql=insert+into+dogs+(name)+values+('Cleo')", actor={"id": "root"}, ) blank_create_response = await ds.client.get( - "/data/-/queries/insert", + "/data/-/queries/store", + actor={"id": "root"}, + ) + old_insert_response = await ds.client.get( + "/data/-/queries/insert?sql=select+*+from+dogs", actor={"id": "root"}, ) old_create_response = await ds.client.get( @@ -1075,7 +1079,8 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): ) assert query_response.status_code == 200 assert "Save this query" in query_response.text - assert "/data/-/queries/insert?sql=select+%2A+from+dogs" in query_response.text + assert "/data/-/queries/store?sql=select+%2A+from+dogs" in query_response.text + assert old_insert_response.status_code == 404 assert old_create_response.status_code == 404 @@ -1153,7 +1158,7 @@ async def test_create_query_form_error_redisplays_form_with_values(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, data={ "name": "dogs", @@ -1176,7 +1181,7 @@ async def test_create_query_form_error_redisplays_form_with_values(): assert 'name="is_private" value="1" checked' in response.text public_response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, data={ "name": "dogs", From 0cadd071871ef0b33e4ce3a23e316a104b3137c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:53:31 -0700 Subject: [PATCH 181/203] No need to document QueryCreateAnalyzeView --- tests/test_docs.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_docs.py b/tests/test_docs.py index 396ba1a2..0d0ef1e1 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -66,7 +66,14 @@ def documented_views(): if first_word.endswith("View"): view_labels.add(first_word) # We deliberately don't document these: - view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView")) + view_labels.update( + ( + "PatternPortfolioView", + "AuthTokenView", + "ApiExplorerView", + "QueryCreateAnalyzeView", + ) + ) return view_labels From 4bf1c4b065fef64676abf5eabd04ff35e07188c5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:54:35 -0700 Subject: [PATCH 182/203] Rename canned queries to queries/stored queries in docs --- datasette/default_actions.py | 4 +- datasette/hookspecs.py | 4 +- datasette/resources.py | 2 +- datasette/views/database.py | 24 ++++----- datasette/views/table.py | 4 +- docs/authentication.rst | 16 +++--- docs/configuration.rst | 10 ++-- docs/custom_templates.rst | 8 +-- docs/internals.rst | 12 ++--- docs/introspection.rst | 2 +- docs/json_api.rst | 32 ++++++------ docs/pages.rst | 4 +- docs/plugin_hooks.rst | 16 +++--- docs/spatialite.rst | 2 +- docs/sql_queries.rst | 95 ++++++++++++++++++++++++++---------- tests/test_html.py | 6 +-- tests/test_permissions.py | 4 +- 17 files changed, 144 insertions(+), 101 deletions(-) diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 0f4c25fa..2f78570b 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -121,13 +121,13 @@ def register_actions(): Action( name="update-query", abbr="uq", - description="Update saved queries", + description="Update stored queries", resource_class=QueryResource, ), Action( name="delete-query", abbr="dq", - description="Delete saved queries", + description="Delete stored queries", resource_class=QueryResource, ), ) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index a4067eaa..22da02a4 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -174,7 +174,7 @@ def view_actions(datasette, actor, database, view, request): @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): - """Links for the query and canned query actions menu""" + """Links for the query and stored query actions menu""" @hookspec @@ -229,7 +229,7 @@ def top_query(datasette, request, database, sql): @hookspec def top_canned_query(datasette, request, database, query_name): - """HTML to include at the top of the canned query page""" + """HTML to include at the top of the stored query page""" @hookspec diff --git a/datasette/resources.py b/datasette/resources.py index 91a46d36..ee2e6d98 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -41,7 +41,7 @@ class TableResource(Resource): class QueryResource(Resource): - """A saved query in a database.""" + """A stored query in a database.""" name = "query" parent_class = DatabaseResource diff --git a/datasette/views/database.py b/datasette/views/database.py index 900b94ba..f30d3815 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -222,11 +222,11 @@ class DatabaseContext(Context): tables: list = field(metadata={"help": "List of table objects in the database"}) hidden_count: int = field(metadata={"help": "Count of hidden tables"}) views: list = field(metadata={"help": "List of view objects in the database"}) - queries: list = field(metadata={"help": "List of canned query objects"}) + queries: list = field(metadata={"help": "List of stored query objects"}) queries_more: bool = field( - metadata={"help": "Boolean indicating if more saved queries are available"} + metadata={"help": "Boolean indicating if more stored queries are available"} ) - queries_count: int = field(metadata={"help": "Count of visible saved queries"}) + queries_count: int = field(metadata={"help": "Count of visible stored queries"}) allow_execute_sql: bool = field( metadata={"help": "Boolean indicating if custom SQL can be executed"} ) @@ -272,7 +272,7 @@ class QueryContext(Context): metadata={"help": "The SQL query object containing the `sql` string"} ) canned_query: str = field( - metadata={"help": "The name of the canned query if this is a canned query"} + metadata={"help": "The name of the stored query if this is a stored query"} ) private: bool = field( metadata={"help": "Boolean indicating if this is a private database"} @@ -282,11 +282,11 @@ class QueryContext(Context): # ) canned_query_write: bool = field( metadata={ - "help": "Boolean indicating if this is a canned query that allows writes" + "help": "Boolean indicating if this is a stored query that allows writes" } ) metadata: dict = field( - metadata={"help": "Metadata about the database or the canned query"} + metadata={"help": "Metadata about the database or the stored query"} ) db_is_immutable: bool = field( metadata={"help": "Boolean indicating if this database is immutable"} @@ -315,7 +315,7 @@ class QueryContext(Context): metadata={"help": "Dictionary of parameter names/values"} ) edit_sql_url: str = field( - metadata={"help": "URL to edit the SQL for a canned query"} + metadata={"help": "URL to edit the SQL for a stored query"} ) display_rows: list = field(metadata={"help": "List of result rows to display"}) columns: list = field(metadata={"help": "List of column names"}) @@ -1623,7 +1623,7 @@ class QueryView(View): db = await datasette.resolve_database(request) - # We must be a canned query + # We must be a stored query table_found = False try: await datasette.resolve_table(request) @@ -1742,14 +1742,14 @@ class QueryView(View): # Create lookup dict for quick access allowed_dict = {r.child: r for r in allowed_tables_page.resources} - # Are we a canned query? + # Are we a stored query? canned_query = None canned_query_write = False if "table" in request.url_vars: try: await datasette.resolve_table(request) except TableNotFound as table_not_found: - # Was this actually a canned query? + # Was this actually a stored query? canned_query = await datasette.get_canned_query( table_not_found.database_name, table_not_found.table, request.actor ) @@ -1759,7 +1759,7 @@ class QueryView(View): private = False if canned_query: - # Respect canned query permissions + # Respect stored query permissions visible, private = await datasette.check_visibility( request.actor, action="view-query", @@ -1823,7 +1823,7 @@ class QueryView(View): # For regular queries we only allow SELECT, plus other rules validate_sql_select(sql) else: - # Canned queries can run magic parameters + # Stored queries can run magic parameters params_for_query = MagicParameters(sql, params, request, datasette) await params_for_query.execute_params() results = await datasette.execute( diff --git a/datasette/views/table.py b/datasette/views/table.py index 7027bb10..7b1a5a82 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -963,11 +963,11 @@ async def table_view_traced(datasette, request): try: resolved = await datasette.resolve_table(request) except TableNotFound as not_found: - # Was this actually a canned query? + # Was this actually a stored query? canned_query = await datasette.get_canned_query( not_found.database_name, not_found.table, request.actor ) - # If this is a canned query, not a table, then dispatch to QueryView instead + # If this is a stored query, not a table, then dispatch to QueryView instead if canned_query: return await QueryView()(request, datasette) else: diff --git a/docs/authentication.rst b/docs/authentication.rst index 184fec5e..22db41d8 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -468,7 +468,7 @@ You can control the following: * Access to the entire Datasette instance * Access to specific databases * Access to specific tables and views -* Access to specific :ref:`canned_queries` +* Access to specific :ref:`queries ` If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within. @@ -641,12 +641,12 @@ This works for SQL views as well - you can list their names in the ``"tables"`` .. _authentication_permissions_query: -Access to specific canned queries ---------------------------------- +Access to specific queries +-------------------------- -:ref:`canned_queries` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. +:ref:`Queries ` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. -To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user`: +To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user`: .. [[[cog config_example(cog, """ @@ -1285,7 +1285,7 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i view-query ---------- -Actor is allowed to view a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted saved query also requires ``execute-sql`` or the relevant write permissions; trusted saved queries can execute with ``view-query`` alone. +Actor is allowed to view a stored query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted stored query also requires ``execute-sql`` or the relevant write permissions; :ref:`trusted stored queries ` can execute with ``view-query`` alone. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) @@ -1308,7 +1308,7 @@ Actor is allowed to create stored queries in a database. update-query ------------ -Actor is allowed to update a saved query. +Actor is allowed to update a stored query. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) @@ -1320,7 +1320,7 @@ Actor is allowed to update a saved query. delete-query ------------ -Actor is allowed to delete a saved query. +Actor is allowed to delete a stored query. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) diff --git a/docs/configuration.rst b/docs/configuration.rst index 8c8c8a67..cf9590b8 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -87,6 +87,7 @@ This is equivalent to a ``datasette.yaml`` file containing the following: } .. [[[end]]] + .. _configuration_reference: ``datasette.yaml`` reference @@ -435,10 +436,10 @@ Here is a simple example: .. _configuration_reference_canned_queries: -Canned queries configuration -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Queries configuration +~~~~~~~~~~~~~~~~~~~~~ -:ref:`Canned queries ` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level: +:ref:`Queries ` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level: .. [[[cog from metadata_doc import config_example, config_example @@ -483,7 +484,7 @@ Canned queries configuration } .. [[[end]]] -See the :ref:`canned queries documentation ` for more, including how to configure :ref:`writable canned queries `. +See the :ref:`queries documentation ` for more, including how to configure :ref:`writable queries `. .. _configuration_reference_css_js: @@ -1211,4 +1212,3 @@ For column types that accept additional configuration, use an object with ``type } } .. [[[end]]] - diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index 8cc40f0f..c324fb79 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -29,7 +29,7 @@ The custom SQL template (``/dbname?sql=...``) gets this: -A canned query template (``/dbname/queryname``) gets this: +A stored query template (``/dbname/queryname``) gets this: .. code-block:: html @@ -193,8 +193,8 @@ The lookup rules Datasette uses are as follows:: query-mydatabase.html query.html - Canned query page (/mydatabase/canned-query): - query-mydatabase-canned-query.html + Stored query page (/mydatabase/query-name): + query-mydatabase-query-name.html query-mydatabase.html query.html @@ -230,7 +230,7 @@ will look something like this:: -This example is from the canned query page for a query called "tz" in the +This example is from the stored query page for a query called "tz" in the database called "mydb". The asterisk shows which template was selected - so in this case, Datasette found a template file called ``query-mydb-tz.html`` and used that - but if that template had not been found, it would have tried for diff --git a/docs/internals.rst b/docs/internals.rst index c76de487..084922f8 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -725,7 +725,7 @@ The builder methods are: - ``allow_all(action)`` - allow an action across all databases and resources - ``allow_database(database, action)`` - allow an action on a specific database -- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`canned query `) within a database +- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`stored query `) within a database Each method returns the ``TokenRestrictions`` instance so calls can be chained. @@ -837,10 +837,10 @@ await .get_resource_metadata(self, database_name, resource_name) ``database_name`` - string The name of the database to query. ``resource_name`` - string - The name of the resource (table, view, or canned query) inside ``database_name`` to query. + The name of the resource (table, view, or stored query) inside ``database_name`` to query. Returns metadata keys and values for the specified "resource" as a dictionary. -A "resource" in this context can be a table, view, or canned query. +A "resource" in this context can be a table, view, or stored query. Internally queries the ``metadata_resources`` table inside the :ref:`internal database `. .. _datasette_get_column_metadata: @@ -851,7 +851,7 @@ await .get_column_metadata(self, database_name, resource_name, column_name) ``database_name`` - string The name of the database to query. ``resource_name`` - string - The name of the resource (table, view, or canned query) inside ``database_name`` to query. + The name of the resource (table, view, or stored query) inside ``database_name`` to query. ``column_name`` - string The name of the column inside ``resource_name`` to query. @@ -897,7 +897,7 @@ await .set_resource_metadata(self, database_name, resource_name, key, value) ``database_name`` - string The database the metadata entry belongs to. ``resource_name`` - string - The resource (table, view, or canned query) the metadata entry belongs to. + The resource (table, view, or stored query) the metadata entry belongs to. ``key`` - string The metadata entry key to insert (ex ``title``, ``description``, etc.) ``value`` - string @@ -915,7 +915,7 @@ await .set_column_metadata(self, database_name, resource_name, column_name, key, ``database_name`` - string The database the metadata entry belongs to. ``resource_name`` - string - The resource (table, view, or canned query) the metadata entry belongs to. + The resource (table, view, or stored query) the metadata entry belongs to. ``column-name`` - string The column the metadata entry belongs to. ``key`` - string diff --git a/docs/introspection.rst b/docs/introspection.rst index d2eb8efd..7702a4b5 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -149,7 +149,7 @@ Shows currently attached databases. `Databases example /-/queries.json`` returns saved query definitions for a specific database. Use ``?_size=50`` to set the page size and ``?_next=...`` with the cursor returned by the previous page to fetch the next page. +``GET /-/queries.json`` returns stored query definitions across every database that the actor can view. ``GET //-/queries.json`` returns stored query definitions for a specific database. Use ``?_size=50`` to set the page size and ``?_next=...`` with the cursor returned by the previous page to fetch the next page. .. _QueryCreateView: -Creating saved queries in the UI -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Creating stored queries in the UI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``GET //-/queries/store`` provides a form for creating stored queries. .. _QueryStoreView: .. _QueryInsertView: -Creating saved queries -~~~~~~~~~~~~~~~~~~~~~~ +Creating stored queries +~~~~~~~~~~~~~~~~~~~~~~~ ``POST //-/queries/store`` creates a stored query. This requires ``execute-sql`` and ``store-query`` for the database. @@ -545,24 +545,24 @@ Executing write SQL .. _QueryDefinitionView: -Getting a saved query definition -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Getting a stored query definition +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``GET ///-/definition`` returns a saved query definition without executing it. +``GET ///-/definition`` returns a stored query definition without executing it. .. _QueryUpdateView: -Updating saved queries -~~~~~~~~~~~~~~~~~~~~~~ +Updating stored queries +~~~~~~~~~~~~~~~~~~~~~~~ -``POST ///-/update`` updates a saved query using a JSON body with an ``"update"`` object. +``POST ///-/update`` updates a stored query using a JSON body with an ``"update"`` object. .. _QueryDeleteView: -Deleting saved queries -~~~~~~~~~~~~~~~~~~~~~~ +Deleting stored queries +~~~~~~~~~~~~~~~~~~~~~~~ -``POST ///-/delete`` deletes a saved query. +``POST ///-/delete`` deletes a stored query. .. _TableInsertView: diff --git a/docs/pages.rst b/docs/pages.rst index 34c851a5..e57c15e6 100644 --- a/docs/pages.rst +++ b/docs/pages.rst @@ -28,7 +28,7 @@ The index page can also be accessed at ``/-/``, useful for if the default index Database ======== -Each database has a page listing the tables, views and canned queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data. +Each database has a page listing the tables, views and stored queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data. Examples: @@ -68,7 +68,7 @@ This means you can link directly to a query by constructing the following URL: ``/database-name/-/query?sql=SELECT+*+FROM+table_name`` -Each configured :ref:`canned query ` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results. +Each configured :ref:`stored query ` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results. In both cases adding a ``.json`` extension to the URL will return the results as JSON. diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index b2676b3e..264b473e 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -609,7 +609,7 @@ When a request is received, the ``"render"`` callback function is called with ze The SQL query that was executed. ``query_name`` - string or None - If this was the execution of a :ref:`canned query `, the name of that query. + If this was the execution of a :ref:`stored query `, the name of that query. ``database`` - string The name of the database. @@ -1212,7 +1212,7 @@ Examples: `datasette-saved-queries `__ @@ -1635,7 +1635,7 @@ register_magic_parameters(datasette) ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. -:ref:`canned_queries_magic_parameters` can be used to add automatic parameters to :ref:`canned queries `. This plugin hook allows additional magic parameters to be defined by plugins. +:ref:`canned_queries_magic_parameters` can be used to add automatic parameters to :ref:`configured queries `. This plugin hook allows additional magic parameters to be defined by plugins. Magic parameters all take this format: ``_prefix_rest_of_parameter``. The prefix indicates which magic parameter function should be called - the rest of the parameter is passed as an argument to that function. @@ -1828,7 +1828,7 @@ jump_items_sql(datasette, actor, request) This hook allows plugins to add extra results to Datasette's ``/`` jump menu, which is powered by the ``/-/jump`` JSON endpoint. -Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and canned query results. The hook can also be an ``async def`` function, or return an awaitable that resolves to one of these values. +Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and stored query results. The hook can also be an ``async def`` function, or return an awaitable that resolves to one of these values. ``JumpSQL`` queries run against Datasette's internal database by default. To run a query against another database, pass its name as the optional ``database=`` argument. For example, ``JumpSQL(database="content", sql="...")`` runs against the ``content`` database. @@ -2004,7 +2004,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params) The name of the database. ``query_name`` - string or None - The name of the canned query, or ``None`` if this is an arbitrary SQL query. + The name of the stored query, or ``None`` if this is an arbitrary SQL query. ``request`` - :ref:`internals_request` The current HTTP request. @@ -2015,7 +2015,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params) ``params`` - dictionary The parameters passed to the SQL query, if any. -Populates a "Query actions" menu on the canned query and arbitrary SQL query pages. +Populates a "Query actions" menu on the stored query and arbitrary SQL query pages. This example adds a new query action linking to a page for explaining a query: @@ -2294,9 +2294,9 @@ top_canned_query(datasette, request, database, query_name) The name of the database. ``query_name`` - string - The name of the canned query. + The name of the stored query. -Returns HTML to be displayed at the top of the canned query page. +Returns HTML to be displayed at the top of the stored query page. .. _plugin_event_tracking: diff --git a/docs/spatialite.rst b/docs/spatialite.rst index c93c1e00..1999ab78 100644 --- a/docs/spatialite.rst +++ b/docs/spatialite.rst @@ -30,7 +30,7 @@ Warning The following steps are recommended: - Disable arbitrary SQL queries by untrusted users. See :ref:`authentication_permissions_execute_sql` for ways to do this. The easiest is to start Datasette with the ``datasette --setting default_allow_sql off`` option. - - Define :ref:`canned_queries` with the SQL queries that use SpatiaLite functions that you want people to be able to execute. + - Define :ref:`queries ` with the SQL queries that use SpatiaLite functions that you want people to be able to execute. The `Datasette SpatiaLite tutorial `__ includes detailed instructions for running SpatiaLite safely using these techniques diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index 7c3cd4ac..d60656e3 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -68,10 +68,10 @@ You can also use the `sqlite-utils `__ tool .. _canned_queries: -Canned queries --------------- +Queries +------- -As an alternative to adding views to your database, you can define canned queries inside your ``datasette.yaml`` file. Here's an example: +As an alternative to adding views to your database, you can define named queries inside your ``datasette.yaml`` file. Here's an example: .. [[[cog from metadata_doc import config_example, config_example @@ -120,24 +120,67 @@ Then run Datasette like this:: datasette sf-trees.db -m metadata.json -Each canned query will be listed on the database index page, and will also get its own URL at:: +Each configured query will be listed on the database index page, and will also get its own URL at:: - /database-name/canned-query-name + /database-name/query-name For the above example, that URL would be:: /sf-trees/just_species -You can optionally include ``"title"`` and ``"description"`` keys to show a title and description on the canned query page. As with regular table metadata you can alternatively specify ``"description_html"`` to have your description rendered as HTML (rather than having HTML special characters escaped). +You can optionally include ``"title"`` and ``"description"`` keys to show a title and description on the query page. As with regular table metadata you can alternatively specify ``"description_html"`` to have your description rendered as HTML (rather than having HTML special characters escaped). + +.. _stored_queries: +.. _saved_queries: + +Stored queries +~~~~~~~~~~~~~~ + +Datasette stores both configured queries and user-created queries in the ``queries`` table in the :ref:`internal database `. Configured queries come from the ``queries`` section of ``datasette.yaml``. User-created stored queries can be created from the SQL query page by actors with the :ref:`actions_store_query` and :ref:`actions_execute_sql` permissions. Writable stored queries also require the permissions needed for the writes they perform. + +Stored queries created by users default to private. Private stored queries can only be viewed, updated or deleted by the actor that created them. Broad ``view-query``, ``update-query`` or ``delete-query`` permission grants still do not allow other actors to access another actor's private stored queries. + +Stored queries created by users are untrusted. This means they execute using the permissions of the actor who runs them, as if that actor had pasted the SQL into the regular custom SQL interface or write SQL interface. Read-only stored queries require ``execute-sql``. Writable stored queries require ``execute-write-sql`` plus the relevant table-level write permissions. + +.. _trusted_stored_queries: +.. _trusted_saved_queries: + +Trusted stored queries +++++++++++++++++++++++ + +A trusted stored query can execute with ``view-query`` permission alone. It skips the additional ``execute-sql`` and write permission checks that are applied to untrusted stored queries. + +Trusted stored queries should only be used for SQL that has been reviewed by someone trusted to configure the Datasette instance. For that reason, trusted stored queries can only be added using configuration. Users cannot create trusted stored queries through the web interface or the stored query JSON API. + +Queries defined in ``datasette.yaml`` are trusted by default: + +.. code-block:: yaml + + databases: + mydatabase: + queries: + report: + sql: select * from report + +You can opt out of this behavior for a configured query using ``is_trusted: false``: + +.. code-block:: yaml + + databases: + mydatabase: + queries: + report: + sql: select * from report + is_trusted: false .. _canned_queries_named_parameters: -Canned query parameters -~~~~~~~~~~~~~~~~~~~~~~~ +Query parameters +~~~~~~~~~~~~~~~~ -Canned queries support named parameters, so if you include those in the SQL you will then be able to enter them using the form fields on the canned query page or by adding them to the URL. This means canned queries can be used to create custom JSON APIs based on a carefully designed SQL statement. +Configured queries support named parameters, so if you include those in the SQL you will then be able to enter them using the form fields on the query page or by adding them to the URL. This means configured queries can be used to create custom JSON APIs based on a carefully designed SQL statement. -Here's an example of a canned query with a named parameter: +Here's an example of a configured query with a named parameter: .. code-block:: sql @@ -147,7 +190,7 @@ Here's an example of a canned query with a named parameter: where neighborhood like '%' || :text || '%' order by neighborhood; -In the canned query configuration looks like this: +The query configuration looks like this: .. [[[cog @@ -204,7 +247,7 @@ In the canned query configuration looks like this: Note that we are using SQLite string concatenation here - the ``||`` operator - to add wildcard ``%`` characters to the string provided by the user. -You can try this canned query out here: +You can try this query out here: https://latest.datasette.io/fixtures/neighborhood_search?text=town In this example the ``:text`` named parameter is automatically extracted from the query using a regular expression. @@ -272,15 +315,15 @@ You can alternatively provide an explicit list of named parameters using the ``" .. _canned_queries_options: -Additional canned query options -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Additional query options +~~~~~~~~~~~~~~~~~~~~~~~~ -Additional options can be specified for canned queries in the YAML or JSON configuration. +Additional options can be specified for configured queries in the YAML or JSON configuration. hide_sql ++++++++ -Canned queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a "show" link that can be used to make it visible. +Configured queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a "show" link that can be used to make it visible. Add the ``"hide_sql": true`` option to hide the SQL query by default. @@ -289,7 +332,7 @@ fragment Some plugins, such as `datasette-vega `__, can be configured by including additional data in the fragment hash of the URL - the bit that comes after a ``#`` symbol. -You can set a default fragment hash that will be included in the link to the canned query from the database index page using the ``"fragment"`` key. +You can set a default fragment hash that will be included in the link to the query from the database index page using the ``"fragment"`` key. This example demonstrates both ``fragment`` and ``hide_sql``: @@ -348,12 +391,12 @@ This example demonstrates both ``fragment`` and ``hide_sql``: .. _canned_queries_writable: -Writable canned queries -~~~~~~~~~~~~~~~~~~~~~~~ +Writable queries +~~~~~~~~~~~~~~~~ -Canned queries by default are read-only. You can use the ``"write": true`` key to indicate that a canned query can write to the database. +Configured queries are read-only by default. You can use the ``"write": true`` key to indicate that a query can write to the database. -See :ref:`authentication_permissions_query` for details on how to add permission checks to canned queries, using the ``"allow"`` key. +See :ref:`authentication_permissions_query` for details on how to add permission checks to queries, using the ``"allow"`` key. .. [[[cog config_example(cog, { @@ -488,7 +531,7 @@ Magic parameters Named parameters that start with an underscore are special: they can be used to automatically add values created by Datasette that are not contained in the incoming form fields or query string. -These magic parameters are only supported for canned queries: to avoid security issues (such as queries that extract the user's private cookies) they are not available to SQL that is executed by the user as a custom SQL query. +These magic parameters are only supported for configured queries: to avoid security issues (such as queries that extract the user's private cookies) they are not available to SQL that is executed by the user as a custom SQL query. Available magic parameters are: @@ -580,12 +623,12 @@ Additional custom magic parameters can be added by plugins using the :ref:`plugi .. _canned_queries_json_api: -JSON API for writable canned queries -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +JSON API for writable queries +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Writable canned queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON. +Writable queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON. -To submit JSON to a writable canned query, encode key/value parameters as a JSON document:: +To submit JSON to a writable query, encode key/value parameters as a JSON document:: POST /mydatabase/add_message diff --git a/tests/test_html.py b/tests/test_html.py index 9e460da1..8edb9f6e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -154,7 +154,7 @@ async def test_database_page(ds_client): ("/fixtures/simple_view", "simple_view"), ] == sorted([(a["href"], a.text) for a in views_ul.find_all("a")]) - # And a list of canned queries + # And a list of stored queries queries_ul = soup.find("h2", string="Queries").find_next_sibling("ul") assert queries_ul is not None assert [ @@ -701,7 +701,7 @@ async def test_show_hide_sql_query(ds_client): @pytest.mark.asyncio async def test_canned_query_with_hide_has_no_hidden_sql(ds_client): - # For a canned query the show/hide should NOT have a hidden SQL field + # For a stored query the show/hide should NOT have a hidden SQL field # https://github.com/simonw/datasette/issues/1411 response = await ds_client.get("/fixtures/pragma_cache_size?_hide_sql=1") soup = Soup(response.content, "html.parser") @@ -1106,7 +1106,7 @@ async def test_trace_correctly_escaped(ds_client): "/fixtures/-/query?sql=select+*+from+facetable", "http://localhost/fixtures/-/query.json?sql=select+*+from+facetable", ), - # Canned query page + # Stored query page ( "/fixtures/neighborhood_search?text=town", "http://localhost/fixtures/neighborhood_search.json?text=town", diff --git a/tests/test_permissions.py b/tests/test_permissions.py index eb6cee9f..0e38c876 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -890,7 +890,7 @@ PermConfigTestCase = collections.namedtuple( resource=("perms_ds_one", "t1"), expected_result=True, ), - # view-query on canned query, wrong actor + # view-query on stored query, wrong actor PermConfigTestCase( config={ "databases": { @@ -909,7 +909,7 @@ PermConfigTestCase = collections.namedtuple( resource=("perms_ds_one", "q1"), expected_result=False, ), - # view-query on canned query, right actor + # view-query on stored query, right actor PermConfigTestCase( config={ "databases": { From b1029acc68626c2fddf7b678adc3339be0fce6e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:05:41 -0700 Subject: [PATCH 183/203] top_canned_query is now top_stored_query, closes #2747 --- datasette/hookspecs.py | 2 +- datasette/templates/query.html | 2 +- datasette/views/database.py | 8 ++++---- docs/changelog.rst | 1 + docs/plugin_hooks.rst | 4 ++-- tests/test_plugins.py | 10 ++++++---- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 22da02a4..dcd502af 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -228,7 +228,7 @@ def top_query(datasette, request, database, sql): @hookspec -def top_canned_query(datasette, request, database, query_name): +def top_stored_query(datasette, request, database, query_name): """HTML to include at the top of the stored query page""" diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 785b05af..3f03424a 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -33,7 +33,7 @@ {% set action_links, action_title = query_actions(), "Query actions" %} {% include "_action_menu.html" %} -{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %} +{% if canned_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index f30d3815..def3c530 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -339,8 +339,8 @@ class QueryContext(Context): top_query: callable = field( metadata={"help": "Callable to render the top_query slot"} ) - top_canned_query: callable = field( - metadata={"help": "Callable to render the top_canned_query slot"} + top_stored_query: callable = field( + metadata={"help": "Callable to render the top_stored_query slot"} ) query_actions: callable = field( metadata={ @@ -2095,8 +2095,8 @@ class QueryView(View): top_query=make_slot_function( "top_query", datasette, request, database=database, sql=sql ), - top_canned_query=make_slot_function( - "top_canned_query", + top_stored_query=make_slot_function( + "top_stored_query", datasette, request, database=database, diff --git a/docs/changelog.rst b/docs/changelog.rst index dfb2a736..300ac02f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,7 @@ Unreleased ---------- - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) +- The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() `. (:issue:`2747`) .. _v1_0_a30: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 264b473e..4737ca03 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -2279,9 +2279,9 @@ top_query(datasette, request, database, sql) Returns HTML to be displayed at the top of the query results page. -.. _plugin_hook_top_canned_query: +.. _plugin_hook_top_stored_query: -top_canned_query(datasette, request, database, query_name) +top_stored_query(datasette, request, database, query_name) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f7adbd66..32276437 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1486,8 +1486,10 @@ class SlotPlugin: return "Xtop_query:{}:{}:{}".format(database, sql, request.args["z"]) @hookimpl - def top_canned_query(self, request, database, query_name): - return "Xtop_query:{}:{}:{}".format(database, query_name, request.args["z"]) + def top_stored_query(self, request, database, query_name): + return "Xtop_stored_query:{}:{}:{}".format( + database, query_name, request.args["z"] + ) @pytest.mark.asyncio @@ -1548,12 +1550,12 @@ async def test_hook_top_query(ds_client): @pytest.mark.asyncio -async def test_hook_top_canned_query(ds_client): +async def test_hook_top_stored_query(ds_client): try: pm.register(SlotPlugin(), name="SlotPlugin") response = await ds_client.get("/fixtures/magic_parameters?z=xyz") assert response.status_code == 200 - assert "Xtop_query:fixtures:magic_parameters:xyz" in response.text + assert "Xtop_stored_query:fixtures:magic_parameters:xyz" in response.text finally: pm.unregister(name="SlotPlugin") From 2f73869c09962e320e5f40f4691df70618cd052e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:09:48 -0700 Subject: [PATCH 184/203] Document that canned_queries() has been removed --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 300ac02f..674ff5b3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,7 @@ Unreleased - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) - The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() `. (:issue:`2747`) +- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to managed stored queries instead. .. _v1_0_a30: From 56b14f37d547e03ba902516ac9ae13ef52765f77 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:16:18 -0700 Subject: [PATCH 185/203] The stored queries do not live in that DB --- docs/authentication.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authentication.rst b/docs/authentication.rst index 22db41d8..86df7f04 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1298,7 +1298,7 @@ Actor is allowed to view a stored query page, e.g. https://latest.datasette.io/f store-query ----------- -Actor is allowed to create stored queries in a database. +Actor is allowed to create stored queries against a database. ``resource`` - ``datasette.resources.DatabaseResource(database)`` ``database`` is the name of the database (string) From 02a1468f1b3c8c14fb80037686b43de856e49c1f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:17:51 -0700 Subject: [PATCH 186/203] Renamed canned queries to queries / stored queries in docs And a few renames in code and YAML as well. --- .github/workflows/deploy-latest.yml | 33 +- datasette/app.py | 7 - datasette/facets.py | 2 +- datasette/static/app.css | 2 +- datasette/templates/query.html | 18 +- datasette/views/database.py | 92 +++--- datasette/views/table.py | 6 +- docs/authentication.rst | 10 +- docs/changelog.rst | 23 +- docs/configuration.rst | 6 +- docs/plugin_hooks.rst | 12 +- docs/spatialite.rst | 2 +- docs/sql_queries.rst | 12 +- docs/upgrade-1.0a20.md | 6 +- tests/test_canned_queries.py | 473 ---------------------------- tests/test_html.py | 12 +- tests/test_jump.py | 4 +- 17 files changed, 115 insertions(+), 605 deletions(-) delete mode 100644 tests/test_canned_queries.py diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 7d8dd37d..166d33d0 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -57,7 +57,7 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db - - name: And the counters writable canned query demo + - name: And the counters writable stored query demo run: | cat > plugins/counters.py <This query cannot be executed because the database is immutable.

      {% endif %} -

      {{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}

      +

      {{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}

      {% set action_links, action_title = query_actions(), "Query actions" %} {% include "_action_menu.html" %} -{% if canned_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} +{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} - +

      Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} ({{ show_hide_text }}) {% endif %}

      @@ -52,7 +52,7 @@
      {% if query %}{{ query.sql }}{% endif %}
      {% endif %} {% else %} - {% if not canned_query %} + {% if not stored_query %} @@ -64,10 +64,10 @@ {% include "_sql_parameters.html" %}

      {% if not hide_sql %}{% endif %} - + {{ show_hide_hidden }} {% if save_query_url %}Save this query{% endif %} - {% if canned_query and edit_sql_url %}Edit SQL{% endif %} + {% if stored_query and edit_sql_url %}Edit SQL{% endif %}

      @@ -90,7 +90,7 @@
      Required permission
      {% else %} - {% if not canned_query_write and not error %} + {% if not stored_query_write and not error %}

      0 results

      {% endif %} {% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index def3c530..c36476f6 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -100,12 +100,12 @@ class DatabaseView(View): limit=5, include_private=True, ) - canned_queries = queries_page["queries"] + stored_queries = queries_page["queries"] queries_more = queries_page["has_more"] queries_count = ( await datasette.count_queries(database, actor=request.actor) if queries_more - else len(canned_queries) + else len(stored_queries) ) async def database_actions(): @@ -137,7 +137,7 @@ class DatabaseView(View): "tables": tables, "hidden_count": len([t for t in tables if t["hidden"]]), "views": sql_views, - "queries": canned_queries, + "queries": stored_queries, "queries_more": queries_more, "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, @@ -172,7 +172,7 @@ class DatabaseView(View): tables=tables, hidden_count=len([t for t in tables if t["hidden"]]), views=sql_views, - queries=canned_queries, + queries=stored_queries, queries_more=queries_more, queries_count=queries_count, allow_execute_sql=allow_execute_sql, @@ -271,7 +271,7 @@ class QueryContext(Context): query: dict = field( metadata={"help": "The SQL query object containing the `sql` string"} ) - canned_query: str = field( + stored_query: str = field( metadata={"help": "The name of the stored query if this is a stored query"} ) private: bool = field( @@ -280,7 +280,7 @@ class QueryContext(Context): # urls: dict = field( # metadata={"help": "Object containing URL helpers like `database()`"} # ) - canned_query_write: bool = field( + stored_query_write: bool = field( metadata={ "help": "Boolean indicating if this is a stored query that allows writes" } @@ -1629,10 +1629,10 @@ class QueryView(View): await datasette.resolve_table(request) table_found = True except TableNotFound as table_not_found: - canned_query = await datasette.get_canned_query( - table_not_found.database_name, table_not_found.table, request.actor + stored_query = await datasette.get_query( + table_not_found.database_name, table_not_found.table ) - if canned_query is None: + if stored_query is None: raise if table_found: # That should not have happened @@ -1640,13 +1640,13 @@ class QueryView(View): if not await datasette.allowed( action="view-query", - resource=QueryResource(database=db.name, query=canned_query["name"]), + resource=QueryResource(database=db.name, query=stored_query["name"]), actor=request.actor, ): raise Forbidden("You do not have permission to view this query") await _ensure_stored_query_execution_permissions( - datasette, db, canned_query, request.actor + datasette, db, stored_query, request.actor ) # If database is immutable, return an error @@ -1674,19 +1674,19 @@ class QueryView(View): or params.get("_json") ) params_for_query = MagicParameters( - canned_query["sql"], params, request, datasette + stored_query["sql"], params, request, datasette ) await params_for_query.execute_params() ok = None redirect_url = None try: cursor = await db.execute_write( - canned_query["sql"], params_for_query, request=request + stored_query["sql"], params_for_query, request=request ) # success message can come from on_success_message or on_success_message_sql message = None message_type = datasette.INFO - on_success_message_sql = canned_query.get("on_success_message_sql") + on_success_message_sql = stored_query.get("on_success_message_sql") if on_success_message_sql: try: message_result = ( @@ -1698,18 +1698,18 @@ class QueryView(View): message = "Error running on_success_message_sql: {}".format(ex) message_type = datasette.ERROR if not message: - message = canned_query.get( + message = stored_query.get( "on_success_message" ) or "Query executed, {} row{} affected".format( cursor.rowcount, "" if cursor.rowcount == 1 else "s" ) - redirect_url = canned_query.get("on_success_redirect") + redirect_url = stored_query.get("on_success_redirect") ok = True except Exception as ex: - message = canned_query.get("on_error_message") or str(ex) + message = stored_query.get("on_error_message") or str(ex) message_type = datasette.ERROR - redirect_url = canned_query.get("on_error_redirect") + redirect_url = stored_query.get("on_error_redirect") ok = False if should_return_json: return Response.json( @@ -1743,33 +1743,33 @@ class QueryView(View): allowed_dict = {r.child: r for r in allowed_tables_page.resources} # Are we a stored query? - canned_query = None - canned_query_write = False + stored_query = None + stored_query_write = False if "table" in request.url_vars: try: await datasette.resolve_table(request) except TableNotFound as table_not_found: # Was this actually a stored query? - canned_query = await datasette.get_canned_query( - table_not_found.database_name, table_not_found.table, request.actor + stored_query = await datasette.get_query( + table_not_found.database_name, table_not_found.table ) - if canned_query is None: + if stored_query is None: raise - canned_query_write = bool(canned_query.get("write")) + stored_query_write = bool(stored_query.get("write")) private = False - if canned_query: + if stored_query: # Respect stored query permissions visible, private = await datasette.check_visibility( request.actor, action="view-query", - resource=QueryResource(database=database, query=canned_query["name"]), + resource=QueryResource(database=database, query=stored_query["name"]), ) if not visible: raise Forbidden("You do not have permission to view this query") - if not canned_query_write: + if not stored_query_write: await _ensure_stored_query_execution_permissions( - datasette, db, canned_query, request.actor + datasette, db, stored_query, request.actor ) else: @@ -1783,15 +1783,15 @@ class QueryView(View): params = {key: request.args.get(key) for key in request.args} sql = None - if canned_query: - sql = canned_query["sql"] + if stored_query: + sql = stored_query["sql"] elif "sql" in params: sql = params.pop("sql") # Extract any :named parameters named_parameters = [] - if canned_query and canned_query.get("params"): - named_parameters = canned_query["params"] + if stored_query and stored_query.get("params"): + named_parameters = stored_query["params"] if not named_parameters and sql: named_parameters = derive_named_parameters(sql) named_parameter_values = { @@ -1817,9 +1817,9 @@ class QueryView(View): params_for_query = params - if sql and not canned_query_write: + if sql and not stored_query_write: try: - if not canned_query: + if not stored_query: # For regular queries we only allow SELECT, plus other rules validate_sql_select(sql) else: @@ -1879,7 +1879,7 @@ class QueryView(View): columns=columns, rows=rows, sql=sql, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query["name"] if stored_query else None, database=database, table=None, request=request, @@ -1911,10 +1911,10 @@ class QueryView(View): elif format_ == "html": headers = {} templates = [f"query-{to_css_class(database)}.html", "query.html"] - if canned_query: + if stored_query: templates.insert( 0, - f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html", + f"query-{to_css_class(database)}-{to_css_class(stored_query['name'])}.html", ) environment = datasette.get_jinja_environment(request) @@ -1932,8 +1932,8 @@ class QueryView(View): } ) metadata = await datasette.get_database_metadata(database) - if canned_query: - metadata = dict(canned_query) + if stored_query: + metadata = dict(stored_query) metadata.pop("source", None) renderers = {} @@ -1968,7 +1968,7 @@ class QueryView(View): ) show_hide_hidden = "" - if canned_query and canned_query.get("hide_sql"): + if stored_query and stored_query.get("hide_sql"): if bool(params.get("_show_sql")): show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_text = "hide" @@ -2018,7 +2018,7 @@ class QueryView(View): ) save_query_url = None if ( - not canned_query + not stored_query and allow_execute_sql and allow_store_query and is_validated_sql @@ -2036,7 +2036,7 @@ class QueryView(View): datasette=datasette, actor=request.actor, database=database, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query["name"] if stored_query else None, request=request, sql=sql, params=params, @@ -2056,15 +2056,15 @@ class QueryView(View): "sql": sql, "params": params, }, - canned_query=canned_query["name"] if canned_query else None, + stored_query=stored_query["name"] if stored_query else None, private=private, - canned_query_write=canned_query_write, + stored_query_write=stored_query_write, db_is_immutable=not db.is_mutable, error=query_error, hide_sql=hide_sql, show_hide_link=datasette.urls.path(show_hide_link), show_hide_text=show_hide_text, - editable=not canned_query, + editable=not stored_query, allow_execute_sql=allow_execute_sql, save_query_url=save_query_url, tables=await get_tables(datasette, request, db, allowed_dict), @@ -2100,7 +2100,7 @@ class QueryView(View): datasette, request, database=database, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query["name"] if stored_query else None, ), query_actions=query_actions, ), diff --git a/datasette/views/table.py b/datasette/views/table.py index 7b1a5a82..da69c6b5 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -964,11 +964,11 @@ async def table_view_traced(datasette, request): resolved = await datasette.resolve_table(request) except TableNotFound as not_found: # Was this actually a stored query? - canned_query = await datasette.get_canned_query( - not_found.database_name, not_found.table, request.actor + stored_query = await datasette.get_query( + not_found.database_name, not_found.table ) # If this is a stored query, not a table, then dispatch to QueryView instead - if canned_query: + if stored_query: return await QueryView()(request, datasette) else: raise diff --git a/docs/authentication.rst b/docs/authentication.rst index 86df7f04..cec47f97 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of ` How permissions are resolved ---------------------------- -Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. +Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. ``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified. @@ -468,7 +468,7 @@ You can control the following: * Access to the entire Datasette instance * Access to specific databases * Access to specific tables and views -* Access to specific :ref:`queries ` +* Access to specific :ref:`queries ` If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within. @@ -496,7 +496,7 @@ Here's how to restrict access to your entire Datasette instance to just the ``"i title: My private Datasette instance allow: id: root - + .. tab:: datasette.json @@ -644,7 +644,7 @@ This works for SQL views as well - you can list their names in the ``"tables"`` Access to specific queries -------------------------- -:ref:`Queries ` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. +:ref:`Queries ` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user`: @@ -1020,7 +1020,7 @@ You can also restrict permissions such that they can only be used within specifi The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database. -Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries ` - within a specific database:: +Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries ` - within a specific database:: datasette create-token root --resource mydatabase mytable insert-row diff --git a/docs/changelog.rst b/docs/changelog.rst index 674ff5b3..d15dec50 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,8 @@ Unreleased - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) - The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() `. (:issue:`2747`) -- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to managed stored queries instead. +- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to manage stored queries instead. +- The ``datasette.get_canned_query()`` and ``datasette.get_canned_queries()`` methods have been removed. Plugins can use ``datasette.get_query()`` and ``datasette.list_queries()`` instead. .. _v1_0_a30: @@ -658,7 +659,7 @@ For more information and workarounds, read `the security advisory `` in a `` -

      +

      + + {% if save_query_base_url %}Save this query{% endif %} +

      ", + "on_success_message_sql": "select 'secret'", + } + }, + ) + form_response = await ds.client.post( + "/data/-/queries/store", + actor={"id": "root"}, + data={ + "name": "unsafe_form", + "sql": "select 1", + "description_html": "", + }, + ) + + assert response.status_code == 400 + assert response.json()["errors"] == [ + "Invalid keys: description_html, on_success_message_sql" + ] + assert form_response.status_code == 400 + assert "Invalid keys: description_html" in form_response.text + assert await ds.get_query("data", "unsafe") is None + assert await ds.get_query("data", "unsafe_form") is None + + @pytest.mark.asyncio async def test_query_store_api_creates_writable_query(): ds = Datasette(memory=True, default_deny=True) @@ -959,6 +1000,42 @@ async def test_query_update_and_delete_api(): assert await ds.get_query("data", "editable") is None +@pytest.mark.asyncio +async def test_query_update_api_rejects_config_only_fields(): + ds = Datasette(memory=True, default_deny=True) + ds.root_enabled = True + db = ds.add_memory_database("query_update_config_only_fields", name="data") + await db.execute_write("create table dogs (id integer primary key, name text)") + await ds.invoke_startup() + await ds.add_query( + "data", + "editable", + "insert into dogs (name) values (:name)", + is_write=True, + source="user", + owner_id="root", + ) + + response = await ds.client.post( + "/data/editable/-/update", + actor={"id": "root"}, + json={ + "update": { + "description_html": "", + "on_success_message_sql": "select 'secret'", + } + }, + ) + + assert response.status_code == 400 + assert response.json()["errors"] == [ + "Invalid keys: description_html, on_success_message_sql" + ] + query = await ds.get_query("data", "editable") + assert query["description_html"] is None + assert query["on_success_message_sql"] is None + + @pytest.mark.asyncio async def test_query_update_api_rejects_trusted_queries_but_internal_update_allowed(): ds = Datasette( From b1289a73f9869e83a433a088c2a6c48285e67f2d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 16:51:00 -0700 Subject: [PATCH 203/203] stored_queries.StoredQuery dataclass --- datasette/app.py | 102 ++++++------ datasette/stored_queries.py | 258 ++++++++++++++++++++---------- datasette/views/database.py | 56 +++---- datasette/views/query_helpers.py | 19 +-- datasette/views/stored_queries.py | 37 +++-- docs/internals.rst | 14 +- tests/test_queries.py | 128 +++++++-------- 7 files changed, 357 insertions(+), 257 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 96683895..56b89789 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1029,8 +1029,8 @@ class Datasette: ) @staticmethod - def _query_row_to_dict(row): - return stored_queries.query_row_to_dict(row) + def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None: + return stored_queries.query_row_to_stored_query(row) @staticmethod def _query_options_json(options): @@ -1038,28 +1038,28 @@ class Datasette: async def add_query( self, - database, - name, - sql, + database: str, + name: str, + sql: str, *, - title=None, - description=None, - description_html=None, - hide_sql=False, - fragment=None, - parameters=None, - is_write=False, - is_private=False, - is_trusted=False, - source="plugin", - owner_id=None, - on_success_message=None, - on_success_message_sql=None, - on_success_redirect=None, - on_error_message=None, - on_error_redirect=None, - replace=True, - ): + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, + ) -> None: return await stored_queries.add_query( self, database, @@ -1086,8 +1086,8 @@ class Datasette: async def update_query( self, - database, - name, + database: str, + name: str, *, sql=stored_queries.UNCHANGED, title=stored_queries.UNCHANGED, @@ -1106,7 +1106,7 @@ class Datasette: on_success_redirect=stored_queries.UNCHANGED, on_error_message=stored_queries.UNCHANGED, on_error_redirect=stored_queries.UNCHANGED, - ): + ) -> None: return await stored_queries.update_query( self, database, @@ -1130,24 +1130,28 @@ class Datasette: on_error_redirect=on_error_redirect, ) - async def remove_query(self, database, name, source=None): + async def remove_query( + self, database: str, name: str, source: str | None = None + ) -> None: return await stored_queries.remove_query(self, database, name, source=source) - async def get_query(self, database, name): + async def get_query( + self, database: str, name: str + ) -> stored_queries.StoredQuery | None: return await stored_queries.get_query(self, database, name) async def count_queries( self, - database=None, + database: str | None = None, *, - actor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, - ): + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + ) -> int: return await stored_queries.count_queries( self, database, @@ -1162,19 +1166,19 @@ class Datasette: async def list_queries( self, - database=None, + database: str | None = None, *, - actor=None, - limit=50, - cursor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, - include_private=False, - ): + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, + ) -> stored_queries.StoredQueryPage: return await stored_queries.list_queries( self, database, diff --git a/datasette/stored_queries.py b/datasette/stored_queries.py index a28b71bf..bcfdfdb4 100644 --- a/datasette/stored_queries.py +++ b/datasette/stored_queries.py @@ -1,6 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass import json +from typing import Any, Iterable from .resources import TableResource from .utils import named_parameters, sqlite3, tilde_encode, urlsafe_components @@ -19,7 +21,76 @@ QUERY_OPTION_FIELDS = ( ) -async def save_queries_from_config(datasette): +@dataclass +class StoredQuery: + database: str + name: str + sql: str + title: str | None + description: str | None + description_html: str | None + hide_sql: bool + fragment: str | None + parameters: list[str] + is_write: bool + is_private: bool + is_trusted: bool + source: str + owner_id: str | None + on_success_message: str | None + on_success_message_sql: str | None + on_success_redirect: str | None + on_error_message: str | None + on_error_redirect: str | None + private: bool | None = None + + +@dataclass +class StoredQueryPage: + queries: list[StoredQuery] + next: str | None + has_more: bool + limit: int + + +def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]: + data = { + "database": query.database, + "name": query.name, + "sql": query.sql, + "title": query.title, + "description": query.description, + "description_html": query.description_html, + "hide_sql": query.hide_sql, + "fragment": query.fragment, + "params": list(query.parameters), + "parameters": list(query.parameters), + "is_write": query.is_write, + "is_private": query.is_private, + "is_trusted": query.is_trusted, + "source": query.source, + "owner_id": query.owner_id, + "on_success_message": query.on_success_message, + "on_success_message_sql": query.on_success_message_sql, + "on_success_redirect": query.on_success_redirect, + "on_error_message": query.on_error_message, + "on_error_redirect": query.on_error_redirect, + } + if query.private is not None: + data["private"] = query.private + return data + + +def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]: + return { + "queries": [stored_query_to_dict(query) for query in page.queries], + "next": page.next, + "has_more": page.has_more, + "limit": page.limit, + } + + +async def save_queries_from_config(datasette: Any) -> None: # Apply configured query entries from datasette.yaml to the internal table. await datasette.get_internal_database().execute_write( "DELETE FROM queries WHERE source = 'config'" @@ -50,36 +121,38 @@ async def save_queries_from_config(datasette): ) -def query_row_to_dict(row): +def query_row_to_stored_query( + row: Any, private: bool | None = None +) -> StoredQuery | None: if row is None: return None parameters = json.loads(row["parameters"] or "[]") options = json.loads(row["options"] or "{}") - return { - "database": row["database_name"], - "name": row["name"], - "sql": row["sql"], - "title": row["title"], - "description": row["description"], - "description_html": row["description_html"], - "hide_sql": bool(options.get("hide_sql")), - "fragment": options.get("fragment"), - "params": parameters, - "parameters": parameters, - "is_write": bool(row["is_write"]), - "is_private": bool(row["is_private"]), - "is_trusted": bool(row["is_trusted"]), - "source": row["source"], - "owner_id": row["owner_id"], - "on_success_message": options.get("on_success_message"), - "on_success_message_sql": options.get("on_success_message_sql"), - "on_success_redirect": options.get("on_success_redirect"), - "on_error_message": options.get("on_error_message"), - "on_error_redirect": options.get("on_error_redirect"), - } + return StoredQuery( + database=row["database_name"], + name=row["name"], + sql=row["sql"], + title=row["title"], + description=row["description"], + description_html=row["description_html"], + hide_sql=bool(options.get("hide_sql")), + fragment=options.get("fragment"), + parameters=parameters, + is_write=bool(row["is_write"]), + is_private=bool(row["is_private"]), + is_trusted=bool(row["is_trusted"]), + source=row["source"], + owner_id=row["owner_id"], + on_success_message=options.get("on_success_message"), + on_success_message_sql=options.get("on_success_message_sql"), + on_success_redirect=options.get("on_success_redirect"), + on_error_message=options.get("on_error_message"), + on_error_redirect=options.get("on_error_redirect"), + private=private, + ) -def query_options_json(options): +def query_options_json(options: dict[str, Any]) -> str: options_dict = {} for field in QUERY_OPTION_FIELDS: value = options.get(field) @@ -92,29 +165,29 @@ def query_options_json(options): async def add_query( - datasette, - database, - name, - sql, + datasette: Any, + database: str, + name: str, + sql: str, *, - title=None, - description=None, - description_html=None, - hide_sql=False, - fragment=None, - parameters=None, - is_write=False, - is_private=False, - is_trusted=False, - source="plugin", - owner_id=None, - on_success_message=None, - on_success_message_sql=None, - on_success_redirect=None, - on_error_message=None, - on_error_redirect=None, - replace=True, -): + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, +) -> None: parameters_json = json.dumps(list(parameters or [])) options_json = query_options_json( { @@ -170,9 +243,9 @@ async def add_query( async def update_query( - datasette, - database, - name, + datasette: Any, + database: str, + name: str, *, sql=UNCHANGED, title=UNCHANGED, @@ -191,7 +264,7 @@ async def update_query( on_success_redirect=UNCHANGED, on_error_message=UNCHANGED, on_error_redirect=UNCHANGED, -): +) -> None: fields = { "sql": sql, "title": title, @@ -263,7 +336,9 @@ async def update_query( ) -async def remove_query(datasette, database, name, source=None): +async def remove_query( + datasette: Any, database: str, name: str, source: str | None = None +) -> None: sql = "DELETE FROM queries WHERE database_name = ? AND name = ?" params = [database, name] if source is not None: @@ -272,7 +347,7 @@ async def remove_query(datasette, database, name, source=None): await datasette.get_internal_database().execute_write(sql, params) -async def get_query(datasette, database, name): +async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None: rows = await datasette.get_internal_database().execute( """ SELECT * FROM queries @@ -280,21 +355,21 @@ async def get_query(datasette, database, name): """, [database, name], ) - return query_row_to_dict(rows.first()) + return query_row_to_stored_query(rows.first()) async def count_queries( - datasette, - database=None, + datasette: Any, + database: str | None = None, *, - actor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, -): + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, +) -> int: allowed_sql, allowed_params = await datasette.allowed_resources_sql( action="view-query", actor=actor, @@ -354,20 +429,20 @@ async def count_queries( async def list_queries( - datasette, - database=None, + datasette: Any, + database: str | None = None, *, - actor=None, - limit=50, - cursor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, - include_private=False, -): + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, +) -> StoredQueryPage: limit = min(max(1, int(limit)), 1000) allowed_sql, allowed_params = await datasette.allowed_resources_sql( action="view-query", @@ -480,9 +555,10 @@ async def list_queries( queries = [] for row in rows: - query = query_row_to_dict(row) - if include_private: - query["private"] = bool(row["private"]) + query = query_row_to_stored_query( + row, private=bool(row["private"]) if include_private else None + ) + assert query is not None queries.append(query) next_token = None @@ -499,17 +575,23 @@ async def list_queries( tilde_encode(last_row["sort_key"]), tilde_encode(last_row["name"]), ) - return { - "queries": queries, - "next": next_token, - "has_more": has_more, - "limit": limit, - } + return StoredQueryPage( + queries=queries, + next=next_token, + has_more=has_more, + limit=limit, + ) async def ensure_query_write_permissions( - datasette, database, sql, *, actor=None, params=None, analysis=None -): + datasette: Any, + database: str, + sql: str, + *, + actor: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + analysis: Any = None, +) -> Any: write_actions = { "insert": "insert-row", "update": "update-row", diff --git a/datasette/views/database.py b/datasette/views/database.py index 98ca989c..b558b002 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -13,6 +13,7 @@ import textwrap from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.database import QueryInterrupted from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import stored_query_to_dict from datasette.utils import ( add_cors_headers, await_me_maybe, @@ -99,8 +100,8 @@ class DatabaseView(View): limit=5, include_private=True, ) - stored_queries = queries_page["queries"] - queries_more = queries_page["has_more"] + stored_queries = queries_page.queries + queries_more = queries_page.has_more queries_count = ( await datasette.count_queries(database, actor=request.actor) if queries_more @@ -136,7 +137,7 @@ class DatabaseView(View): "tables": tables, "hidden_count": len([t for t in tables if t["hidden"]]), "views": sql_views, - "queries": stored_queries, + "queries": [stored_query_to_dict(query) for query in stored_queries], "queries_more": queries_more, "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, @@ -447,7 +448,7 @@ class QueryView(View): if not await datasette.allowed( action="view-query", - resource=QueryResource(database=db.name, query=stored_query["name"]), + resource=QueryResource(database=db.name, query=stored_query.name), actor=request.actor, ): raise Forbidden("You do not have permission to view this query") @@ -480,20 +481,18 @@ class QueryView(View): or request.args.get("_json") or params.get("_json") ) - params_for_query = MagicParameters( - stored_query["sql"], params, request, datasette - ) + params_for_query = MagicParameters(stored_query.sql, params, request, datasette) await params_for_query.execute_params() ok = None redirect_url = None try: cursor = await db.execute_write( - stored_query["sql"], params_for_query, request=request + stored_query.sql, params_for_query, request=request ) # success message can come from on_success_message or on_success_message_sql message = None message_type = datasette.INFO - on_success_message_sql = stored_query.get("on_success_message_sql") + on_success_message_sql = stored_query.on_success_message_sql if on_success_message_sql: try: message_result = ( @@ -505,18 +504,19 @@ class QueryView(View): message = "Error running on_success_message_sql: {}".format(ex) message_type = datasette.ERROR if not message: - message = stored_query.get( - "on_success_message" - ) or "Query executed, {} row{} affected".format( - cursor.rowcount, "" if cursor.rowcount == 1 else "s" + message = ( + stored_query.on_success_message + or "Query executed, {} row{} affected".format( + cursor.rowcount, "" if cursor.rowcount == 1 else "s" + ) ) - redirect_url = stored_query.get("on_success_redirect") + redirect_url = stored_query.on_success_redirect ok = True except Exception as ex: - message = stored_query.get("on_error_message") or str(ex) + message = stored_query.on_error_message or str(ex) message_type = datasette.ERROR - redirect_url = stored_query.get("on_error_redirect") + redirect_url = stored_query.on_error_redirect ok = False if should_return_json: return Response.json( @@ -562,7 +562,7 @@ class QueryView(View): ) if stored_query is None: raise - stored_query_write = bool(stored_query.get("is_write")) + stored_query_write = stored_query.is_write private = False if stored_query: @@ -570,7 +570,7 @@ class QueryView(View): visible, private = await datasette.check_visibility( request.actor, action="view-query", - resource=QueryResource(database=database, query=stored_query["name"]), + resource=QueryResource(database=database, query=stored_query.name), ) if not visible: raise Forbidden("You do not have permission to view this query") @@ -591,14 +591,14 @@ class QueryView(View): sql = None if stored_query: - sql = stored_query["sql"] + sql = stored_query.sql elif "sql" in params: sql = params.pop("sql") # Extract any :named parameters named_parameters = [] - if stored_query and stored_query.get("params"): - named_parameters = stored_query["params"] + if stored_query and stored_query.parameters: + named_parameters = stored_query.parameters if not named_parameters and sql: named_parameters = derive_named_parameters(sql) named_parameter_values = { @@ -686,7 +686,7 @@ class QueryView(View): columns=columns, rows=rows, sql=sql, - query_name=stored_query["name"] if stored_query else None, + query_name=stored_query.name if stored_query else None, database=database, table=None, request=request, @@ -721,7 +721,7 @@ class QueryView(View): if stored_query: templates.insert( 0, - f"query-{to_css_class(database)}-{to_css_class(stored_query['name'])}.html", + f"query-{to_css_class(database)}-{to_css_class(stored_query.name)}.html", ) environment = datasette.get_jinja_environment(request) @@ -740,7 +740,7 @@ class QueryView(View): ) metadata = await datasette.get_database_metadata(database) if stored_query: - metadata = dict(stored_query) + metadata = stored_query_to_dict(stored_query) metadata.pop("source", None) renderers = {} @@ -775,7 +775,7 @@ class QueryView(View): ) show_hide_hidden = "" - if stored_query and stored_query.get("hide_sql"): + if stored_query and stored_query.hide_sql: if bool(params.get("_show_sql")): show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_text = "hide" @@ -843,7 +843,7 @@ class QueryView(View): datasette=datasette, actor=request.actor, database=database, - query_name=stored_query["name"] if stored_query else None, + query_name=stored_query.name if stored_query else None, request=request, sql=sql, params=params, @@ -863,7 +863,7 @@ class QueryView(View): "sql": sql, "params": params, }, - stored_query=stored_query["name"] if stored_query else None, + stored_query=stored_query.name if stored_query else None, private=private, stored_query_write=stored_query_write, db_is_immutable=not db.is_mutable, @@ -907,7 +907,7 @@ class QueryView(View): datasette, request, database=database, - query_name=stored_query["name"] if stored_query else None, + query_name=stored_query.name if stored_query else None, ), query_actions=query_actions, ), diff --git a/datasette/views/query_helpers.py b/datasette/views/query_helpers.py index de732431..46d71b8e 100644 --- a/datasette/views/query_helpers.py +++ b/datasette/views/query_helpers.py @@ -2,6 +2,7 @@ import json import re from datasette.resources import DatabaseResource, TableResource +from datasette.stored_queries import StoredQuery from datasette.utils import ( named_parameters as derive_named_parameters, escape_sqlite, @@ -281,18 +282,18 @@ async def _prepare_execute_write(datasette, db, sql, params, actor): return parameter_names, params, analysis -async def _ensure_stored_query_execution_permissions(datasette, db, query, actor): - if query.get("is_trusted"): +async def _ensure_stored_query_execution_permissions( + datasette, db, query: StoredQuery, actor +): + if query.is_trusted: return - if query.get("is_write"): + if query.is_write: await datasette.ensure_permission( action="execute-write-sql", resource=DatabaseResource(db.name), actor=actor, ) - await datasette.ensure_query_write_permissions( - db.name, query["sql"], actor=actor - ) + await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor) else: await datasette.ensure_permission( action="execute-sql", @@ -482,7 +483,7 @@ async def _prepare_query_create(datasette, request, db, data): } -async def _prepare_query_update(datasette, request, db, existing, update): +async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update): invalid_keys = set(update) - _query_update_fields if invalid_keys: raise QueryValidationError( @@ -490,8 +491,8 @@ async def _prepare_query_update(datasette, request, db, existing, update): ) update = _apply_query_data_types(update) - sql = update.get("sql", existing["sql"]) - query_is_write = existing["is_write"] + sql = update.get("sql", existing.sql) + query_is_write = existing.is_write derived = _derived_query_parameters(sql) parameters = None diff --git a/datasette/views/stored_queries.py b/datasette/views/stored_queries.py index 1a2c5d00..8c4e849e 100644 --- a/datasette/views/stored_queries.py +++ b/datasette/views/stored_queries.py @@ -1,6 +1,7 @@ from urllib.parse import parse_qsl, urlencode from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import stored_query_to_dict from datasette.utils import sqlite3, tilde_decode from datasette.utils.asgi import Response @@ -100,7 +101,7 @@ class QueryListView(BaseView): ) query_list_path = self.query_list_path(database) next_url = None - if page["next"]: + if page.next: pairs = [ (key, value) for key, value in parse_qsl( @@ -108,7 +109,7 @@ class QueryListView(BaseView): ) if key != "_next" ] - pairs.append(("_next", page["next"])) + pairs.append(("_next", page.next)) next_url = "{}?{}".format( query_list_path, urlencode(pairs), @@ -194,13 +195,13 @@ class QueryListView(BaseView): "database_color": ( self.ds.get_database(database).color if database is not None else None ), - "queries": page["queries"], - "next": page["next"], + "queries": page.queries, + "next": page.next, "next_url": next_url, - "has_more": page["has_more"], - "limit": page["limit"], - "show_private_note": any(query["is_private"] for query in page["queries"]), - "show_trusted_note": any(query["is_trusted"] for query in page["queries"]), + "has_more": page.has_more, + "limit": page.limit, + "show_private_note": any(query.is_private for query in page.queries), + "show_trusted_note": any(query.is_trusted for query in page.queries), "query_list_path": query_list_path, "show_database": database is None, "facets": facets, @@ -213,7 +214,12 @@ class QueryListView(BaseView): }, } if format_ == "json": - return Response.json(data) + return Response.json( + { + **data, + "queries": [stored_query_to_dict(query) for query in page.queries], + } + ) return await self.render( ["query_list.html"], request, @@ -374,8 +380,11 @@ class QueryStoreView(QueryCreateView): return _error([str(ex)], 400) query = await self.ds.get_query(db.name, name) + assert query is not None if is_json: - return Response.json({"ok": True, "query": query}, status=201) + return Response.json( + {"ok": True, "query": stored_query_to_dict(query)}, status=201 + ) self.ds.add_message(request, "Query saved", self.ds.INFO) return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name))) @@ -395,7 +404,7 @@ class QueryDefinitionView(BaseView): actor=request.actor, ): return _error(["Permission denied"], 403) - return Response.json({"ok": True, "query": query}) + return Response.json({"ok": True, "query": stored_query_to_dict(query)}) class QueryUpdateView(BaseView): @@ -413,7 +422,7 @@ class QueryUpdateView(BaseView): actor=request.actor, ): return _error(["Permission denied: need update-query"], 403) - if existing.get("is_trusted"): + if existing.is_trusted: return _error(["Trusted queries cannot be updated using the API"], 403) try: @@ -444,10 +453,12 @@ class QueryUpdateView(BaseView): await self.ds.update_query(db.name, query_name, **update_kwargs) if data.get("return"): + query = await self.ds.get_query(db.name, query_name) + assert query is not None return Response.json( { "ok": True, - "query": await self.ds.get_query(db.name, query_name), + "query": stored_query_to_dict(query), } ) return Response.json({"ok": True}) diff --git a/docs/internals.rst b/docs/internals.rst index 66724aa9..4980ee8b 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1039,11 +1039,11 @@ Example: await .get_query(database, name) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Returns a stored query dictionary, or ``None`` if the query does not exist. +Returns a ``StoredQuery`` dataclass instance, or ``None`` if the query does not exist. -The dictionary contains ``database``, ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``params``, ``is_write``, ``is_private``, ``is_trusted``, ``source``, ``owner_id``, ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``. +``StoredQuery`` has the following attributes: ``database``, ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write``, ``is_private``, ``is_trusted``, ``source``, ``owner_id``, ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``. -``parameters`` and ``params`` contain the same list of explicit parameter names. +``parameters`` is a list of explicit parameter names. .. _datasette_list_queries: @@ -1087,12 +1087,12 @@ Lists stored queries visible to the specified actor. ``owner_id`` - string, optional Filter by owner actor ID. ``include_private`` - boolean, optional - Set to ``True`` to include a ``private`` boolean in each returned query dictionary indicating if anonymous users would be unable to view that query. + Set to ``True`` to populate a ``private`` boolean on each returned ``StoredQuery`` indicating if anonymous users would be unable to view that query. -The return value is a dictionary with these keys: +The return value is a ``StoredQueryPage`` dataclass instance with these attributes: -``queries`` - list of dictionaries - Stored query dictionaries, in the same format returned by :ref:`datasette_get_query`. +``queries`` - list of StoredQuery instances + Stored queries in the same format returned by :ref:`datasette_get_query`. ``next`` - string or None Pagination cursor for the next page, if one exists. ``has_more`` - boolean diff --git a/tests/test_queries.py b/tests/test_queries.py index 70fb7a03..59fab8c0 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -4,6 +4,7 @@ import pytest from datasette.app import Datasette from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import StoredQuery, StoredQueryPage from datasette.utils.asgi import Forbidden @@ -87,38 +88,41 @@ async def test_add_get_and_remove_query(): } query = await ds.get_query("data", "top_customers") - assert query == { - "database": "data", - "name": "top_customers", - "sql": "select * from customers where region = :region", - "title": "Top customers", - "description": "Customers by region", - "description_html": None, - "hide_sql": True, - "fragment": "chart", - "params": ["region"], - "parameters": ["region"], - "is_write": False, - "is_private": False, - "is_trusted": True, - "source": "user", - "owner_id": "alice", - "on_success_message": None, - "on_success_message_sql": None, - "on_success_redirect": None, - "on_error_message": None, - "on_error_redirect": None, - } + assert query == StoredQuery( + database="data", + name="top_customers", + sql="select * from customers where region = :region", + title="Top customers", + description="Customers by region", + description_html=None, + hide_sql=True, + fragment="chart", + parameters=["region"], + is_write=False, + is_private=False, + is_trusted=True, + source="user", + owner_id="alice", + on_success_message=None, + on_success_message_sql=None, + on_success_redirect=None, + on_error_message=None, + on_error_redirect=None, + ) queries_page = await ds.list_queries("data", actor=None) - assert queries_page["queries"] == [query] - assert queries_page["next"] is None + assert queries_page == StoredQueryPage( + queries=[query], + next=None, + has_more=False, + limit=50, + ) await ds.remove_query("data", "top_customers") assert await ds.get_query("data", "top_customers") is None queries_page = await ds.list_queries("data", actor=None) - assert queries_page["queries"] == [] - assert queries_page["next"] is None + assert queries_page.queries == [] + assert queries_page.next is None @pytest.mark.asyncio @@ -156,13 +160,12 @@ async def test_update_query_only_updates_provided_fields(): ) query = await ds.get_query("data", "redirect") - assert query["title"] == "Updated" - assert query["parameters"] == [] - assert query["params"] == [] - assert query["on_success_redirect"] is None - assert query["sql"] == "select 1" - assert query["is_private"] is False - assert query["is_trusted"] is False + assert query.title == "Updated" + assert query.parameters == [] + assert query.on_success_redirect is None + assert query.sql == "select 1" + assert query.is_private is False + assert query.is_trusted is False options_row = ( await ds.get_internal_database().execute( """ @@ -198,28 +201,27 @@ async def test_config_queries_imported_to_internal_table(): ds.add_memory_database("query_config", name="data") await ds.invoke_startup() - assert await ds.get_query("data", "configured") == { - "database": "data", - "name": "configured", - "sql": "select :name as name", - "title": "Configured query", - "description": None, - "description_html": "

      Configured HTML

      ", - "hide_sql": False, - "fragment": None, - "params": ["name"], - "parameters": ["name"], - "is_write": False, - "is_private": False, - "is_trusted": True, - "source": "config", - "owner_id": None, - "on_success_message": None, - "on_success_message_sql": "select 'Hello ' || :name", - "on_success_redirect": None, - "on_error_message": None, - "on_error_redirect": None, - } + assert await ds.get_query("data", "configured") == StoredQuery( + database="data", + name="configured", + sql="select :name as name", + title="Configured query", + description=None, + description_html="

      Configured HTML

      ", + hide_sql=False, + fragment=None, + parameters=["name"], + is_write=False, + is_private=False, + is_trusted=True, + source="config", + owner_id=None, + on_success_message=None, + on_success_message_sql="select 'Hello ' || :name", + on_success_redirect=None, + on_error_message=None, + on_error_redirect=None, + ) @pytest.mark.asyncio @@ -1032,8 +1034,8 @@ async def test_query_update_api_rejects_config_only_fields(): "Invalid keys: description_html, on_success_message_sql" ] query = await ds.get_query("data", "editable") - assert query["description_html"] is None - assert query["on_success_message_sql"] is None + assert query.description_html is None + assert query.on_success_message_sql is None @pytest.mark.asyncio @@ -1072,9 +1074,9 @@ async def test_query_update_api_rejects_trusted_queries_but_internal_update_allo "Trusted queries cannot be updated using the API" ] query = await ds.get_query("data", "trusted_report") - assert query["is_trusted"] is True - assert query["sql"] == "select 1 as one" - assert query["title"] == "Original" + assert query.is_trusted is True + assert query.sql == "select 1 as one" + assert query.title == "Original" await ds.update_query( "data", @@ -1083,9 +1085,9 @@ async def test_query_update_api_rejects_trusted_queries_but_internal_update_allo title="Internal", ) query = await ds.get_query("data", "trusted_report") - assert query["is_trusted"] is True - assert query["sql"] == "select 3 as three" - assert query["title"] == "Internal" + assert query.is_trusted is True + assert query.sql == "select 3 as three" + assert query.title == "Internal" @pytest.mark.asyncio