diff --git a/datasette/app.py b/datasette/app.py index 023568dd..40877802 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -615,7 +615,7 @@ class Datasette: fragment=query_config.get("fragment"), parameters=query_config.get("params"), is_write=bool(query_config.get("write")), - published=bool(query_config.get("published")), + is_published=bool(query_config.get("is_published")), source="config", on_success_message=query_config.get("on_success_message"), on_success_message_sql=query_config.get("on_success_message_sql"), @@ -1081,7 +1081,7 @@ class Datasette: "parameters": parameters, "is_write": is_write, "write": is_write, - "published": bool(row["published"]), + "is_published": bool(row["is_published"]), "source": row["source"], "owner_id": row["owner_id"], "on_success_message": options.get("on_success_message"), @@ -1116,7 +1116,7 @@ class Datasette: fragment=None, parameters=None, is_write=False, - published=False, + is_published=False, source="plugin", owner_id=None, on_success_message=None, @@ -1141,7 +1141,7 @@ class Datasette: sql_statement = """ INSERT INTO queries ( database_name, name, sql, title, description, description_html, - options, parameters, is_write, published, source, owner_id + options, parameters, is_write, is_published, source, owner_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ if replace: @@ -1154,7 +1154,7 @@ class Datasette: options = excluded.options, parameters = excluded.parameters, is_write = excluded.is_write, - published = excluded.published, + is_published = excluded.is_published, source = excluded.source, owner_id = excluded.owner_id, updated_at = CURRENT_TIMESTAMP @@ -1171,7 +1171,7 @@ class Datasette: options_json, parameters_json, int(bool(is_write)), - int(bool(published)), + int(bool(is_published)), source, owner_id, ], @@ -1190,7 +1190,7 @@ class Datasette: fragment=UNCHANGED, parameters=UNCHANGED, is_write=UNCHANGED, - published=UNCHANGED, + is_published=UNCHANGED, source=UNCHANGED, owner_id=UNCHANGED, on_success_message=UNCHANGED, @@ -1206,7 +1206,7 @@ class Datasette: "description_html": description_html, "parameters": parameters, "is_write": is_write, - "published": published, + "is_published": is_published, "source": source, "owner_id": owner_id, } @@ -1224,7 +1224,7 @@ class Datasette: for field, value in fields.items(): if value is UNCHANGED: continue - if field in {"is_write", "published"}: + if field in {"is_write", "is_published"}: value = int(bool(value)) elif field == "parameters": value = json.dumps(list(value or [])) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 9737de96..58deea01 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -136,7 +136,7 @@ async def default_query_permissions_sql( 'published query' AS reason FROM queries WHERE is_write = 0 - AND published = 1 + 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 @@ -145,7 +145,7 @@ async def default_query_permissions_sql( ON es.parent = q.database_name AND es.child IS NULL WHERE q.is_write = 0 - AND q.published = 0 + AND q.is_published = 0 {trusted_writable_sql} {user_writable_sql} """, diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index 1b3d30a8..fb2599d2 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -28,7 +28,7 @@

{% if can_publish %} -

+

{% endif %} {% if sql and analysis_is_write %}

Execute write SQL

diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index 854e8784..0f84e886 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -123,13 +123,13 @@ 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)), - published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), + is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published 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), - CHECK (is_write = 0 OR published = 0) + CHECK (is_write = 0 OR is_published = 0) ); CREATE INDEX IF NOT EXISTS queries_owner_idx diff --git a/datasette/views/database.py b/datasette/views/database.py index a90d889e..ed38189b 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -431,7 +431,7 @@ _query_fields = { "fragment", "parameters", "params", - "published", + "is_published", "on_success_message", "on_success_message_sql", "on_success_redirect", @@ -549,7 +549,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, published): +async def _analyze_user_query(datasette, db, sql, *, actor, is_published): if not sql or not isinstance(sql, str): raise QueryValidationError("SQL is required") derived = _derived_query_parameters(sql) @@ -561,7 +561,7 @@ async def _analyze_user_query(datasette, db, sql, *, actor, published): is_write = _analysis_is_write(analysis) if is_write: - if published: + if is_published: raise QueryValidationError("Writable queries cannot be published") try: await datasette.ensure_query_write_permissions( @@ -660,7 +660,7 @@ async def _prepare_execute_write(datasette, db, sql, params, actor): def _apply_query_data_types(data): typed = dict(data) - for key in ("hide_sql", "published"): + for key in ("hide_sql", "is_published"): if key in typed: typed[key] = _as_bool(typed[key]) return typed @@ -677,15 +677,15 @@ 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") - published = _as_bool(data.get("published")) + is_published = _as_bool(data.get("is_published")) is_write, derived, analysis = await _analyze_user_query( datasette, db, data.get("sql"), actor=request.actor, - published=published, + is_published=is_published, ) - if published and not await datasette.allowed( + if is_published and not await datasette.allowed( action="publish-query", resource=DatabaseResource(db.name), actor=request.actor, @@ -708,7 +708,7 @@ async def _prepare_query_create(datasette, request, db, data): "fragment": data.get("fragment"), "parameters": parameters, "is_write": is_write, - "published": published, + "is_published": is_published, "source": "user", "owner_id": _actor_id(request.actor), "on_success_message": data.get("on_success_message"), @@ -727,7 +727,7 @@ async def _prepare_query_update(datasette, request, db, existing, update): update = _apply_query_data_types(update) sql = update.get("sql", existing["sql"]) - published = update.get("published", existing["published"]) + is_published = update.get("is_published", existing["is_published"]) query_is_write = existing["is_write"] derived = _derived_query_parameters(sql) parameters = None @@ -738,11 +738,11 @@ async def _prepare_query_update(datasette, request, db, existing, update): db, sql, actor=request.actor, - published=published, + is_published=is_published, ) - elif published and query_is_write: + elif is_published and query_is_write: raise QueryValidationError("Writable queries cannot be published") - if published and not existing["published"]: + if is_published and not existing["is_published"]: if not await datasette.allowed( action="publish-query", resource=DatabaseResource(db.name), @@ -772,7 +772,7 @@ async def _prepare_query_update(datasette, request, db, existing, update): "fragment": update.get("fragment"), "parameters": parameters, "is_write": query_is_write, - "published": published, + "is_published": is_published, "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"), diff --git a/docs/internals.rst b/docs/internals.rst index a0845ade..892cf64c 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -2158,13 +2158,13 @@ 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)), - published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), + is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published 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), - CHECK (is_write = 0 OR published = 0) + CHECK (is_write = 0 OR is_published = 0) ); CREATE INDEX queries_owner_idx ON queries(owner_id); diff --git a/queries-plan.md b/queries-plan.md index dbc46101..0fbddecd 100644 --- a/queries-plan.md +++ b/queries-plan.md @@ -13,7 +13,7 @@ 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_published_idx` index. +- 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. - Add `update-query` and `delete-query`, so administrators can manage queries created by other users. @@ -45,13 +45,13 @@ 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)), - published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), + is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published 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), - CHECK (is_write = 0 OR published = 0) + CHECK (is_write = 0 OR is_published = 0) ); CREATE INDEX IF NOT EXISTS queries_owner_idx @@ -65,11 +65,11 @@ 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`. -- `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_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. - `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_published_idx` index for now. +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. `QueryResource.resources_sql()` can become: @@ -115,7 +115,7 @@ 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 `published=1` requires: +Setting `is_published=1` requires: - `publish-query` on `DatabaseResource(database)` - The query must be read-only according to `Database.analyze_sql()`. @@ -125,7 +125,7 @@ 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 `published` changes from `0` to `1`, also require `publish-query` on the database. +- If `is_published` changes from `0` to `1`, also require `publish-query` on the database. Deleting an existing query requires: @@ -140,12 +140,12 @@ Default owner permissions: Default execution rule for read-only queries: -- If `published=0`, the actor needs `execute-sql` on the database. -- If `published=1`, the actor can execute the query without `execute-sql`. +- 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`. Default execution rule for user-created writable queries: -- `published` must be `0`. +- `is_published` 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. @@ -153,8 +153,8 @@ Implementation: - Remove `view-query` from the broad `DEFAULT_ALLOW_ACTIONS` set. - Replace it with query-aware default `view-query` permission SQL. -- For `published=1 AND is_write=0`, emit a child-level `view-query` allow. -- For `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_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`. @@ -181,7 +181,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 `published=0`. +4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_published=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` @@ -201,7 +201,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. -- `published=1` is requested. +- `is_published=1` is requested. This gives us writable user-created queries without letting `execute-sql` alone become a path to create arbitrary write endpoints. @@ -226,7 +226,7 @@ Create request: "sql": "select * from customers order by revenue desc limit 20", "title": "Top customers", "description": "Highest revenue customers", - "published": false, + "is_published": false, "parameters": ["region"] } } @@ -243,7 +243,7 @@ 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", - "published": false, + "is_published": false, "parameters": ["region"] } } @@ -255,7 +255,7 @@ Update request, imitating `RowUpdateView`: { "update": { "title": "Top customers by revenue", - "published": true + "is_published": true }, "return": true } @@ -271,7 +271,7 @@ 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", - "published": true + "is_published": true } } ``` @@ -318,7 +318,7 @@ await datasette.add_query( fragment=None, parameters=None, is_write=False, - published=False, + is_published=False, source="plugin", owner_id=None, on_success_message=None, @@ -341,7 +341,7 @@ await datasette.update_query( fragment=UNCHANGED, parameters=UNCHANGED, is_write=UNCHANGED, - published=UNCHANGED, + is_published=UNCHANGED, source=UNCHANGED, owner_id=UNCHANGED, on_success_message=UNCHANGED, @@ -371,13 +371,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`, `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_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()`. ## 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 `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. @@ -416,7 +416,7 @@ The existing edit-SQL flow from query pages can continue to point back to arbitr - `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 `published=true` requires `publish-query`. +- Setting `is_published=true` requires `publish-query`. - 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. diff --git a/tests/test_queries.py b/tests/test_queries.py index edb9484a..df4131b9 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -30,7 +30,7 @@ async def test_queries_internal_table_schema(): "options", "parameters", "is_write", - "published", + "is_published", "source", "owner_id", "created_at", @@ -53,7 +53,7 @@ async def test_add_get_and_remove_query(): hide_sql=True, fragment="chart", parameters=["region"], - published=True, + is_published=True, source="user", owner_id="alice", ) @@ -86,7 +86,7 @@ async def test_add_get_and_remove_query(): "parameters": ["region"], "is_write": False, "write": False, - "published": True, + "is_published": True, "source": "user", "owner_id": "alice", "on_success_message": None, @@ -143,7 +143,7 @@ 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["published"] is False + assert query["is_published"] is False options_row = ( await ds.get_internal_database().execute( """ @@ -190,7 +190,7 @@ async def test_config_queries_imported_to_internal_table(): "parameters": ["name"], "is_write": False, "write": False, - "published": False, + "is_published": False, "source": "config", "owner_id": None, "on_success_message": None, @@ -218,8 +218,8 @@ async def test_unpublished_query_requires_execute_sql_but_published_does_not(): ds = Datasette(memory=True, settings={"default_allow_sql": False}) ds.add_memory_database("query_permissions", name="data") await ds.invoke_startup() - await ds.add_query("data", "unpublished", "select 1", published=False) - await ds.add_query("data", "published", "select 1", published=True) + await ds.add_query("data", "unpublished", "select 1", is_published=False) + await ds.add_query("data", "published", "select 1", is_published=True) assert not await ds.allowed( action="execute-sql", @@ -347,7 +347,7 @@ async def test_query_list_and_definition_api(): ds.root_enabled = True ds.add_memory_database("query_list_api", name="data") await ds.invoke_startup() - await ds.add_query("data", "listed", "select 1", title="Listed", published=True) + await ds.add_query("data", "listed", "select 1", title="Listed", is_published=True) list_response = await ds.client.get( "/data/-/queries", @@ -387,7 +387,7 @@ async def test_query_insert_api_publish_requires_publish_query(): response = await ds.client.post( "/data/-/queries/-/insert", actor={"id": "writer"}, - json={"query": {"name": "public", "sql": "select 1", "published": True}}, + json={"query": {"name": "public", "sql": "select 1", "is_published": True}}, ) assert response.status_code == 403 @@ -416,7 +416,7 @@ 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["published"] is False + assert query["is_published"] is False assert query["parameters"] == ["name"] bad_response = await ds.client.post( @@ -426,7 +426,7 @@ async def test_query_insert_api_creates_writable_query(): "query": { "name": "published_insert", "sql": "insert into dogs (name) values (:name)", - "published": True, + "is_published": True, } }, )