Write SQL query UI, canned queries now stored in internal database

PR #2741
This commit is contained in:
Simon Willison 2026-05-26 16:54:00 -07:00 committed by GitHub
commit 316daf9a43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 6762 additions and 564 deletions

View file

@ -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 <<EOF
from datasette import hookimpl
@ -69,23 +69,24 @@ jobs:
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
return inner
@hookimpl
def canned_queries(database):
if database == "counters":
queries = {}
for name in ("counter_a", "counter_b", "counter_c"):
queries["increment_{}".format(name)] = {
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
"write": True,
}
queries["decrement_{}".format(name)] = {
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
"write": True,
}
return queries
await datasette.add_query(
"counters",
"increment_{}".format(name),
"update counters set value = value + 1 where name = '{}'".format(name),
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
is_write=True,
is_trusted=True,
)
await datasette.add_query(
"counters",
"decrement_{}".format(name),
"update counters set value = value - 1 where name = '{}'".format(name),
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
is_write=True,
is_trusted=True,
)
return inner
EOF
# - name: Make some modifications to metadata.json
# run: |

View file

@ -42,8 +42,25 @@ from jinja2.exceptions import TemplateNotFound
from .events import Event
from .column_types import SQLiteType
from . import stored_queries
from .views import Context
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
from .views.database import (
database_download,
DatabaseView,
TableCreateView,
QueryView,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.stored_queries import (
QueryCreateAnalyzeView,
QueryDeleteView,
QueryDefinitionView,
GlobalQueryListView,
QueryListView,
QueryParametersView,
QueryStoreView,
QueryUpdateView,
)
from .views.index import IndexView
from .views.special import (
JsonDataView,
@ -571,6 +588,9 @@ class Datasette:
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
# a warning to user that they should delete their metadata.json file
async def _save_queries_from_config(self):
await stored_queries.save_queries_from_config(self)
def get_jinja_environment(self, request: Request = None) -> Environment:
environment = self._jinja_env
if request:
@ -731,6 +751,7 @@ class Datasette:
await await_me_maybe(hook)
# Ensure internal tables and metadata are populated before startup hooks
await self._refresh_schemas()
await self._save_queries_from_config()
# Load column_types from config into internal DB
await self._apply_column_types_config()
for hook in pm.hook.startup(datasette=self):
@ -1007,6 +1028,179 @@ class Datasette:
[database_name, resource_name, column_name, key, value],
)
@staticmethod
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):
return stored_queries.query_options_json(options)
async def add_query(
self,
database: str,
name: str,
sql: str,
*,
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,
name,
sql,
title=title,
description=description,
description_html=description_html,
hide_sql=hide_sql,
fragment=fragment,
parameters=parameters,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
on_success_message=on_success_message,
on_success_message_sql=on_success_message_sql,
on_success_redirect=on_success_redirect,
on_error_message=on_error_message,
on_error_redirect=on_error_redirect,
replace=replace,
)
async def update_query(
self,
database: str,
name: str,
*,
sql=stored_queries.UNCHANGED,
title=stored_queries.UNCHANGED,
description=stored_queries.UNCHANGED,
description_html=stored_queries.UNCHANGED,
hide_sql=stored_queries.UNCHANGED,
fragment=stored_queries.UNCHANGED,
parameters=stored_queries.UNCHANGED,
is_write=stored_queries.UNCHANGED,
is_private=stored_queries.UNCHANGED,
is_trusted=stored_queries.UNCHANGED,
source=stored_queries.UNCHANGED,
owner_id=stored_queries.UNCHANGED,
on_success_message=stored_queries.UNCHANGED,
on_success_message_sql=stored_queries.UNCHANGED,
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,
name,
sql=sql,
title=title,
description=description,
description_html=description_html,
hide_sql=hide_sql,
fragment=fragment,
parameters=parameters,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
on_success_message=on_success_message,
on_success_message_sql=on_success_message_sql,
on_success_redirect=on_success_redirect,
on_error_message=on_error_message,
on_error_redirect=on_error_redirect,
)
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: str, name: str
) -> stored_queries.StoredQuery | None:
return await stored_queries.get_query(self, database, name)
async def count_queries(
self,
database: str | None = 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,
actor=actor,
q=q,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
)
async def list_queries(
self,
database: str | None = None,
*,
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,
actor=actor,
limit=limit,
cursor=cursor,
q=q,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
include_private=include_private,
)
async def ensure_query_write_permissions(
self, database, sql, *, actor=None, params=None, analysis=None
):
return await stored_queries.ensure_query_write_permissions(
self, database, sql, actor=actor, params=params, analysis=analysis
)
# Column types API
async def _get_resource_column_details(self, database: str, resource: str):
@ -1238,29 +1432,6 @@ class Datasette:
def app_css_hash(self):
return self.static_hash("app.css")
async def get_canned_queries(self, database_name, actor):
queries = {}
for more_queries in pm.hook.canned_queries(
datasette=self,
database=database_name,
actor=actor,
):
more_queries = await await_me_maybe(more_queries)
queries.update(more_queries or {})
# Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
for key in queries:
if not isinstance(queries[key], dict):
queries[key] = {"sql": queries[key]}
# Also make sure "name" is available:
queries[key]["name"] = key
return queries
async def get_canned_query(self, database_name, query_name, actor):
queries = await self.get_canned_queries(database_name, actor)
query = queries.get(query_name)
if query:
return query
def _prepare_connection(self, conn, database):
conn.row_factory = sqlite3.Row
conn.text_factory = lambda x: str(x, "utf-8", "replace")
@ -2236,6 +2407,10 @@ class Datasette:
JumpView.as_view(self),
r"/-/jump(\.(?P<format>json))?$",
)
add_route(
GlobalQueryListView.as_view(self),
r"/-/queries(\.(?P<format>json))?$",
)
add_route(
InstanceSchemaView.as_view(self),
r"/-/schema(\.(?P<format>json|md))?$",
@ -2281,14 +2456,50 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
)
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
add_route(
QueryListView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
)
add_route(
QueryCreateAnalyzeView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/analyze$",
)
add_route(
QueryStoreView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/store$",
)
add_route(
ExecuteWriteAnalyzeView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write/analyze$",
)
add_route(
ExecuteWriteView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write$",
)
add_route(
DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
)
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
)
add_route(
wrap_view(QueryView, self),
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
)
add_route(
QueryDefinitionView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
)
add_route(
QueryUpdateView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
)
add_route(
QueryDeleteView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
)
add_route(
wrap_view(table_view, self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",

View file

@ -25,6 +25,7 @@ from .utils import (
table_columns,
table_column_details,
)
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
from .utils.sqlite import sqlite_version
from .inspect import inspect_hash
@ -301,6 +302,13 @@ class Database:
# Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True)
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
self._check_not_closed()
return await self.execute_isolated_fn(
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
)
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
self._check_not_closed()
pending_events = []

View file

@ -48,12 +48,26 @@ def register_actions():
resource_class=DatabaseResource,
also_requires="view-database",
),
Action(
name="execute-write-sql",
abbr="ews",
description="Execute writable SQL queries",
resource_class=DatabaseResource,
also_requires="view-database",
),
Action(
name="create-table",
abbr="ct",
description="Create tables",
resource_class=DatabaseResource,
),
Action(
name="store-query",
abbr="sq",
description="Create stored queries",
resource_class=DatabaseResource,
also_requires="execute-sql",
),
# Table-level actions (child-level)
Action(
name="view-table",
@ -104,4 +118,16 @@ def register_actions():
description="View named query results",
resource_class=QueryResource,
),
Action(
name="update-query",
abbr="uq",
description="Update stored queries",
resource_class=QueryResource,
),
Action(
name="delete-query",
abbr="dq",
description="Delete stored queries",
resource_class=QueryResource,
),
)

View file

@ -0,0 +1,24 @@
from datasette import hookimpl
from datasette.resources import DatabaseResource
@hookimpl
def database_actions(datasette, actor, database, request):
async def inner():
if not datasette.get_database(database).is_mutable:
return []
if not await datasette.allowed(
action="execute-write-sql",
resource=DatabaseResource(database),
actor=actor,
):
return []
return [
{
"href": datasette.urls.database(database) + "/-/execute-write",
"label": "Execute write SQL",
"description": "Run writable SQL with table permission checks.",
}
]
return inner

View file

@ -17,13 +17,6 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql,
@ -33,16 +26,9 @@ 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,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
@hookimpl
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
"""Return canned queries defined in datasette.yaml configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

@ -67,3 +67,48 @@ async def default_action_permissions_sql(
return PermissionSQL.allow(reason=reason)
return None
@hookimpl(specname="permission_resources_sql")
async def default_query_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[PermissionSQL]:
actor_id = actor.get("id") if isinstance(actor, dict) else None
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:
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/mutable by owner
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
""",
params=params,
)

View file

@ -83,7 +83,7 @@ class Facet:
self.ds = ds
self.request = request
self.database = database
# For foreign key expansion. Can be None for e.g. canned SQL queries:
# For foreign key expansion. Can be None for e.g. stored SQL queries:
self.table = table
self.sql = sql or f"select * from [{table}]"
self.params = params or []

View file

@ -137,11 +137,6 @@ def permission_resources_sql(datasette, actor, action):
"""
@hookspec
def canned_queries(datasette, database, actor):
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
@hookspec
def register_magic_parameters(datasette):
"""Return a list of (name, function) magic parameter functions"""
@ -179,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
@ -233,8 +228,8 @@ 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"""
def top_stored_query(datasette, request, database, query_name):
"""HTML to include at the top of the stored query page"""
@hookspec

View file

@ -30,6 +30,7 @@ DEFAULT_PLUGINS = (
"datasette.blob_renderer",
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.handle_exception",
"datasette.forbidden",
"datasette.events",

View file

@ -41,7 +41,7 @@ class TableResource(Resource):
class QueryResource(Resource):
"""A canned query in a database."""
"""A stored query in a database."""
name = "query"
parent_class = DatabaseResource
@ -51,42 +51,8 @@ class QueryResource(Resource):
@classmethod
async def resources_sql(cls, datasette, actor=None) -> str:
from datasette.plugins import pm
from datasette.utils import await_me_maybe
# Get all databases from catalog
db = datasette.get_internal_database()
result = await db.execute("SELECT database_name FROM catalog_databases")
databases = [row[0] for row in result.rows]
# Gather canned queries for this actor from all databases.
# This keeps allowed_resources("view-query", actor=...) consistent with
# actor-specific canned_queries() implementations.
query_pairs = []
for database_name in databases:
# Call the hook to get queries (including from config via default plugin)
for queries_result in pm.hook.canned_queries(
datasette=datasette,
database=database_name,
actor=actor,
):
queries = await await_me_maybe(queries_result)
if queries:
for query_name in queries.keys():
query_pairs.append((database_name, query_name))
# Build SQL
if not query_pairs:
return "SELECT NULL AS parent, NULL AS child WHERE 0"
# Generate UNION ALL query
selects = []
for db_name, query_name in query_pairs:
# Escape single quotes by doubling them
db_escaped = db_name.replace("'", "''")
query_escaped = query_name.replace("'", "''")
selects.append(
f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child"
)
return " UNION ALL ".join(selects)
return """
SELECT q.database_name AS parent, q.name AS child
FROM queries q
JOIN catalog_databases cd ON cd.database_name = q.database_name
"""

View file

@ -1409,11 +1409,15 @@ svg.dropdown-menu-icon {
border-bottom: 5px solid #666;
}
.canned-query-edit-sql {
.stored-query-edit-sql {
padding-left: 0.5em;
position: relative;
top: 1px;
}
.save-query {
display: inline-block;
margin-left: 0.45em;
}
.blob-download {
display: block;

623
datasette/stored_queries.py Normal file
View file

@ -0,0 +1,623 @@
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
from .utils.asgi import Forbidden
UNCHANGED = object()
QUERY_OPTION_FIELDS = (
"hide_sql",
"fragment",
"on_success_message",
"on_success_message_sql",
"on_success_redirect",
"on_error_message",
"on_error_redirect",
)
@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'"
)
for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items():
for query_name, query_config in (db_config.get("queries") or {}).items():
if not isinstance(query_config, dict):
query_config = {"sql": query_config}
await datasette.add_query(
dbname,
query_name,
query_config["sql"],
title=query_config.get("title"),
description=query_config.get("description"),
description_html=query_config.get("description_html"),
hide_sql=bool(query_config.get("hide_sql")),
fragment=query_config.get("fragment"),
parameters=query_config.get("params"),
is_write=bool(query_config.get("write")),
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"),
on_success_redirect=query_config.get("on_success_redirect"),
on_error_message=query_config.get("on_error_message"),
on_error_redirect=query_config.get("on_error_redirect"),
)
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 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: dict[str, Any]) -> str:
options_dict = {}
for field in QUERY_OPTION_FIELDS:
value = options.get(field)
if field == "hide_sql":
if value:
options_dict[field] = True
elif value is not None:
options_dict[field] = value
return json.dumps(options_dict, sort_keys=True)
async def add_query(
datasette: Any,
database: str,
name: str,
sql: str,
*,
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(
{
"hide_sql": hide_sql,
"fragment": fragment,
"on_success_message": on_success_message,
"on_success_message_sql": on_success_message_sql,
"on_success_redirect": on_success_redirect,
"on_error_message": on_error_message,
"on_error_redirect": on_error_redirect,
}
)
sql_statement = """
INSERT INTO queries (
database_name, name, sql, title, description, description_html,
options, parameters, is_write, is_private, is_trusted, source, owner_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
if replace:
sql_statement += """
ON CONFLICT(database_name, name) DO UPDATE SET
sql = excluded.sql,
title = excluded.title,
description = excluded.description,
description_html = excluded.description_html,
options = excluded.options,
parameters = excluded.parameters,
is_write = excluded.is_write,
is_private = excluded.is_private,
is_trusted = excluded.is_trusted,
source = excluded.source,
owner_id = excluded.owner_id,
updated_at = CURRENT_TIMESTAMP
"""
await datasette.get_internal_database().execute_write(
sql_statement,
[
database,
name,
sql,
title,
description,
description_html,
options_json,
parameters_json,
int(bool(is_write)),
int(bool(is_private)),
int(bool(is_trusted)),
source,
owner_id,
],
)
async def update_query(
datasette: Any,
database: str,
name: str,
*,
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,
) -> None:
fields = {
"sql": sql,
"title": title,
"description": description,
"description_html": description_html,
"parameters": parameters,
"is_write": is_write,
"is_private": is_private,
"is_trusted": is_trusted,
"source": source,
"owner_id": owner_id,
}
option_fields = {
"hide_sql": hide_sql,
"fragment": fragment,
"on_success_message": on_success_message,
"on_success_message_sql": on_success_message_sql,
"on_success_redirect": on_success_redirect,
"on_error_message": on_error_message,
"on_error_redirect": on_error_redirect,
}
updates = []
params = []
for field, value in fields.items():
if value is UNCHANGED:
continue
if field in {"is_write", "is_private", "is_trusted"}:
value = int(bool(value))
elif field == "parameters":
value = json.dumps(list(value or []))
updates.append(f"{field} = ?")
params.append(value)
changed_options = {
field: value for field, value in option_fields.items() if value is not UNCHANGED
}
if changed_options:
rows = await datasette.get_internal_database().execute(
"""
SELECT options FROM queries
WHERE database_name = ? AND name = ?
""",
[database, name],
)
row = rows.first()
options = json.loads(row["options"] or "{}") if row is not None else {}
for field, value in changed_options.items():
if field == "hide_sql":
if value:
options[field] = True
else:
options.pop(field, None)
elif value is None:
options.pop(field, None)
else:
options[field] = value
updates.append("options = ?")
params.append(json.dumps(options, sort_keys=True))
if not updates:
return
updates.append("updated_at = CURRENT_TIMESTAMP")
params.extend([database, name])
await datasette.get_internal_database().execute_write(
"""
UPDATE queries
SET {}
WHERE database_name = ? AND name = ?
""".format(", ".join(updates)),
params,
)
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:
sql += " AND source = ?"
params.append(source)
await datasette.get_internal_database().execute_write(sql, params)
async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None:
rows = await datasette.get_internal_database().execute(
"""
SELECT * FROM queries
WHERE database_name = ? AND name = ?
""",
[database, name],
)
return query_row_to_stored_query(rows.first())
async def count_queries(
datasette: Any,
database: str | None = 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,
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 datasette.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(
datasette: Any,
database: str | None = None,
*,
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",
actor=actor,
parent=database,
include_is_private=include_private,
)
params = dict(allowed_params)
params.update({"limit": limit + 1})
sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))"
where_clauses = []
order_by = "q.database_name, sort_key, q.name"
if database is not None:
params["query_database"] = database
where_clauses.append("q.database_name = :query_database")
order_by = "sort_key, q.name"
if cursor:
try:
components = urlsafe_components(cursor)
except ValueError:
components = []
if database is None and len(components) == 3:
where_clauses.append("""
(
q.database_name > :cursor_database
OR (
q.database_name = :cursor_database
AND (
{sort_key_sql} > :cursor_sort_key
OR (
{sort_key_sql} = :cursor_sort_key
AND q.name > :cursor_name
)
)
)
)
""".format(sort_key_sql=sort_key_sql))
params["cursor_database"] = components[0]
params["cursor_sort_key"] = components[1]
params["cursor_name"] = components[2]
elif database is not None and len(components) == 2:
where_clauses.append("""
(
{sort_key_sql} > :cursor_sort_key
OR (
{sort_key_sql} = :cursor_sort_key
AND q.name > :cursor_name
)
)
""".format(sort_key_sql=sort_key_sql))
params["cursor_sort_key"] = components[0]
params["cursor_name"] = components[1]
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
private_select = ", allowed.is_private AS private" if include_private else ""
rows = list(
(
await datasette.get_internal_database().execute(
"""
SELECT q.*, {sort_key_sql} AS sort_key{private_select}
FROM queries q
JOIN (
{allowed_sql}
) allowed
ON allowed.parent = q.database_name
AND allowed.child = q.name
WHERE {where}
ORDER BY {order_by}
LIMIT :limit
""".format(
allowed_sql=allowed_sql,
private_select=private_select,
sort_key_sql=sort_key_sql,
where=" AND ".join(where_clauses) or "1 = 1",
order_by=order_by,
),
params,
)
).rows
)
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
queries = []
for row in rows:
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
if has_more and rows:
last_row = rows[-1]
if database is None:
next_token = "{},{},{}".format(
tilde_encode(last_row["database_name"]),
tilde_encode(last_row["sort_key"]),
tilde_encode(last_row["name"]),
)
else:
next_token = "{},{}".format(
tilde_encode(last_row["sort_key"]),
tilde_encode(last_row["name"]),
)
return StoredQueryPage(
queries=queries,
next=next_token,
has_more=has_more,
limit=limit,
)
async def ensure_query_write_permissions(
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",
"delete": "delete-row",
}
db = datasette.get_database(database)
if analysis is None:
if params is None:
params = {name: "" for name in named_parameters(sql)}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise Forbidden(f"Could not analyze query: {ex}") from ex
for access in analysis.table_accesses:
action = write_actions.get(access.operation)
if action is None:
continue
if access.database != database:
raise Forbidden("Writable queries may not write to attached databases")
if not await datasette.allowed(
action=action,
resource=TableResource(database=access.database, table=access.table),
actor=actor,
):
raise Forbidden(
f"Permission denied: need {action} on {access.database}/{access.table}"
)
return analysis

View file

@ -0,0 +1,111 @@
<script>
window.datasetteSqlAnalysis = (() => {
if (
window.datasetteSqlAnalysis &&
window.datasetteSqlAnalysis.renderAnalysis
) {
return window.datasetteSqlAnalysis;
}
function appendCodeCell(row, value, emptyText) {
const cell = document.createElement("td");
if (value) {
const code = document.createElement("code");
code.textContent = value;
cell.appendChild(code);
} else if (emptyText) {
appendNotApplicable(cell);
}
row.appendChild(cell);
}
function appendNotApplicable(cell) {
const notApplicable = document.createElement("span");
notApplicable.className = "execute-write-analysis-na";
notApplicable.textContent = "n/a";
cell.appendChild(notApplicable);
}
function renderAnalysis(section, data) {
if (!section) {
return;
}
section.replaceChildren();
if (data.has_sql === false) {
section.hidden = true;
return;
}
section.hidden = false;
const heading = document.createElement("h2");
heading.textContent = "Query operations";
section.appendChild(heading);
if (data.analysis_error) {
const error = document.createElement("p");
error.className = "message-error";
error.textContent = data.analysis_error;
section.appendChild(error);
return;
}
const rows = data.analysis_rows || [];
if (!rows.length) {
const empty = document.createElement("p");
empty.textContent =
"Analysis will show each affected table and required permission.";
section.appendChild(empty);
return;
}
const wrapper = document.createElement("div");
wrapper.className = "table-wrapper";
const table = document.createElement("table");
table.className = "execute-write-analysis";
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
[
"Operation",
"Database",
"Table",
"Required permission",
"Allowed",
].forEach((label) => {
const th = document.createElement("th");
th.scope = "col";
th.textContent = label;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
rows.forEach((analysisRow) => {
const row = document.createElement("tr");
appendCodeCell(row, analysisRow.operation);
appendCodeCell(row, analysisRow.database);
appendCodeCell(row, analysisRow.table);
appendCodeCell(row, analysisRow.required_permission, "n/a");
const allowedCell = document.createElement("td");
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
const allowed = document.createElement("span");
allowed.className = analysisRow.allowed
? "execute-write-analysis-allowed"
: "execute-write-analysis-denied";
allowed.textContent = analysisRow.allowed ? "yes" : "no";
allowedCell.appendChild(allowed);
} else {
appendNotApplicable(allowedCell);
}
row.appendChild(allowedCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
wrapper.appendChild(table);
section.appendChild(wrapper);
}
return { renderAnalysis };
})();
</script>

View file

@ -0,0 +1,41 @@
<style>
.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;
}
.execute-write-analysis-na {
color: #687386;
font-style: italic;
}
</style>

View file

@ -0,0 +1,293 @@
<script>
window.datasetteSqlParameters = (() => {
if (
window.datasetteSqlParameters &&
window.datasetteSqlParameters.setupSqlParameterRefresh
) {
return window.datasetteSqlParameters;
}
function currentSql(form) {
if (window.editor) {
return window.editor.state.doc.toString();
}
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
return sqlInput ? sqlInput.value : "";
}
function controlState(control) {
return {
value: control.value,
expanded: control.tagName.toLowerCase() === "textarea",
};
}
function syncParameterState(manager) {
manager.parameterState = new Map();
manager.section
.querySelectorAll("[data-parameter-control]")
.forEach((control) => {
manager.parameterState.set(control.name, controlState(control));
});
}
function createControl(parameter, id, state) {
const control = document.createElement(state.expanded ? "textarea" : "input");
control.id = id;
control.name = parameter;
control.value = state.value;
control.setAttribute("data-parameter-control", "");
if (state.expanded) {
control.rows = 5;
} else {
control.type = "text";
}
return control;
}
function replaceParameterControl(
manager,
control,
button,
expand,
value,
selectionStart
) {
const replacement = createControl(control.name, control.id, {
value: value === undefined ? control.value : value,
expanded: expand,
});
button.textContent = expand ? "Collapse" : "Expand";
button.setAttribute("aria-expanded", expand ? "true" : "false");
control.replaceWith(replacement);
replacement.focus();
if (selectionStart !== undefined && replacement.setSelectionRange) {
replacement.setSelectionRange(selectionStart, selectionStart);
}
manager.parameterState.set(replacement.name, controlState(replacement));
}
function renderParameters(manager, parameters) {
syncParameterState(manager);
const previousState = manager.parameterState;
const nextState = new Map();
manager.section.replaceChildren();
if (!parameters.length) {
manager.parameterState = nextState;
return;
}
const heading = document.createElement("h2");
heading.textContent = "Parameters";
manager.section.appendChild(heading);
parameters.forEach((parameter, index) => {
const id = `qp${index + 1}`;
const state = previousState.get(parameter) || {
value: "",
expanded: false,
};
if (!manager.allowExpand) {
state.expanded = false;
}
nextState.set(parameter, state);
const row = document.createElement("p");
row.className = "sql-parameter-row";
const label = document.createElement("label");
label.htmlFor = id;
label.textContent = parameter;
const control = createControl(parameter, id, state);
row.append(label, control);
if (manager.allowExpand) {
const button = document.createElement("button");
button.type = "button";
button.className = "sql-parameter-toggle";
button.setAttribute("data-parameter-toggle", "");
button.setAttribute("aria-controls", id);
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
button.textContent = state.expanded ? "Collapse" : "Expand";
row.append(" ", button);
}
manager.section.appendChild(row);
});
manager.parameterState = nextState;
}
function bindParameterControls(manager) {
manager.form.addEventListener("input", (event) => {
const control = event.target;
if (!control.matches || !control.matches("[data-parameter-control]")) {
return;
}
manager.parameterState.set(control.name, controlState(control));
});
if (!manager.allowExpand) {
return;
}
manager.form.addEventListener("click", (event) => {
const button = event.target.closest
? event.target.closest("[data-parameter-toggle]")
: null;
if (!button || !manager.form.contains(button)) {
return;
}
const control = document.getElementById(button.getAttribute("aria-controls"));
if (!control) {
return;
}
const expanded = control.tagName.toLowerCase() === "textarea";
replaceParameterControl(manager, control, button, !expanded);
});
manager.form.addEventListener("paste", (event) => {
const control = event.target;
if (
!(control instanceof HTMLInputElement) ||
!control.matches("[data-parameter-control]")
) {
return;
}
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
if (!/[\r\n]/.test(pasted)) {
return;
}
const button = document.querySelector(
`[data-parameter-toggle][aria-controls="${control.id}"]`
);
if (!button) {
return;
}
event.preventDefault();
const selectionStart = control.selectionStart ?? control.value.length;
const selectionEnd = control.selectionEnd ?? selectionStart;
const value =
control.value.slice(0, selectionStart) +
pasted +
control.value.slice(selectionEnd);
replaceParameterControl(
manager,
control,
button,
true,
value,
selectionStart + pasted.length
);
});
}
function bindEditorChanges(form, callback) {
const editorElement = form.querySelector(".cm-content");
if (editorElement) {
editorElement.addEventListener("input", callback);
}
if (!window.editor) {
const sqlInput = form.querySelector("textarea#sql-editor");
if (sqlInput) {
sqlInput.addEventListener("input", callback);
}
return;
}
if (!window.editor.datasetteSqlParameterCallbacks) {
const editor = window.editor;
const originalDispatch = editor.dispatch.bind(editor);
editor.datasetteSqlParameterCallbacks = [];
editor.dispatch = (...transactions) => {
const before = editor.state.doc.toString();
originalDispatch(...transactions);
if (editor.state.doc.toString() !== before) {
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
}
};
}
window.editor.datasetteSqlParameterCallbacks.push(callback);
}
function setupSqlParameterRefresh(options) {
const form =
options.form || document.querySelector("form.sql.core[data-parameters-url]");
if (!form) {
return null;
}
const shouldRenderParameters = options.renderParameters !== false;
const section =
options.section || form.querySelector("[data-sql-parameters-section]");
if (shouldRenderParameters && !section) {
return null;
}
const manager = {
form,
section,
allowExpand:
options.allowExpand === undefined
? section
? section.dataset.allowExpand === "1"
: false
: options.allowExpand,
parameterState: new Map(),
};
if (section) {
bindParameterControls(manager);
syncParameterState(manager);
}
const url = options.url || form.dataset.parametersUrl;
let refreshTimer = null;
let refreshSequence = 0;
async function refreshParameters() {
if (!url) {
return;
}
const sequence = ++refreshSequence;
try {
const requestUrl = new URL(url, window.location.href);
requestUrl.searchParams.set("sql", currentSql(form));
const response = await fetch(requestUrl, {
headers: { accept: "application/json" },
});
const data = await response.json();
if (sequence !== refreshSequence) {
return;
}
if (!response.ok) {
throw new Error((data.errors || [response.statusText]).join("; "));
}
if (shouldRenderParameters) {
renderParameters(manager, data.parameters || []);
}
if (options.onData) {
options.onData(data, manager);
}
} catch (error) {
if (sequence !== refreshSequence) {
return;
}
if (options.onError) {
options.onError(error, manager);
}
}
}
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
}
bindEditorChanges(form, scheduleRefresh);
return {
currentSql: () => currentSql(form),
refreshParameters,
renderParameters: (parameters) => renderParameters(manager, parameters),
};
}
return { setupSqlParameterRefresh };
})();
</script>

View file

@ -0,0 +1,58 @@
<style>
form.sql .sql-editor {
max-width: 52rem;
}
form.sql .sql-editor textarea#sql-editor {
width: 100%;
}
form.sql .sql-parameters-section {
max-width: 52rem;
}
form.sql .sql-parameter-row {
align-items: start;
column-gap: 0.6rem;
display: grid;
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
margin: 0 0 0.65rem;
max-width: 52rem;
}
form.sql .sql-parameter-row label {
overflow-wrap: anywhere;
padding-top: 0.55rem;
width: auto;
}
form.sql .sql-parameter-row input[data-parameter-control],
form.sql .sql-parameter-row textarea[data-parameter-control] {
box-sizing: border-box;
width: 100%;
}
form.sql .sql-parameter-row textarea[data-parameter-control] {
border: 1px solid #ccc;
border-radius: 3px;
display: block;
font-family: Helvetica, sans-serif;
font-size: 1em;
min-height: 7rem;
padding: 9px 4px;
}
form.sql.core button.sql-parameter-toggle[type=button] {
font-size: 0.72rem;
height: 1.8rem;
line-height: 1;
margin: 0.25rem 0 0;
padding: 0.25rem 0.45rem;
}
@media (max-width: 480px) {
form.sql .sql-parameter-row {
grid-template-columns: 1fr;
row-gap: 0.25rem;
}
form.sql .sql-parameter-row label {
padding-top: 0;
}
form.sql.core button.sql-parameter-toggle[type=button] {
justify-self: start;
margin-top: 0;
}
}
</style>

View file

@ -0,0 +1,9 @@
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
{% if parameter_names %}
<h2>Parameters</h2>
{% for parameter in parameter_names %}
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control>{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
{% endfor %}
{% endif %}
</div>

View file

@ -5,6 +5,7 @@
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
@ -25,9 +26,13 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if allow_execute_sql %}
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
<h3>Custom SQL query</h3>
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
{% set parameter_names = [] %}
{% set parameter_values = {} %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p>
<button id="sql-format" type="button" hidden>Format SQL</button>
<input type="submit" value="Run SQL">
@ -53,6 +58,9 @@
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
{% endfor %}
</ul>
{% if queries_more %}
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
{% endif %}
{% endif %}
{% if tables %}
@ -87,5 +95,11 @@
{% endif %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %}

View file

@ -0,0 +1,314 @@
{% extends "base.html" %}
{% block title %}Write to this database{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
<style>
.execute-write-template-menu {
margin: 0.9rem 0 0.8rem;
max-width: 52rem;
}
.execute-write-template-menu summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 0.35rem;
}
.execute-write-template-controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.4rem 0 0.7rem;
}
.execute-write-template-menu .execute-write-template-controls label {
margin-right: 0.25rem;
width: auto;
}
.execute-write-template-controls select,
.execute-write-template-controls button[type=button] {
box-sizing: border-box;
font-size: 0.78rem;
height: 2rem;
line-height: 1.1;
padding: 0.35rem 0.55rem;
}
.execute-write-template-controls select {
background-color: #fff;
border: 1px solid #777;
border-radius: 0.25rem;
min-width: 13rem;
}
</style>
{% include "_execute_write_analysis_styles.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %}
{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Write to this database</h1>
<p>Execute SQL to insert, update or delete rows in this database.</p>
{% if execution_message %}
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
{% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
{% if write_template_tables %}
<div class="execute-write-template-menu">
<details>
<summary>Start with a template</summary>
<p class="execute-write-template-controls">
<label for="execute-write-template-table">Table</label>
<select id="execute-write-template-table">
{% for table_name, columns in write_template_tables|dictsort %}
<option value="{{ table_name }}">{{ table_name }}</option>
{% endfor %}
</select>
<button type="button" data-sql-template="insert">Insert row</button>
<button type="button" data-sql-template="update">Update rows</button>
<button type="button" data-sql-template="delete">Delete rows</button>
</p>
</details>
</div>
{% endif %}
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
{% set sql_parameters_allow_expand = true %}
{% include "_sql_parameters.html" %}
<div id="execute-write-analysis-section">
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
</div>
<p>
<input type="submit" value="Execute" data-execute-write-submit{% if execute_disabled %} disabled{% endif %}>
{% if save_query_base_url %}<a href="{{ save_query_url or save_query_base_url }}" class="save-query" data-save-query-link data-save-query-base-url="{{ save_query_base_url }}"{% if not save_query_url %} hidden{% endif %}>Save this query</a>{% endif %}
</p>
</form>
<script>
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
if (executeWriteSqlInput && !executeWriteSqlInput.value) {
executeWriteSqlInput.value = "\n\n\n";
}
</script>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#execute-write-analysis-section");
const submitButton = form
? form.querySelector("[data-execute-write-submit]")
: null;
const saveQueryLink = form
? form.querySelector("[data-save-query-link]")
: null;
function updateSaveQueryLink(data) {
if (!saveQueryLink) {
return;
}
const sql = window.editor
? window.editor.state.doc.toString()
: executeWriteSqlInput.value;
if (!sql.trim() || !data.ok || data.execute_disabled) {
saveQueryLink.hidden = true;
return;
}
const url = new URL(saveQueryLink.dataset.saveQueryBaseUrl, window.location.href);
url.searchParams.set("sql", sql);
saveQueryLink.href = url.pathname + url.search + url.hash;
saveQueryLink.hidden = false;
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
allowExpand: true,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
if (submitButton) {
submitButton.disabled = data.execute_disabled;
}
updateSaveQueryLink(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
if (submitButton) {
submitButton.disabled = true;
}
if (saveQueryLink) {
saveQueryLink.hidden = true;
}
},
});
});
</script>
{% if write_template_tables %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const tableColumns = {{ write_template_tables|tojson(2) }};
const tableSelect = document.querySelector("#execute-write-template-table");
const templateButtons = document.querySelectorAll("[data-sql-template]");
function quoteIdentifier(identifier) {
return `"${identifier.replace(/"/g, '""')}"`;
}
function parameterNames(columns) {
const seen = new Set();
const names = {};
columns.forEach((column) => {
let base = column
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "");
if (!base) {
base = "value";
}
if (/^[0-9]/.test(base)) {
base = `p_${base}`;
}
let name = base;
let index = 2;
while (seen.has(name)) {
name = `${base}_${index}`;
index += 1;
}
seen.add(name);
names[column] = name;
});
return names;
}
function preferredWhereColumn(table, columns) {
const lowerTableId = `${table.toLowerCase()}_id`;
return (
columns.find((column) => column.toLowerCase() === "id") ||
columns.find((column) => column.toLowerCase() === lowerTableId) ||
columns[0]
);
}
function insertSql(table, columns) {
const names = parameterNames(columns);
return [
`insert into ${quoteIdentifier(table)} (`,
columns.map((column) => ` ${quoteIdentifier(column)}`).join(",\n"),
")",
"values (",
columns.map((column) => ` :${names[column]}`).join(",\n"),
")",
].join("\n");
}
function updateSql(table, columns) {
const names = parameterNames(columns);
const whereColumn = preferredWhereColumn(table, columns);
const setColumns = columns.filter((column) => column !== whereColumn);
if (!setColumns.length) {
return [
`update ${quoteIdentifier(table)}`,
`set ${quoteIdentifier(whereColumn)} = :new_${names[whereColumn]}`,
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
].join("\n");
}
return [
`update ${quoteIdentifier(table)}`,
"set " +
setColumns
.map((column, index) => {
const indent = index ? " " : "";
return `${indent}${quoteIdentifier(column)} = :${names[column]}`;
})
.join(",\n"),
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
].join("\n");
}
function deleteSql(table, columns) {
const names = parameterNames(columns);
const whereColumn = preferredWhereColumn(table, columns);
return [
`delete from ${quoteIdentifier(table)}`,
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
].join("\n");
}
function templateSql(operation, table, columns) {
if (operation === "insert") {
return insertSql(table, columns);
}
if (operation === "update") {
return updateSql(table, columns);
}
return deleteSql(table, columns);
}
templateButtons.forEach((button) => {
button.addEventListener("click", () => {
const table = tableSelect.value;
const columns = tableColumns[table] || [];
if (!columns.length) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set(
"sql",
templateSql(button.dataset.sqlTemplate, table, columns)
);
window.location.href = url.toString();
});
});
});
</script>
{% endif %}
{% endblock %}

View file

@ -14,9 +14,10 @@
</style>
{% endif %}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %}
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
@ -24,19 +25,19 @@
{% block content %}
{% if canned_query_write and db_is_immutable %}
{% if stored_query_write and db_is_immutable %}
<p class="message-error">This query cannot be executed because the database is immutable.</p>
{% endif %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
{% set action_links, action_title = query_actions(), "Query actions" %}
{% include "_action_menu.html" %}
{% if canned_query %}{{ top_canned_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 %}
<form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
<form class="sql core" action="{{ urls.database(database) }}{% if stored_query %}/{{ stored_query }}{% endif %}" method="{% if stored_query_write %}post{% else %}get{% endif %}" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
<h3>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 %}
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
{% endif %}</h3>
@ -45,29 +46,28 @@
{% endif %}
{% if not hide_sql %}
{% if editable and allow_execute_sql %}
<p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
{% else %}
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
{% endif %}
{% else %}
{% if not canned_query %}
{% if not stored_query %}
<input type="hidden" name="sql"
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
>
{% endif %}
{% endif %}
{% if named_parameter_values %}
<h3>Query parameters</h3>
{% for name, value in named_parameter_values.items() %}
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
{% endfor %}
{% endif %}
{% set parameter_names = named_parameter_values.keys()|list %}
{% set parameter_values = named_parameter_values %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p>
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}>
{{ show_hide_hidden }}
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %}
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
</p>
</form>
@ -90,11 +90,17 @@
</tbody>
</table></div>
{% else %}
{% if not canned_query_write and not error %}
{% if not stored_query_write and not error %}
<p class="zero-results">0 results</p>
{% endif %}
{% endif %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %}

View file

@ -0,0 +1,302 @@
{% extends "base.html" %}
{% block title %}Create query{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
<style>
.query-create-page {
max-width: 64rem;
}
.query-create-form {
--query-create-label-width: clamp(7rem, 18vw, 10rem);
--query-create-column-gap: 0.8rem;
--query-create-control-width: minmax(16rem, 1fr);
}
.query-create-fields {
margin: 0 0 0.85rem;
max-width: 52rem;
}
.query-create-field {
align-items: start;
column-gap: var(--query-create-column-gap);
display: grid;
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
margin: 0 0 0.65rem;
}
.query-create-field label {
padding-top: 0.55rem;
width: auto;
}
.query-create-field input[type=text],
.query-create-field textarea {
box-sizing: border-box;
width: 100%;
}
form.sql .query-create-field textarea {
width: 100%;
}
.query-create-url-control {
align-items: center;
box-sizing: border-box;
display: grid;
gap: 0.35rem;
grid-template-columns: max-content minmax(12rem, 1fr);
width: 100%;
}
.query-create-url-prefix {
color: #4f5b6d;
font-family: var(--font-monospace, monospace);
white-space: nowrap;
}
.query-create-url-control input[type=text] {
border: 1px solid #ccc;
border-radius: 3px;
}
.query-create-field textarea {
border: 1px solid #ccc;
border-radius: 3px;
display: block;
font-family: Helvetica, sans-serif;
font-size: 1em;
min-height: 5rem;
padding: 9px 4px;
resize: vertical;
}
form.sql .query-create-sql {
column-gap: var(--query-create-column-gap);
display: grid;
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
margin: 0.9rem 0 0.75rem;
max-width: 52rem;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
grid-column: 2;
width: 100%;
}
.query-create-options {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.8rem 1.4rem;
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
}
.query-create-options label {
align-items: center;
display: inline-flex;
gap: 0.35rem;
width: auto;
}
.query-create-options input[type=checkbox] {
margin: 0;
}
.query-create-option-note,
.query-create-analysis-note {
color: #4f5b6d;
flex-basis: 100%;
font-size: 0.82rem;
}
.query-create-option-note {
margin: -0.45rem 0 0;
}
.query-create-analysis-note {
margin: 0;
}
.query-create-action {
margin: 0.35rem 0 1rem;
}
.query-create-analysis {
margin-top: 0.8rem;
}
.query-create-submit {
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
margin-bottom: 0.9rem;
margin-top: 1rem;
}
@media (max-width: 560px) {
.query-create-form {
--query-create-label-width: 1fr;
--query-create-column-gap: 0;
}
.query-create-field {
grid-template-columns: 1fr;
row-gap: 0.25rem;
}
.query-create-field label {
padding-top: 0;
}
form.sql .query-create-sql {
grid-template-columns: 1fr;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
grid-column: 1;
}
.query-create-options,
.query-create-submit {
margin-left: 0;
}
}
</style>
{% endblock %}
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-create-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/store" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
<div class="query-create-fields">
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
</div>
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="query-create-options">
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
<input type="hidden" name="is_private" value="0">
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
</p>
{% if sql and analysis_is_write %}
<p class="query-create-action"><a href="{{ urls.database(database) }}/-/execute-write?{{ {'sql': sql}|urlencode|safe }}">Execute write SQL</a></p>
{% endif %}
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
{% if has_sql %}
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
{% endif %}
</div>
</form>
</div>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const titleInput = document.querySelector("#query-title");
const urlInput = document.querySelector("#query-url-slug");
let urlEdited = Boolean(urlInput && urlInput.value);
function slugify(value) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
if (titleInput && urlInput) {
titleInput.addEventListener("input", () => {
if (!urlEdited) {
urlInput.value = slugify(titleInput.value);
}
});
urlInput.addEventListener("input", () => {
urlEdited = true;
});
}
});
</script>
<script>
window.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#query-create-analysis-section");
const submitButton = form
? form.querySelector("[data-query-create-submit]")
: null;
const analysisNote = form
? form.querySelector("[data-query-create-analysis-note]")
: null;
function updateAnalysisNote(data) {
if (!analysisNote) {
return;
}
if (data.analysis_error) {
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
} else if (data.has_sql === false) {
analysisNote.textContent = "Enter SQL to analyze this query.";
} else if (data.analysis_is_write) {
analysisNote.textContent = "This query updates data in the database.";
} else {
analysisNote.textContent = "This is a read-only query.";
}
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
renderParameters: false,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
if (submitButton) {
submitButton.disabled = data.save_disabled;
}
updateAnalysisNote(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
if (submitButton) {
submitButton.disabled = true;
}
updateAnalysisNote({ analysis_error: error.message });
},
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
{% block extra_head %}
{{- super() -}}
<style>
.query-list-page {
max-width: 64rem;
}
.query-list-filters {
margin: 0.5rem 0 0.75rem;
}
.query-list-search {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin: 0 0 0.75rem;
}
.query-list-search label {
width: auto;
}
.query-list-search input[type=search] {
box-sizing: border-box;
flex: 1 1 18rem;
max-width: 24rem;
}
.query-list-search button[type=submit] {
font-size: 0.78rem;
height: 2rem;
line-height: 1.1;
padding: 0.35rem 0.65rem;
}
.query-list-facets {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 1rem 1.6rem;
margin: 0 0 1rem;
}
.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;
padding: 0;
list-style: none;
}
.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;
color: #39445a;
display: inline-flex;
font-size: 0.82rem;
gap: 0.4rem;
line-height: 1.1;
padding: 0.35rem 0.55rem;
text-decoration: none;
}
.query-list-facet-link:hover {
border-color: #7ca5c8;
color: #1f5d85;
}
.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;
margin: 0.25rem 0 1rem;
min-width: 42rem;
width: 100%;
}
.query-list-results th,
.query-list-results td {
border-bottom: 1px solid #d7dde5;
padding: 0.45rem 0.7rem;
text-align: left;
vertical-align: top;
}
.query-list-results th {
background-color: #edf6fb;
border-top: 1px solid #d7dde5;
color: #39445a;
font-weight: 700;
}
.query-list-results tbody tr:nth-child(even) {
background-color: rgba(39, 104, 144, 0.05);
}
.query-list-results a.query-list-title {
font-weight: 700;
}
.query-list-description {
color: #4f5b6d;
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;
border-radius: 0.25rem;
color: #39445a;
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
padding: 0.25rem 0.4rem;
white-space: nowrap;
}
.query-list-pill-write {
background-color: #fff4db;
border-color: #e2b64e;
}
.query-list-pill-public {
background-color: #e7f5ec;
border-color: #9ecfab;
color: #267a3e;
}
.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;
display: inline-block;
padding: 0.45rem 0.7rem;
}
.query-list-pagination-bottom {
margin-top: 0.75rem;
}
@media (max-width: 700px) {
.query-list-search input[type=search] {
max-width: none;
}
}
</style>
{% endblock %}
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-list-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
<form class="query-list-filters core" action="{{ query_list_path }}" method="get">
<p class="query-list-search">
<label for="query-search">Search</label>
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
{% if filters.is_write %}<input type="hidden" name="is_write" value="{{ filters.is_write }}">{% endif %}
{% if filters.is_private %}<input type="hidden" name="is_private" value="{{ filters.is_private }}">{% endif %}
{% if filters.source %}<input type="hidden" name="source" value="{{ filters.source }}">{% endif %}
{% if filters.owner_id %}<input type="hidden" name="owner_id" value="{{ filters.owner_id }}">{% endif %}
<button type="submit">Search</button>
</p>
</form>
<nav class="query-list-facets" aria-label="Query filters">
{% for facet in facets %}
<section class="query-list-facet">
<h2>{{ facet.title }}</h2>
<ul>
{% for item in facet["items"] %}
<li>{% if item.href %}<a class="query-list-facet-link{% if item.active %} query-list-facet-link-active{% endif %}" href="{{ item.href }}"{% if item.active %} aria-current="true"{% endif %}>{% else %}<span class="query-list-facet-link query-list-facet-disabled">{% endif %}<span>{{ item.label }}</span><span class="query-list-facet-count">{{ item.count }}</span>{% if item.href %}</a>{% else %}</span>{% endif %}</li>
{% endfor %}
</ul>
</section>
{% endfor %}
</nav>
{% if queries %}
<div class="table-wrapper"><table class="query-list-results">
<thead>
<tr>
{% if show_database %}<th scope="col">Database</th>{% endif %}
<th scope="col">Query</th>
<th scope="col">Owner</th>
<th scope="col">Flags</th>
</tr>
</thead>
<tbody>
{% for query in queries %}
<tr>
{% if show_database %}
<td><a class="query-list-database" href="{{ urls.database(query.database) }}">{{ query.database }}</a></td>
{% endif %}
<td>
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
</td>
<td class="query-list-owner">{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}<span class="query-list-empty">-</span>{% endif %}</td>
<td>
<span class="query-list-flags">
{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}
{% if query.is_private %}<span class="query-list-pill query-list-pill-private">Private</span>{% endif %}
{% if query.is_trusted %}<span class="query-list-pill query-list-pill-trusted">Trusted</span>{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% if show_private_note or show_trusted_note %}
<div class="query-list-footnotes">
{% if show_private_note %}<p><span class="query-list-pill query-list-pill-private">Private</span>Only the owning actor can view this query.</p>{% endif %}
{% if show_trusted_note %}<p><span class="query-list-pill query-list-pill-trusted">Trusted</span>Execution skips the usual SQL and write permission checks after view-query allows access.</p>{% endif %}
</div>
{% endif %}
{% else %}
<p>No queries found.</p>
{% endif %}
{% if next_url %}
<nav class="query-list-pagination query-list-pagination-bottom" aria-label="Query pagination"><a href="{{ next_url }}">Next page</a></nav>
{% endif %}
</div>
{% endblock %}

View file

@ -241,6 +241,14 @@ async def _build_single_action_sql(
"),",
]
)
else:
query_parts.extend(
[
"anon_rules AS (",
" SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0",
"),",
]
)
# Continue with the cascading logic
query_parts.extend(

View file

@ -112,6 +112,28 @@ async def initialize_metadata_tables(db):
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
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);
"""))

View file

@ -0,0 +1,99 @@
from dataclasses import dataclass
from typing import Literal
from datasette.utils.sqlite import sqlite3
SQLTableOperation = Literal["read", "insert", "update", "delete"]
@dataclass(frozen=True)
class SQLTableAccess:
operation: SQLTableOperation
database: str | None
table: str
sqlite_schema: str | None
columns: tuple[str, ...] = ()
source: str | None = None
@dataclass(frozen=True)
class SQLAnalysis:
table_accesses: tuple[SQLTableAccess, ...]
_ACTION_TO_OPERATION: dict[int, SQLTableOperation] = {
sqlite3.SQLITE_READ: "read",
sqlite3.SQLITE_INSERT: "insert",
sqlite3.SQLITE_UPDATE: "update",
sqlite3.SQLITE_DELETE: "delete",
}
def analyze_sql_tables(
conn,
sql: str,
params=None,
*,
database_name: str | None = None,
schema_to_database: dict[str, str] | None = None,
) -> SQLAnalysis:
"""
Return tables accessed by a SQL statement according to SQLite's authorizer.
This function is synchronous and connection-based. It temporarily installs a
SQLite authorizer, prepares ``EXPLAIN <sql>``, and returns the table access
callbacks observed while SQLite compiles the statement.
"""
accesses: dict[
tuple[SQLTableOperation, str | None, str, str | None, str | None], set[str]
] = {}
def database_for_schema(sqlite_schema):
if schema_to_database and sqlite_schema in schema_to_database:
return schema_to_database[sqlite_schema]
if sqlite_schema == "main" and database_name is not None:
return database_name
return sqlite_schema
def authorizer(action, arg1, arg2, sqlite_schema, source):
operation = _ACTION_TO_OPERATION.get(action)
if operation is None or arg1 is None:
return sqlite3.SQLITE_OK
key = (
operation,
database_for_schema(sqlite_schema),
arg1,
sqlite_schema,
source,
)
columns = accesses.setdefault(key, set())
if operation in ("read", "update") and arg2 is not None:
columns.add(arg2)
return sqlite3.SQLITE_OK
conn.set_authorizer(authorizer)
try:
conn.execute("EXPLAIN " + sql, params if params is not None else {}).fetchall()
finally:
conn.set_authorizer(None)
return SQLAnalysis(
table_accesses=tuple(
SQLTableAccess(
operation=operation,
database=database,
table=table,
sqlite_schema=sqlite_schema,
columns=tuple(sorted(columns)),
source=source,
)
for (
operation,
database,
table,
sqlite_schema,
source,
), columns in accesses.items()
)
)

View file

@ -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,
@ -35,6 +36,7 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm
from .base import BaseView, DatasetteError, View, _error, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from . import Context
@ -92,24 +94,19 @@ class DatabaseView(View):
tables = await get_tables(datasette, request, db, allowed_dict)
# Get allowed queries using the new permission system
allowed_query_page = await datasette.allowed_resources(
"view-query",
request.actor,
parent=database,
include_is_private=True,
limit=1000,
queries_page = await datasette.list_queries(
database,
actor=request.actor,
limit=5,
include_private=True,
)
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(stored_queries)
)
# Build canned_queries list by looking up each allowed query
all_queries = await datasette.get_canned_queries(database, request.actor)
canned_queries = []
for query_resource in allowed_query_page.resources:
query_name = query_resource.child
if query_name in all_queries:
canned_queries.append(
dict(all_queries[query_name], private=query_resource.private)
)
async def database_actions():
links = []
@ -140,7 +137,9 @@ class DatabaseView(View):
"tables": tables,
"hidden_count": len([t for t in tables if t["hidden"]]),
"views": sql_views,
"queries": canned_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,
"table_columns": (
await _table_columns(datasette, database) if allow_execute_sql else {}
@ -173,7 +172,9 @@ 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,
table_columns=(
await _table_columns(datasette, database)
@ -221,7 +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 stored queries are available"}
)
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"}
)
@ -266,8 +271,8 @@ class QueryContext(Context):
query: dict = field(
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"}
stored_query: str = field(
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"}
@ -275,13 +280,13 @@ 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 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"}
@ -302,12 +307,15 @@ class QueryContext(Context):
allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"}
)
save_query_url: str = field(
metadata={"help": "URL to save the current arbitrary SQL as a query"}
)
tables: list = field(metadata={"help": "List of table objects in the database"})
named_parameter_values: dict = field(
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"})
@ -331,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={
@ -423,21 +431,32 @@ 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)
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
raise DatasetteError("Unexpected table found on POST", status=404)
if not await datasette.allowed(
action="view-query",
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, stored_query, request.actor
)
# If database is immutable, return an error
if not db.is_mutable:
raise Forbidden("Database is immutable")
@ -462,20 +481,18 @@ class QueryView(View):
or request.args.get("_json")
or params.get("_json")
)
params_for_query = MagicParameters(
canned_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(
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.on_success_message_sql
if on_success_message_sql:
try:
message_result = (
@ -487,18 +504,19 @@ class QueryView(View):
message = "Error running on_success_message_sql: {}".format(ex)
message_type = datasette.ERROR
if not message:
message = canned_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 = canned_query.get("on_success_redirect")
redirect_url = stored_query.on_success_redirect
ok = True
except Exception as ex:
message = canned_query.get("on_error_message") or str(ex)
message = stored_query.on_error_message or str(ex)
message_type = datasette.ERROR
redirect_url = canned_query.get("on_error_redirect")
redirect_url = stored_query.on_error_redirect
ok = False
if should_return_json:
return Response.json(
@ -531,31 +549,35 @@ 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?
canned_query = None
canned_query_write = False
# Are we a stored query?
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 canned query?
canned_query = await datasette.get_canned_query(
table_not_found.database_name, table_not_found.table, request.actor
# Was this actually a stored query?
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 = stored_query.is_write
private = False
if canned_query:
# Respect canned query permissions
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 stored_query_write:
await _ensure_stored_query_execution_permissions(
datasette, db, stored_query, request.actor
)
else:
await datasette.ensure_permission(
@ -568,15 +590,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.parameters:
named_parameters = stored_query.parameters
if not named_parameters and sql:
named_parameters = derive_named_parameters(sql)
named_parameter_values = {
@ -602,13 +624,13 @@ 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:
# 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(
@ -664,7 +686,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,
@ -696,10 +718,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)
@ -717,6 +739,9 @@ class QueryView(View):
}
)
metadata = await datasette.get_database_metadata(database)
if stored_query:
metadata = stored_query_to_dict(stored_query)
metadata.pop("source", None)
renderers = {}
for key, (_, can_render) in datasette.renderers.items():
@ -743,9 +768,14 @@ class QueryView(View):
resource=DatabaseResource(database=database),
actor=request.actor,
)
allow_store_query = await datasette.allowed(
action="store-query",
resource=DatabaseResource(database=database),
actor=request.actor,
)
show_hide_hidden = ""
if canned_query and canned_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"
@ -793,6 +823,19 @@ class QueryView(View):
}
)
)
save_query_url = None
if (
not stored_query
and allow_execute_sql
and allow_store_query
and is_validated_sql
and ":_" not in sql
):
save_query_url = (
datasette.urls.database(database)
+ "/-/queries/store?"
+ urlencode({"sql": sql})
)
async def query_actions():
query_actions = []
@ -800,7 +843,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,
@ -820,16 +863,17 @@ 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),
named_parameter_values=named_parameter_values,
edit_sql_url=edit_sql_url,
@ -849,7 +893,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}"
@ -858,12 +902,12 @@ 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,
query_name=canned_query["name"] if canned_query else None,
query_name=stored_query.name if stored_query else None,
),
query_actions=query_actions,
),
@ -1176,22 +1220,6 @@ class TableCreateView(BaseView):
return Response.json(details, status=201)
async def _table_columns(datasette, database_name):
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(row["name"])
# Add views
db = datasette.get_database(database_name)
for view_name in await db.view_names():
table_columns[view_name] = []
return table_columns
async def display_rows(datasette, database, request, rows, columns):
display_rows = []
truncate_cells = datasette.setting("truncate_cells_html")

View file

@ -0,0 +1,257 @@
from urllib.parse import urlencode
from datasette.resources import DatabaseResource
from datasette.utils import sqlite3
from datasette.utils.asgi import Response
from .base import BaseView, _error
from .query_helpers import (
QueryValidationError,
_analysis_is_write,
_analysis_rows,
_analysis_rows_with_permissions,
_block_framing,
_coerce_execute_write_payload,
_derived_query_parameters,
_execute_write_analysis_data,
_inserted_row_url,
_json_or_form_payload,
_prepare_execute_write,
_table_columns,
_wants_json,
)
class ExecuteWriteView(BaseView):
name = "execute-write"
has_json_alternate = False
async def _render_form(
self,
request,
db,
*,
sql="",
parameter_values=None,
analysis=None,
analysis_error=None,
execution_message=None,
execution_links=None,
execution_ok=None,
status=200,
):
parameter_values = parameter_values or {}
execution_links = execution_links or []
parameter_names = []
analysis_rows = []
table_columns = await _table_columns(self.ds, db.name)
hidden_table_names = set(await db.hidden_table_names())
write_template_tables = {
table: columns
for table, columns in table_columns.items()
if columns and table not in hidden_table_names
}
if sql and analysis_error is None:
try:
parameter_names = _derived_query_parameters(sql)
if analysis is None:
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
self.ds, analysis, request.actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
allow_save_query = await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
) and await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
)
save_query_base_url = None
save_query_url = None
if allow_save_query:
save_query_base_url = self.ds.urls.database(db.name) + "/-/queries/store"
if (
sql
and analysis_error is None
and not any(row["allowed"] is False for row in analysis_rows)
):
save_query_url = save_query_base_url + "?" + urlencode({"sql": sql})
response = await self.render(
["execute_write.html"],
request,
{
"database": db.name,
"database_color": db.color,
"sql": sql,
"parameter_names": parameter_names,
"parameter_values": parameter_values,
"analysis_error": analysis_error,
"analysis_rows": [
row for row in analysis_rows if row["operation"] != "read"
],
"execution_message": execution_message,
"execution_links": execution_links,
"execution_ok": execution_ok,
"execute_disabled": bool(
(not sql)
or analysis_error
or any(row["allowed"] is False for row in analysis_rows)
),
"table_columns": table_columns,
"write_template_tables": write_template_tables,
"save_query_url": save_query_url,
"save_query_base_url": save_query_base_url,
},
)
response.status = status
return _block_framing(response)
async def get(self, request):
db = await self.ds.resolve_database(request)
await self.ds.ensure_permission(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
)
if not db.is_mutable:
return _block_framing(
_error(
["Cannot execute write SQL because this database is immutable."],
403,
)
)
return await self._render_form(
request,
db,
sql=request.args.get("sql") or "",
)
async def post(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 403)
)
if not db.is_mutable:
return _block_framing(_error(["Database is immutable"], 403))
data = {}
is_json = request.headers.get("content-type", "").startswith("application/json")
sql = ""
provided_params = {}
try:
data, is_json = await _json_or_form_payload(request)
sql, provided_params = _coerce_execute_write_payload(data, is_json)
parameter_names, params, analysis = await _prepare_execute_write(
self.ds, db, sql, provided_params, request.actor
)
except QueryValidationError as ex:
if _wants_json(request, is_json, data):
return _block_framing(_error([ex.message], ex.status))
return await self._render_form(
request,
db,
sql=sql or "",
parameter_values=provided_params,
analysis_error=ex.message,
execution_message=ex.message,
execution_ok=False,
status=ex.status,
)
try:
cursor = await db.execute_write(sql, params, request=request)
except sqlite3.DatabaseError as ex:
message = str(ex)
if _wants_json(request, is_json, data):
return _block_framing(_error([message], 400))
return await self._render_form(
request,
db,
sql=sql,
parameter_values=params,
analysis=analysis,
execution_message=message,
execution_ok=False,
status=400,
)
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
if _wants_json(request, is_json, data):
return _block_framing(
Response.json(
{
"ok": True,
"message": message,
"rowcount": cursor.rowcount,
"analysis": _analysis_rows(analysis),
}
)
)
inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor)
execution_links = (
[{"href": inserted_row_url, "label": "View row"}]
if inserted_row_url
else []
)
return await self._render_form(
request,
db,
sql=sql,
parameter_values={name: params.get(name, "") for name in parameter_names},
analysis=analysis,
execution_message=message,
execution_links=execution_links,
execution_ok=True,
)
class ExecuteWriteAnalyzeView(BaseView):
name = "execute-write-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-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 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 _execute_write_analysis_data(self.ds, db, sql, request.actor)
)
)

View file

@ -0,0 +1,556 @@
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,
path_from_row_pks,
sqlite3,
validate_sql_select,
InvalidSql,
)
from datasette.utils.asgi import Forbidden
_query_name_re = re.compile(r"^[^/\.\n]+$")
_query_fields = {
"sql",
"title",
"description",
"hide_sql",
"fragment",
"parameters",
"params",
"is_private",
"on_success_message",
"on_success_redirect",
"on_error_message",
"on_error_redirect",
}
_query_create_fields = _query_fields | {"name", "mode", "csrftoken"}
_query_update_fields = _query_fields
_query_write_fields = {
"on_success_message",
"on_success_redirect",
"on_error_message",
"on_error_redirect",
}
class QueryValidationError(Exception):
def __init__(self, message, status=400):
self.message = message
self.status = status
def _actor_id(actor):
if isinstance(actor, dict):
return actor.get("id")
return None
def _as_bool(value):
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, int):
return bool(value)
if isinstance(value, str):
return value.lower() in {"1", "true", "t", "yes", "on"}
return bool(value)
def _as_optional_bool(value, name):
if value is None or value == "":
return None
if isinstance(value, bool):
return value
if isinstance(value, int):
return bool(value)
if isinstance(value, str):
lowered = value.lower()
if lowered in {"1", "true", "t", "yes", "on"}:
return True
if lowered in {"0", "false", "f", "no", "off"}:
return False
raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default=50):
if value in (None, ""):
return default
try:
return min(max(1, int(value)), 1000)
except ValueError as ex:
raise QueryValidationError("_size must be an integer") from ex
def _derived_query_parameters(sql):
parameters = []
seen = set()
for parameter in derive_named_parameters(sql):
if parameter.startswith("_"):
raise QueryValidationError("Magic parameters are not allowed")
if parameter not in seen:
parameters.append(parameter)
seen.add(parameter)
return parameters
def _coerce_query_parameters(value, derived):
if value is None:
return derived
if isinstance(value, str):
parameters = [
parameter.strip()
for parameter in re.split(r"[\s,]+", value)
if parameter.strip()
]
elif isinstance(value, list):
parameters = value
else:
raise QueryValidationError("parameters must be a list of strings")
if not all(isinstance(parameter, str) for parameter in parameters):
raise QueryValidationError("parameters must be a list of strings")
if any(parameter.startswith("_") for parameter in parameters):
raise QueryValidationError("Magic parameters are not allowed")
if set(parameters) != set(derived):
raise QueryValidationError("parameters must match SQL named parameters")
return parameters
def _analysis_is_write(analysis):
return any(
access.operation in {"insert", "update", "delete"}
for access in analysis.table_accesses
)
def _block_framing(response):
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
response.headers["X-Frame-Options"] = "DENY"
return response
def _wants_json(request, is_json, data):
return (
is_json
or request.headers.get("accept") == "application/json"
or (isinstance(data, dict) and data.get("_json"))
)
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"):
body = await request.post_body()
try:
return json.loads(body or b"{}"), True
except json.JSONDecodeError as e:
raise QueryValidationError("Invalid JSON: {}".format(e))
return await request.post_vars(), False
async def _check_query_name(db, name, *, existing=False):
if not name or not isinstance(name, str):
raise QueryValidationError("Query name is required")
if not _query_name_re.match(name):
raise QueryValidationError("Invalid query name")
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
raise QueryValidationError("Query name conflicts with a table or view")
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)
params = {parameter: "" for parameter in derived}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
is_write = _analysis_is_write(analysis)
if is_write:
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
else:
try:
validate_sql_select(sql)
except InvalidSql as ex:
raise QueryValidationError(str(ex)) from ex
return is_write, derived, analysis
def _analysis_rows(analysis):
write_actions = {
"insert": "insert-row",
"update": "update-row",
"delete": "delete-row",
}
return [
{
"operation": access.operation,
"database": access.database,
"table": access.table,
"required_permission": write_actions.get(access.operation, ""),
"source": access.source,
}
for access in analysis.table_accesses
]
async def _analysis_rows_with_permissions(datasette, analysis, actor):
rows = _analysis_rows(analysis)
for row in rows:
permission = row["required_permission"]
if permission:
row["allowed"] = await datasette.allowed(
action=permission,
resource=TableResource(row["database"], row["table"]),
actor=actor,
)
else:
row["allowed"] = None
return rows
def _coerce_execute_write_payload(data, is_json):
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
if is_json:
invalid_keys = set(data) - {"sql", "params"}
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
params = data.get("params") or {}
else:
params = {
key: value
for key, value in data.items()
if key not in {"sql", "csrftoken", "_json"}
}
if not isinstance(params, dict):
raise QueryValidationError("params must be a dictionary")
return data.get("sql"), params
async def _prepare_execute_write(datasette, db, sql, params, actor):
if not sql or not isinstance(sql, str):
raise QueryValidationError("SQL is required")
parameter_names = _derived_query_parameters(sql)
extra_params = set(params) - set(parameter_names)
if extra_params:
raise QueryValidationError(
"Unknown parameters: {}".format(", ".join(sorted(extra_params)))
)
params = {name: params.get(name, "") for name in parameter_names}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
if not _analysis_is_write(analysis):
raise QueryValidationError(
"Use /-/query for read-only SQL; this endpoint only executes writes"
)
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
return parameter_names, params, analysis
async def _ensure_stored_query_execution_permissions(
datasette, db, query: StoredQuery, actor
):
if query.is_trusted:
return
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)
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 = []
analysis_error = None
if sql:
try:
parameter_names = _derived_query_parameters(sql)
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
datasette, analysis, actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
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": [row for row in analysis_rows if row["operation"] != "read"],
"execute_disabled": bool(
(not sql)
or analysis_error
or any(row["allowed"] is False for row in analysis_rows)
),
}
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
lastrowid = getattr(cursor, "lastrowid", None)
if lastrowid is None:
return None
direct_inserts = [
access
for access in analysis.table_accesses
if access.operation == "insert"
and access.source is None
and access.database == db.name
]
if len(direct_inserts) != 1:
return None
table = direct_inserts[0].table
pks = await db.primary_keys(table)
use_rowid = not pks
select = (
"rowid"
if use_rowid
else ", ".join(escape_sqlite(primary_key) for primary_key in pks)
)
try:
result = await db.execute(
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
[lastrowid],
)
except sqlite3.DatabaseError:
return None
row = result.first()
if row is None:
return None
row_path = path_from_row_pks(row, pks, use_rowid)
return datasette.urls.row(db.name, table, row_path)
def _apply_query_data_types(data):
typed = dict(data)
for key in ("hide_sql", "is_private"):
if key in typed:
typed[key] = _as_bool(typed[key])
return typed
async def _prepare_query_create(datasette, request, db, data):
invalid_keys = set(data) - _query_create_fields
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
data = _apply_query_data_types(data)
name = data.get("name")
await _check_query_name(db, name)
if await datasette.get_query(db.name, name) is not None:
raise QueryValidationError("Query already exists")
is_write, derived, analysis = await _analyze_user_query(
datasette,
db,
data.get("sql"),
actor=request.actor,
)
if not is_write and any(data.get(field) for field in _query_write_fields):
raise QueryValidationError("Writable query fields require writable SQL")
parameters = _coerce_query_parameters(
data.get("parameters", data.get("params")),
derived,
)
return {
"name": name,
"sql": data["sql"],
"title": data.get("title"),
"description": data.get("description"),
"hide_sql": _as_bool(data.get("hide_sql")),
"fragment": data.get("fragment"),
"parameters": parameters,
"is_write": is_write,
"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"),
"on_success_redirect": data.get("on_success_redirect"),
"on_error_message": data.get("on_error_message"),
"on_error_redirect": data.get("on_error_redirect"),
"analysis": analysis,
}
async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update):
invalid_keys = set(update) - _query_update_fields
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
update = _apply_query_data_types(update)
sql = update.get("sql", existing.sql)
query_is_write = existing.is_write
derived = _derived_query_parameters(sql)
parameters = None
if "sql" in update:
query_is_write, derived, _ = await _analyze_user_query(
datasette,
db,
sql,
actor=request.actor,
)
if "parameters" in update or "params" in update:
parameters = _coerce_query_parameters(
update.get("parameters", update.get("params")),
derived,
)
elif "sql" in update:
parameters = derived
if not query_is_write and any(update.get(field) for field in _query_write_fields):
raise QueryValidationError("Writable query fields require writable SQL")
field_values = {
"sql": sql,
"title": update.get("title"),
"description": update.get("description"),
"hide_sql": update.get("hide_sql"),
"fragment": update.get("fragment"),
"parameters": parameters,
"is_write": query_is_write,
"is_private": update.get("is_private"),
"on_success_message": update.get("on_success_message"),
"on_success_redirect": update.get("on_success_redirect"),
"on_error_message": update.get("on_error_message"),
"on_error_redirect": update.get("on_error_redirect"),
}
update_kwargs = {}
for field_name, value in field_values.items():
if field_name in update:
update_kwargs[field_name] = value
if parameters is not None:
update_kwargs["parameters"] = parameters
if "sql" in update:
update_kwargs["is_write"] = query_is_write
return update_kwargs
async def _table_columns(datasette, database_name):
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(row["name"])
# Add views
db = datasette.get_database(database_name)
for view_name in await db.view_names():
table_columns[view_name] = []
return table_columns

View file

@ -0,0 +1,483 @@
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
from .base import BaseView, _error
from .query_helpers import (
QueryValidationError,
_as_bool,
_as_optional_bool,
_block_framing,
_derived_query_parameters,
_json_or_form_payload,
_prepare_query_create,
_prepare_query_update,
_query_create_analysis_data,
_query_create_form_context,
_query_create_form_error_message,
_query_list_limit,
)
class QueryParametersView(BaseView):
name = "query-parameters"
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))
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
try:
parameters = _derived_query_parameters(request.args.get("sql") or "")
except QueryValidationError as ex:
return _block_framing(_error([ex.message], ex.status))
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"
async def database_name(self, request):
return (await self.ds.resolve_database(request)).name
def query_list_path(self, database):
return self.ds.urls.database(database) + "/-/queries"
async def get(self, request):
database = await self.database_name(request)
format_ = request.url_vars.get("format") or "html"
try:
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_private = _as_optional_bool(request.args.get("is_private"), "is_private")
except QueryValidationError as ex:
return _error([ex.message], ex.status)
page = await self.ds.list_queries(
database,
actor=request.actor,
limit=limit,
cursor=request.args.get("_next"),
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,
include_private=True,
)
query_list_path = self.query_list_path(database)
next_url = None
if page.next:
pairs = [
(key, value)
for key, value in parse_qsl(
request.query_string, keep_blank_values=True
)
if key != "_next"
]
pairs.append(("_next", page.next))
next_url = "{}?{}".format(
query_list_path,
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,
"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,
"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,
"filters": {
"q": request.args.get("q") or "",
"is_write": request.args.get("is_write") or "",
"is_private": request.args.get("is_private") or "",
"source": request.args.get("source") or "",
"owner_id": request.args.get("owner_id") or "",
},
}
if format_ == "json":
return Response.json(
{
**data,
"queries": [stored_query_to_dict(query) for query in page.queries],
}
)
return await self.render(
["query_list.html"],
request,
data,
)
class GlobalQueryListView(QueryListView):
name = "global-query-list"
async def database_name(self, request):
return None
def query_list_path(self, database):
return self.ds.urls.path("/-/queries")
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(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
)
await self.ds.ensure_permission(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
)
return await self._render_form(request, db, sql=request.args.get("sql") or "")
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="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need store-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 QueryStoreView(QueryCreateView):
name = "query-store"
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(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _error(["Permission denied: need execute-sql"], 403)
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _error(["Permission denied: need store-query"], 403)
is_json = False
query_data = {}
try:
data, is_json = await _json_or_form_payload(request)
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
query_data = data.get("query") if is_json else data
if not isinstance(query_data, dict):
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")
name = prepared.pop("name")
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)
assert query is not None
if is_json:
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)))
class QueryDefinitionView(BaseView):
name = "query-definition"
async def get(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name)
if query is None:
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="view-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
class QueryUpdateView(BaseView):
name = "query-update"
async def post(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return _error(["Trusted queries cannot be updated using the API"], 403)
try:
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
invalid_keys = set(data) - {"update", "return"}
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(invalid_keys))
)
update = data.get("update")
if not isinstance(update, dict):
raise QueryValidationError("JSON must contain an update dictionary")
if "sql" in update and not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
raise QueryValidationError(
"Permission denied: need execute-sql", status=403
)
update_kwargs = await _prepare_query_update(
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
return _error([ex.message], ex.status)
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": stored_query_to_dict(query),
}
)
return Response.json({"ok": True})
class QueryDeleteView(BaseView):
name = "query-delete"
async def post(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need delete-query"], 403)
await self.ds.remove_query(db.name, query_name)
return Response.json({"ok": True})

View file

@ -963,12 +963,12 @@ 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?
canned_query = await datasette.get_canned_query(
not_found.database_name, not_found.table, request.actor
# Was this actually a stored query?
stored_query = await datasette.get_query(
not_found.database_name, not_found.table
)
# If this is a canned query, not a table, then dispatch to QueryView instead
if canned_query:
# If this is a stored query, not a table, then dispatch to QueryView instead
if stored_query:
return await QueryView()(request, datasette)
else:
raise

View file

@ -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:`canned_queries`
* Access to specific :ref:`queries <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 <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<authentication_root>`:
To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
.. [[[cog
config_example(cog, """
@ -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 <canned_queries>` - within a specific database::
Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <queries>` - within a specific database::
datasette create-token root --resource mydatabase mytable insert-row
@ -1285,12 +1285,46 @@ 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 :ref:`canned query <canned_queries>` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
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 <trusted_stored_queries>` can execute with ``view-query`` alone.
``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string)
``query`` is the name of the canned query (string)
``query`` is the name of the query (string)
.. _actions_store_query:
store-query
-----------
Actor is allowed to create stored queries against a database.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_update_query:
update-query
------------
Actor is allowed to update a stored query.
``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string)
``query`` is the name of the query (string)
.. _actions_delete_query:
delete-query
------------
Actor is allowed to delete a stored query.
``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string)
``query`` is the name of the query (string)
.. _actions_insert_row:
@ -1379,13 +1413,23 @@ Actor is allowed to drop a database table.
execute-sql
-----------
Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
Actor is allowed to run arbitrary read-only SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`.
.. _actions_execute_write_sql:
execute-write-sql
-----------------
Actor is allowed to run arbitrary writable SQL queries against a specific database, subject to table-level write permissions such as ``insert-row``, ``update-row`` and ``delete-row``.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_permissions_debug:
permissions-debug

View file

@ -9,6 +9,31 @@ Changelog
Unreleased
----------
Stored queries
~~~~~~~~~~~~~~
- The previous "canned queries" feature has been renamed and expanded into :ref:`stored queries <stored_queries>`. Queries configured in ``datasette.yaml`` are now loaded into a new ``queries`` table in Datasette's :ref:`internal database <internals_internal_schema>`, alongside user-created stored queries. (:issue:`2735`)
- New stored query management APIs: ``datasette.add_query()``, ``datasette.update_query()``, ``datasette.remove_query()``, ``datasette.get_query()``, ``datasette.list_queries()`` and ``datasette.count_queries()``. These replace the removed ``datasette.get_canned_query()`` and ``datasette.get_canned_queries()`` methods. (:issue:`2735`)
- Users with :ref:`store-query <actions_store_query>` and :ref:`execute-sql <actions_execute_sql>` permission can create stored queries from the SQL query page or the new ``GET /<database>/-/queries/store`` form. (:issue:`2735`)
- The database page now shows a count and preview of stored queries, capped at five, and links to new paginated query browsers at ``/-/queries`` and ``/<database>/-/queries``. Those browsers support search. (:issue:`2735`)
- Stored queries created by users default to private and untrusted. Private stored queries can only be viewed, updated or deleted by their owner, even if another actor has broad ``view-query``, ``update-query`` or ``delete-query`` permission. Untrusted stored queries execute using the permissions of the actor running them. See :ref:`stored_queries` and :ref:`trusted_stored_queries` for details. (:issue:`2735`)
- New ``store-query``, ``update-query`` and ``delete-query`` permissions, plus updated semantics for :ref:`view-query <actions_view_query>`. Trusted stored queries can still execute with ``view-query`` alone; untrusted read queries also require :ref:`execute-sql <actions_execute_sql>` and untrusted writable queries require :ref:`execute-write-sql <actions_execute_write_sql>` plus the relevant table-level write permissions. (:issue:`2735`)
Write SQL UI
~~~~~~~~~~~~
- New "Write to this database" interface at ``/<database>/-/execute-write`` for running arbitrary writable SQL against mutable databases. The form extracts named parameters, analyzes the SQL, shows the table operations that will be attempted and links to a newly inserted row when a single-row insert succeeds. (:issue:`2742`)
- Added the new :ref:`execute-write-sql <actions_execute_write_sql>` permission for running arbitrary writable SQL. Execution is also gated by table-level permissions such as :ref:`insert-row <actions_insert_row>`, :ref:`update-row <actions_update_row>` and :ref:`delete-row <actions_delete_row>`, and writes to attached databases are rejected. (:issue:`2742`)
Plugin API changes
~~~~~~~~~~~~~~~~~~
- The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() <plugin_hook_top_stored_query>`. (:issue:`2747`)
- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new :ref:`stored query management methods <datasette_stored_queries>` together with :ref:`startup() <plugin_hook_startup>` to register queries. (:issue:`2735`)
Bug fixes
~~~~~~~~~
- Fixed a bug where visiting ``/<database>/-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`)
.. _v1_0_a30:
@ -656,7 +681,7 @@ For more information and workarounds, read `the security advisory <https://githu
Also in this alpha:
- The new ``datasette plugins --requirements`` option outputs a list of currently installed plugins in Python ``requirements.txt`` format, useful for duplicating that installation elsewhere. (:issue:`2133`)
- :ref:`canned_queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
- :ref:`queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`)
- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
@ -1050,7 +1075,7 @@ Other small fixes
- The ``base.html`` template now wraps everything other than the ``<footer>`` in a ``<div class="not-footer">`` element, to help with advanced CSS customization. (:issue:`1446`)
- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook can now return an awaitable function. This means the hook can execute SQL queries. (:issue:`1425`)
- :ref:`plugin_register_routes` plugin hook now accepts an optional ``datasette`` argument. (:issue:`1404`)
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`canned_queries_options`. (:issue:`1422`)
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`queries_options`. (:issue:`1422`)
- New ``--cpu`` option for :ref:`datasette publish cloudrun <publish_cloud_run>`. (:issue:`1420`)
- If `Rich <https://github.com/willmcgugan/rich>`__ is installed in the same virtual environment as Datasette, it will be used to provide enhanced display of error tracebacks on the console. (:issue:`1416`)
- ``datasette.utils`` :ref:`internals_utils_parse_metadata` function, used by the new `datasette-remote-metadata plugin <https://datasette.io/plugins/datasette-remote-metadata>`__, is now a documented API. (:issue:`1405`)
@ -1424,7 +1449,7 @@ See also `Datasette 0.50: The annotated release notes <https://simonwillison.net
See also `Datasette 0.49: The annotated release notes <https://simonwillison.net/2020/Sep/15/datasette-0-49/>`__.
- Writable canned queries now expose a JSON API, see :ref:`canned_queries_json_api`. (:issue:`880`)
- Writable canned queries now expose a JSON API, see :ref:`queries_json_api`. (:issue:`880`)
- New mechanism for defining page templates with custom path parameters - a template file called ``pages/about/{slug}.html`` will be used to render any requests to ``/about/something``. See :ref:`custom_pages_parameters`. (:issue:`944`)
- ``register_output_renderer()`` render functions can now return a ``Response``. (:issue:`953`)
- New ``--upgrade`` option for ``datasette install``. (:issue:`945`)
@ -1516,7 +1541,7 @@ Magic parameters for canned queries, a log out feature, improved plugin document
Magic parameters for canned queries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Canned queries now support :ref:`canned_queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
Canned queries now support :ref:`queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
insert into logs
(user_id, timestamp)
@ -1547,7 +1572,7 @@ New plugin hooks
- :ref:`plugin_hook_register_magic_parameters` can be used to define new types of magic canned query parameters.
- :ref:`plugin_hook_startup` can run custom code when Datasette first starts up. `datasette-init <https://github.com/simonw/datasette-init>`__ is a new plugin that uses this hook to create database tables and views on startup if they have not yet been created. (:issue:`834`)
- :ref:`plugin_hook_canned_queries` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
- ``canned_queries()`` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
- :ref:`plugin_hook_forbidden` is a hook for customizing how Datasette responds to 403 forbidden errors. (:issue:`812`)
Smaller changes
@ -1622,7 +1647,7 @@ A new debug page at ``/-/permissions`` shows recent permission checks, to help a
Writable canned queries
~~~~~~~~~~~~~~~~~~~~~~~
Datasette's :ref:`canned_queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
Datasette's :ref:`queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 introduces the ability for canned queries to execute ``INSERT`` or ``UPDATE`` queries as well, using the new ``"write": true`` property (:issue:`800`):
@ -1641,7 +1666,7 @@ Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 intr
}
}
See :ref:`canned_queries_writable` for more details.
See :ref:`queries_writable` for more details.
Flash messages
~~~~~~~~~~~~~~
@ -1696,7 +1721,7 @@ Smaller changes
- New ``request.cookies`` property.
- ``/-/plugins`` endpoint now shows a list of hooks implemented by each plugin, e.g. https://latest.datasette.io/-/plugins?all=1
- ``request.post_vars()`` method no longer discards empty values.
- New "params" canned query key for explicitly setting named parameters, see :ref:`canned_queries_named_parameters`. (:issue:`797`)
- New "params" canned query key for explicitly setting named parameters, see :ref:`queries_named_parameters`. (:issue:`797`)
- ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object.
- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)
- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)
@ -1730,7 +1755,7 @@ The main focus of this release is a major upgrade to the :ref:`plugin_register_o
* Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`)
* The :ref:`internals_request`, which is passed to several plugin hooks, is now documented. (:issue:`706`)
* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`table_configuration_size`. (:issue:`751`)
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`canned_queries_options`. (:issue:`706`)
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`queries_options`. (:issue:`706`)
* Fixed a bug in ``datasette publish`` when running on operating systems where the ``/tmp`` directory lives in a different volume, using a backport of the Python 3.8 ``shutil.copytree()`` function. (:issue:`744`)
* Every plugin hook is now covered by the unit tests, and a new unit test checks that each plugin hook has at least one corresponding test. (:issue:`771`, :issue:`773`)
@ -2247,7 +2272,7 @@ A number of small new features:
- Documentation for :ref:`datasette publish and datasette package <publishing>`, closes `#337 <https://github.com/simonw/datasette/issues/337>`_
- Fixed compatibility with Python 3.7
- ``datasette publish heroku`` now supports app names via the ``-n`` option, which can also be used to overwrite an existing application [Russ Garrett]
- Title and description metadata can now be set for :ref:`canned SQL queries <canned_queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
- Title and description metadata can now be set for :ref:`canned SQL queries <queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
- New ``force_https_on`` config option, fixes ``https://`` API URLs when deploying to Zeit Now - closes `#333 <https://github.com/simonw/datasette/issues/333>`_
- ``?_json_infinity=1`` query string argument for handling Infinity/-Infinity values in JSON, closes `#332 <https://github.com/simonw/datasette/issues/332>`_
- URLs displayed in the results of custom SQL queries are now URLified, closes `#298 <https://github.com/simonw/datasette/issues/298>`_

View file

@ -87,6 +87,7 @@ This is equivalent to a ``datasette.yaml`` file containing the following:
}
.. [[[end]]]
.. _configuration_reference:
``datasette.yaml`` reference
@ -433,12 +434,12 @@ Here is a simple example:
:ref:`authentication_permissions_config` has the full details.
.. _configuration_reference_canned_queries:
.. _configuration_reference_queries:
Canned queries configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Queries configuration
~~~~~~~~~~~~~~~~~~~~~
:ref:`Canned queries <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 <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 <canned_queries>` for more, including how to configure :ref:`writable canned queries <canned_queries_writable>`.
See the :ref:`queries documentation <queries>` for more, including how to configure :ref:`writable queries <queries_writable>`.
.. _configuration_reference_css_js:
@ -1211,4 +1212,3 @@ For column types that accept additional configuration, use an object with ``type
}
}
.. [[[end]]]

View file

@ -29,7 +29,7 @@ The custom SQL template (``/dbname?sql=...``) gets this:
<body class="query db-dbname">
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::
<!-- Templates considered: *query-mydb-tz.html, query-mydb.html, query.html -->
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

View file

@ -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 <canned_queries>`) within a database
- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`stored query <stored_queries>`) 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 <internals_internal>`.
.. _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
@ -927,6 +927,200 @@ Adds a new metadata entry for the specified column.
Any previous column-level metadata entry with the same ``key`` will be overwritten.
Internally upserts the value into the the ``metadata_columns`` table inside the :ref:`internal database <internals_internal>`.
.. _datasette_stored_queries:
Stored queries
--------------
:ref:`Stored queries <stored_queries>` are stored in the ``queries`` table in the :ref:`internal database <internals_internal>`. Plugins can use the following methods to add, update, list and remove stored queries.
.. _datasette_add_query:
await .add_query(database, name, sql, ...)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Adds a stored query.
.. code-block:: python
async def add_query(
self,
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,
): ...
``database`` - string
The name of the database this query should belong to.
``name`` - string
The name of the stored query, used in the URL for that query.
``sql`` - string
The SQL for the stored query.
``title`` - string, optional
A display title for the query.
``description`` - string, optional
A plain text description.
``description_html`` - string, optional
An HTML description.
``hide_sql`` - boolean, optional
Set to ``True`` to hide the SQL by default on the query page.
``fragment`` - string, optional
A URL fragment to append to query links, for example ``"chart"``.
``parameters`` - list of strings, optional
Explicit parameter names for the query form. If omitted, Datasette derives parameters from the SQL.
``is_write`` - boolean, optional
Set to ``True`` for writable queries. They will the run against the SQLite write connection for the database.
``is_private`` - boolean, optional
Set to ``True`` for private queries. Private queries can only be viewed, updated or deleted by their owner.
``is_trusted`` - boolean, optional
Set to ``True`` for :ref:`trusted stored queries <trusted_stored_queries>`.
``source`` - string, optional
Identifies where the query came from. Defaults to ``"plugin"``.
``owner_id`` - string, optional
Actor ID of the query owner, used by private query permissions.
``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message``, ``on_error_redirect`` - strings, optional
Options for :ref:`writable queries <queries_writable>`.
``replace`` - boolean, optional
Defaults to ``True``, which replaces any existing stored query with the same ``database`` and ``name``. Set this to ``False`` to raise a SQLite integrity error if the query already exists.
Example:
.. code-block:: python
await datasette.add_query(
database="fixtures",
name="recent_rows",
sql="select * from facetable order by created desc limit 10",
title="Recent rows",
source="my-plugin",
)
.. _datasette_update_query:
await .update_query(database, name, ...)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Updates fields for an existing stored query. Only keyword arguments that are provided will be changed.
The available keyword arguments are the same as those for :ref:`datasette_add_query`, except for ``replace``. Pass ``None`` to clear optional text fields and options such as ``on_success_redirect``. Passing ``hide_sql=False`` removes the ``hide_sql`` option.
Example:
.. code-block:: python
await datasette.update_query(
database="fixtures",
name="recent_rows",
title="Latest rows",
is_private=True,
owner_id="alice",
)
.. _datasette_get_query:
await .get_query(database, name)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Returns a ``StoredQuery`` dataclass instance, or ``None`` if the query does not exist.
``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`` is a list of explicit parameter names.
.. _datasette_list_queries:
await .list_queries(database=None, ...)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Lists stored queries visible to the specified actor.
.. code-block:: python
async def list_queries(
self,
database=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,
): ...
``database`` - string, optional
Restrict results to a specific database. Omit this to list queries across all databases.
``actor`` - dictionary, optional
The authenticated actor. Results are filtered using that actor's ``view-query`` permission.
``limit`` - integer, optional
Number of queries to return. Values are clamped to the range 1-1000.
``cursor`` - string, optional
Pagination cursor from the previous page's ``next`` value.
``q`` - string, optional
Search string matched against query name, title, description and SQL.
``is_write``, ``is_private``, ``is_trusted`` - boolean, optional
Filter by those stored query flags.
``source`` - string, optional
Filter by query source.
``owner_id`` - string, optional
Filter by owner actor ID.
``include_private`` - boolean, optional
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 ``StoredQueryPage`` dataclass instance with these attributes:
``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
``True`` if another page of results is available.
``limit`` - integer
The limit used for this page.
.. _datasette_count_queries:
await .count_queries(database=None, ...)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Counts stored queries visible to the specified actor. This accepts the same filtering keyword arguments as :ref:`datasette_list_queries`, except for ``limit``, ``cursor`` and ``include_private``.
.. _datasette_remove_query:
await .remove_query(database, name, source=None)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Removes a stored query.
``database`` - string
The database the query belongs to.
``name`` - string
The query name.
``source`` - string, optional
If provided, only a query with this source will be removed.
.. _datasette_column_types:
Column types
@ -2148,6 +2342,26 @@ The internal database schema is as follows:
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE 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 queries_owner_idx
ON queries(owner_id);
.. [[[end]]]
@ -2219,8 +2433,8 @@ Note that the space character is a special case: it will be replaced with a ``+`
.. _internals_utils_call_with_supported_arguments:
call_with_supported_arguments(fn, **kwargs)
-------------------------------------------
call_with_supported_arguments(fn, \*\*kwargs)
---------------------------------------------
Call ``fn``, passing it only those keyword arguments that match its function signature. This implements a dependency injection pattern - the caller provides all available arguments, and the function receives only the ones it declares as parameters.
@ -2247,8 +2461,8 @@ This is useful in plugins that want to define callback functions that only decla
.. _internals_utils_async_call_with_supported_arguments:
await async_call_with_supported_arguments(fn, **kwargs)
-------------------------------------------------------
await async_call_with_supported_arguments(fn, \*\*kwargs)
---------------------------------------------------------
Async version of :ref:`call_with_supported_arguments <internals_utils_call_with_supported_arguments>`. Use this for ``async def`` callback functions.

View file

@ -149,7 +149,7 @@ Shows currently attached databases. `Databases example <https://latest.datasette
/-/jump
-------
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and canned queries, and plugins can contribute additional items.
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and stored queries, and plugins can contribute additional items.
Each item includes a ``type`` string used as a category label in the menu. Items can also include an optional ``description`` with longer text describing that individual result.

View file

@ -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 <canned_queries>` 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 <stored_queries>` 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.

View file

@ -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 <canned_queries>`, the name of that query.
If this was the execution of a :ref:`stored query <stored_queries>`, the name of that query.
``database`` - string
The name of the database.
@ -1207,85 +1207,6 @@ Potential use-cases:
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__
.. _plugin_hook_canned_queries:
canned_queries(datasette, database, actor)
------------------------------------------
``datasette`` - :ref:`internals_datasette`
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
``database`` - string
The name of the database.
``actor`` - dictionary or None
The currently authenticated :ref:`actor <authentication_actor>`.
Use this hook to return a dictionary of additional :ref:`canned query <canned_queries>` definitions for the specified database. The return value should be the same shape as the JSON described in the :ref:`canned query <canned_queries>` documentation.
.. code-block:: python
from datasette import hookimpl
@hookimpl
def canned_queries(datasette, database):
if database == "mydb":
return {
"my_query": {
"sql": "select * from my_table where id > :min_id"
}
}
The hook can alternatively return an awaitable function that returns a list. Here's an example that returns queries that have been stored in the ``saved_queries`` database table, if one exists:
.. code-block:: python
from datasette import hookimpl
@hookimpl
def canned_queries(datasette, database):
async def inner():
db = datasette.get_database(database)
if await db.table_exists("saved_queries"):
results = await db.execute(
"select name, sql from saved_queries"
)
return {
result["name"]: {"sql": result["sql"]}
for result in results
}
return inner
The actor parameter can be used to include the currently authenticated actor in your decision. Here's an example that returns saved queries that were saved by that actor:
.. code-block:: python
from datasette import hookimpl
@hookimpl
def canned_queries(datasette, database, actor):
async def inner():
db = datasette.get_database(database)
if actor is not None and await db.table_exists(
"saved_queries"
):
results = await db.execute(
"select name, sql from saved_queries where actor_id = :id",
{"id": actor["id"]},
)
return {
result["name"]: {"sql": result["sql"]}
for result in results
}
return inner
Example: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__
.. _plugin_hook_actor_from_request:
actor_from_request(datasette, request)
@ -1704,7 +1625,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 <canned_queries>`. This plugin hook allows additional magic parameters to be defined by plugins.
:ref:`queries_magic_parameters` can be used to add automatic parameters to :ref:`configured queries <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.
@ -1897,7 +1818,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.
@ -2073,7 +1994,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.
@ -2084,7 +2005,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:
@ -2348,9 +2269,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`
@ -2363,9 +2284,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:

View file

@ -216,6 +216,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"register_column_types"
]
},
{
"name": "datasette.default_database_actions",
"static": false,
"templates": false,
"version": null,
"hooks": [
"database_actions"
]
},
{
"name": "datasette.default_debug_menu",
"static": false,
@ -249,7 +258,6 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"templates": false,
"version": null,
"hooks": [
"canned_queries",
"permission_resources_sql"
]
},

View file

@ -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 <queries>` with the SQL queries that use SpatiaLite functions that you want people to be able to execute.
The `Datasette SpatiaLite tutorial <https://datasette.io/tutorials/spatialite>`__ includes detailed instructions for running SpatiaLite safely using these techniques

View file

@ -66,12 +66,12 @@ You can also use the `sqlite-utils <https://sqlite-utils.datasette.io/>`__ tool
sqlite-utils create-view sf-trees.db demo_view "select qSpecies from Street_Tree_List"
.. _canned_queries:
.. _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).
.. _canned_queries_named_parameters:
.. _stored_queries:
.. _saved_queries:
Canned query parameters
~~~~~~~~~~~~~~~~~~~~~~~
Stored queries
~~~~~~~~~~~~~~
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.
Datasette stores both configured queries and user-created queries in the ``queries`` table in the :ref:`internal database <internals_internal>`. 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.
Here's an example of a canned query with a named parameter:
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
.. _queries_named_parameters:
Query parameters
~~~~~~~~~~~~~~~~
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 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.
@ -270,17 +313,17 @@ You can alternatively provide an explicit list of named parameters using the ``"
}
.. [[[end]]]
.. _canned_queries_options:
.. _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 <https://github.com/simonw/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``:
@ -346,14 +389,14 @@ This example demonstrates both ``fragment`` and ``hide_sql``:
`See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action.
.. _canned_queries_writable:
.. _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, {
@ -481,14 +524,14 @@ You can pre-populate form fields when the page first loads using a query string,
If you specify a query in ``"on_success_message_sql"``, that query will be executed after the main query. The first column of the first row return by that query will be displayed as a success message. Named parameters from the main query will be made available to the success message query as well.
.. _canned_queries_magic_parameters:
.. _queries_magic_parameters:
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:
@ -578,14 +621,14 @@ The form presented at ``/mydatabase/add_message`` will have just a field for ``m
Additional custom magic parameters can be added by plugins using the :ref:`plugin_hook_register_magic_parameters` hook.
.. _canned_queries_json_api:
.. _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

View file

@ -244,7 +244,7 @@ except (KeyError, TypeError):
New code:
```python
try:
query_info = await datasette.get_canned_query(database, query_name, request.actor)
query_info = await datasette.get_query(database, query_name)
if query_info and "title" in query_info:
title = query_info["title"]
except (KeyError, TypeError):
@ -253,7 +253,7 @@ except (KeyError, TypeError):
### Update render functions to async
If your plugin's render function needs to call `datasette.get_canned_query()` or other async Datasette methods, it must be declared as async:
If your plugin's render function needs to call `datasette.get_query()` or other async Datasette methods, it must be declared as async:
Old code:
```python
@ -268,7 +268,7 @@ New code:
async def render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):
# ...
if query_name:
query_info = await datasette.get_canned_query(database, query_name, request.actor)
query_info = await datasette.get_query(database, query_name)
if query_info and "title" in query_info:
title = query_info["title"]
```

View file

@ -35,7 +35,6 @@ EXPECTED_PLUGINS = [
"hooks": [
"actor_from_request",
"asgi_wrapper",
"canned_queries",
"database_actions",
"extra_body_script",
"extra_css_urls",
@ -68,7 +67,6 @@ EXPECTED_PLUGINS = [
"hooks": [
"actor_from_request",
"asgi_wrapper",
"canned_queries",
"extra_js_urls",
"extra_template_vars",
"handle_exception",

View file

@ -314,11 +314,6 @@ def startup(datasette):
_ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow)
@hookimpl
def canned_queries(datasette, database, actor):
return {"from_hook": f"select 1, '{actor['id'] if actor else 'null'}' as actor_id"}
@hookimpl
def register_magic_parameters():
from uuid import uuid4

View file

@ -143,20 +143,6 @@ def startup(datasette):
return inner
@hookimpl
def canned_queries(datasette, database):
async def inner():
return {
"from_async_hook": "select {}".format(
(
await datasette.get_database(database).execute("select 1 + 1")
).first()[0]
)
}
return inner
@hookimpl(trylast=True)
def menu_links(datasette, actor):
async def inner():

View file

@ -66,7 +66,23 @@ 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",
"ExecuteWriteAnalyzeView",
"ExecuteWriteView",
"GlobalQueryListView",
"QueryCreateAnalyzeView",
"QueryDeleteView",
"QueryDefinitionView",
"QueryListView",
"QueryParametersView",
"QueryStoreView",
"QueryUpdateView",
)
)
return view_labels

View file

@ -154,12 +154,10 @@ 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 [
("/fixtures/from_async_hook", "from_async_hook"),
("/fixtures/from_hook", "from_hook"),
("/fixtures/magic_parameters", "magic_parameters"),
("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"),
("/fixtures/pragma_cache_size", "pragma_cache_size"),
@ -344,17 +342,29 @@ async def test_query_parameter_form_fields(ds_client):
response = await ds_client.get("/fixtures/-/query?sql=select+:name")
assert response.status_code == 200
assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="">'
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="" data-parameter-control>'
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")
assert response2.status_code == 200
assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello">'
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello" data-parameter-control>'
in response2.text
)
@pytest.mark.asyncio
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 'id="sql-parameters-section"' in response.text
assert "setupSqlParameterRefresh" in response.text
@pytest.mark.asyncio
async def test_row_html_simple_primary_key(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key/1")
@ -623,7 +633,7 @@ async def test_404_content_type(ds_client):
@pytest.mark.asyncio
async def test_canned_query_default_title(ds_client):
async def test_stored_query_default_title(ds_client):
response = await ds_client.get("/fixtures/magic_parameters")
assert response.status_code == 200
soup = Soup(response.content, "html.parser")
@ -631,7 +641,7 @@ async def test_canned_query_default_title(ds_client):
@pytest.mark.asyncio
async def test_canned_query_with_custom_metadata(ds_client):
async def test_stored_query_with_custom_metadata(ds_client):
response = await ds_client.get("/fixtures/neighborhood_search?text=town")
assert response.status_code == 200
soup = Soup(response.content, "html.parser")
@ -690,8 +700,8 @@ 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
async def test_stored_query_with_hide_has_no_hidden_sql(ds_client):
# 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")
@ -710,7 +720,7 @@ async def test_canned_query_with_hide_has_no_hidden_sql(ds_client):
(True, "?_show_sql=1", "_show_sql", "/_memory/one", "hide"),
),
)
def test_canned_query_show_hide_metadata_option(
def test_stored_query_show_hide_metadata_option(
hide_sql,
querystring,
expected_hidden,
@ -971,10 +981,10 @@ def test_base_url_affects_metadata_extra_css_urls(app_client_base_url_prefix):
("/fixtures/magic_parameters", None),
],
)
async def test_edit_sql_link_on_canned_queries(ds_client, path, expected):
async def test_edit_sql_link_on_stored_queries(ds_client, path, expected):
response = await ds_client.get(path)
assert response.status_code == 200
expected_link = f'<a href="{expected}" class="canned-query-edit-sql">Edit SQL</a>'
expected_link = f'<a href="{expected}" class="stored-query-edit-sql">Edit SQL</a>'
if expected:
assert expected_link in response.text
else:
@ -1096,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",

View file

@ -688,6 +688,54 @@ async def test_execute_isolated(db, disable_threads):
assert not await db.execute_isolated_fn(table_exists_checker("created_by_isolated"))
@pytest.mark.asyncio
async def test_analyze_sql():
ds = Datasette(memory=True)
db = ds.add_memory_database("test_analyze_sql", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
analysis = await db.analyze_sql("select name from dogs where id = ?", (1,))
assert [
(
access.operation,
access.database,
access.sqlite_schema,
access.table,
access.columns,
access.source,
)
for access in analysis.table_accesses
] == [
("read", "data", "main", "dogs", ("id", "name"), None),
]
@pytest.mark.asyncio
async def test_analyze_sql_insert_select():
ds = Datasette(memory=True)
db = ds.add_memory_database("test_analyze_sql_insert_select", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await db.execute_write("create table cats (id integer primary key, name text)")
analysis = await db.analyze_sql("insert into dogs (name) select name from cats")
assert {
(
access.operation,
access.database,
access.sqlite_schema,
access.table,
access.columns,
access.source,
)
for access in analysis.table_accesses
} == {
("insert", "data", "main", "dogs", (), None),
("read", "data", "main", "cats", ("name",), None),
}
@pytest.mark.asyncio
async def test_mtime_ns(db):
assert isinstance(db.mtime_ns, int)

View file

@ -76,7 +76,7 @@ async def ds_for_jump():
@pytest.mark.asyncio
async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_jump):
async def test_jump_searches_tables_databases_views_and_stored_queries(ds_for_jump):
response = await ds_for_jump.client.get(
"/-/jump.json?q=content", actor={"id": "user"}
)
@ -98,7 +98,7 @@ async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_ju
@pytest.mark.asyncio
async def test_jump_uses_canned_query_names_not_titles(ds_for_jump):
async def test_jump_uses_stored_query_names_not_titles(ds_for_jump):
response = await ds_for_jump.client.get(
"/-/jump.json?q=datasette", actor={"id": "user"}
)

View file

@ -1,4 +1,5 @@
import collections
from asgiref.sync import async_to_sync
from datasette.app import Datasette
from datasette.cli import cli
from datasette.default_permissions import restrictions_allow_action
@ -609,6 +610,10 @@ def test_padlocks_on_database_page(cascade_app_client):
previous_config = cascade_app_client.ds.config
try:
cascade_app_client.ds.config = config
async_to_sync(cascade_app_client.ds.invoke_startup)()
async_to_sync(cascade_app_client.ds.add_query)(
"fixtures", "query_two", "select 2", source="config"
)
response = cascade_app_client.get(
"/fixtures",
cookies={"ds_actor": cascade_app_client.actor_cookie({"id": "test"})},
@ -617,13 +622,13 @@ def test_padlocks_on_database_page(cascade_app_client):
assert ">123_starts_with_digits</a></h3>" in response.text
assert ">Table With Space In Name</a> 🔒</h3>" in response.text
# Queries
assert ">from_async_hook</a> 🔒</li>" in response.text
assert ">query_two</a></li>" in response.text
# Views
assert ">paginated_view</a> 🔒</li>" in response.text
assert ">simple_view</a></li>" in response.text
finally:
cascade_app_client.ds.config = previous_config
async_to_sync(cascade_app_client.ds.remove_query)("fixtures", "query_two")
@pytest.mark.asyncio
@ -885,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": {
@ -904,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": {
@ -932,16 +937,24 @@ async def test_permissions_in_config(
updated_config = copy.deepcopy(previous_config)
updated_config.update(config)
perms_ds.config = updated_config
await perms_ds._save_queries_from_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
@ -951,42 +964,24 @@ async def test_permissions_in_config(
assert result == expected_result
finally:
perms_ds.config = previous_config
await perms_ds._save_queries_from_config()
@pytest.mark.asyncio
async def test_allowed_resources_view_query_includes_actor_specific_canned_queries():
"""
Actor-specific canned queries should be listed by allowed_resources("view-query").
This test is intentionally explicit about the previous bug:
- the canned query only exists for actor "alice"
- the permission rule only allows actor "alice" to view it
- allowed() succeeds for that specific query resource
- allowed_resources("view-query", actor) must include the same query
Before the fix, QueryResource.resources_sql() called canned_queries(..., actor=None),
so the query was omitted from resource enumeration and allowed_resources() returned
an empty list even though allowed() returned True.
"""
async def test_allowed_resources_view_query_includes_actor_specific_query_permissions():
from datasette import hookimpl
from datasette.permissions import PermissionSQL
from datasette.resources import QueryResource
class ActorSpecificQueryPlugin:
__name__ = "ActorSpecificQueryPlugin"
@hookimpl
def canned_queries(self, datasette, database, actor):
if database == "testdb" and actor and actor.get("id") == "alice":
return {"user_only": {"sql": "select 1 as n"}}
return {}
class ActorSpecificQueryPermissionPlugin:
__name__ = "ActorSpecificQueryPermissionPlugin"
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if action == "view-query" and actor and actor.get("id") == "alice":
return PermissionSQL(sql="""
SELECT 'testdb' AS parent, 'user_only' AS child, 1 AS allow,
'alice can view her actor-specific canned query' AS reason
'alice can view this query' AS reason
""")
return None
@ -994,9 +989,10 @@ async def test_allowed_resources_view_query_includes_actor_specific_canned_queri
await ds.invoke_startup()
ds.add_memory_database("testdb")
await ds._refresh_schemas()
await ds.add_query("testdb", "user_only", "select 1 as n")
plugin = ActorSpecificQueryPlugin()
ds.pm.register(plugin, name="actor_specific_query_plugin")
plugin = ActorSpecificQueryPermissionPlugin()
ds.pm.register(plugin, name="actor_specific_query_permission_plugin")
try:
actor = {"id": "alice"}
@ -1012,7 +1008,7 @@ async def test_allowed_resources_view_query_includes_actor_specific_canned_queri
("testdb", "user_only")
]
finally:
ds.pm.unregister(name="actor_specific_query_plugin")
ds.pm.unregister(name="actor_specific_query_permission_plugin")
@pytest.mark.asyncio

View file

@ -885,40 +885,64 @@ async def test_hook_startup_catalog_populated(ds_client):
@pytest.mark.asyncio
async def test_hook_canned_queries(ds_client):
queries = (await ds_client.get("/fixtures.json")).json()["queries"]
async def test_plugin_startup_can_add_queries():
ds = Datasette(memory=True)
ds.add_memory_database("plugin_startup_queries", name="data")
class AddQueriesPlugin:
__name__ = "AddQueriesPlugin"
@hookimpl
def startup(self, datasette):
async def inner():
result = await datasette.get_database("data").execute("select 1 + 1")
await datasette.add_query(
"data",
"from_startup",
"select {}".format(result.first()[0]),
source="plugin",
)
return inner
ds.pm.register(AddQueriesPlugin(), name="add_queries_plugin")
try:
response = await ds.client.get("/data.json")
finally:
ds.pm.unregister(name="add_queries_plugin")
queries = response.json()["queries"]
queries_by_name = {q["name"]: q for q in queries}
assert {
"sql": "select 2",
"name": "from_async_hook",
"private": False,
} == queries_by_name["from_async_hook"]
assert {
"sql": "select 1, 'null' as actor_id",
"name": "from_hook",
"private": False,
} == queries_by_name["from_hook"]
assert queries_by_name["from_startup"]["sql"] == "select 2"
assert queries_by_name["from_startup"]["private"] is False
@pytest.mark.asyncio
async def test_hook_canned_queries_non_async(ds_client):
response = await ds_client.get("/fixtures/from_hook.json?_shape=array")
assert [{"1": 1, "actor_id": "null"}] == response.json()
async def test_plugin_startup_query_can_execute():
ds = Datasette(memory=True)
ds.add_memory_database("plugin_startup_query_execute", name="data")
class AddQueryPlugin:
__name__ = "AddQueryPlugin"
@hookimpl
def startup(self, datasette):
async def inner():
await datasette.add_query(
"data", "from_startup", "select 2", source="plugin"
)
return inner
ds.pm.register(AddQueryPlugin(), name="add_query_plugin")
try:
response = await ds.client.get("/data/from_startup.json?_shape=array")
finally:
ds.pm.unregister(name="add_query_plugin")
@pytest.mark.asyncio
async def test_hook_canned_queries_async(ds_client):
response = await ds_client.get("/fixtures/from_async_hook.json?_shape=array")
assert [{"2": 2}] == response.json()
@pytest.mark.asyncio
async def test_hook_canned_queries_actor(ds_client):
assert (
await ds_client.get("/fixtures/from_hook.json?_bot=1&_shape=array")
).json() == [{"1": 1, "actor_id": "bot"}]
def test_hook_register_magic_parameters(restore_working_directory):
with make_app_client(
extra_databases={"data.db": "create table logs (line text)"},
@ -1462,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
@ -1524,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/from_hook?z=xyz")
response = await ds_client.get("/fixtures/magic_parameters?z=xyz")
assert response.status_code == 200
assert "Xtop_query:fixtures:from_hook:xyz" in response.text
assert "Xtop_stored_query:fixtures:magic_parameters:xyz" in response.text
finally:
pm.unregister(name="SlotPlugin")

1874
tests/test_queries.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,19 @@
from bs4 import BeautifulSoup as Soup
from asgiref.sync import async_to_sync
import json
import pytest
import re
from .fixtures import make_app_client
def update_query(client, name, **kwargs):
async_to_sync(client.ds.invoke_startup)()
async_to_sync(client.ds.update_query)("data", name, **kwargs)
@pytest.fixture
def canned_write_client(tmpdir):
template_dir = tmpdir / "canned_write_templates"
def stored_write_client(tmpdir):
template_dir = tmpdir / "stored_write_templates"
template_dir.mkdir()
(template_dir / "query-data-update_name.html").write_text(
"""
@ -23,7 +29,7 @@ def canned_write_client(tmpdir):
"databases": {
"data": {
"queries": {
"canned_read": {"sql": "select * from names"},
"stored_read": {"sql": "select * from names"},
"add_name": {
"sql": "insert into names (name) values (:name)",
"write": True,
@ -60,7 +66,7 @@ def canned_write_client(tmpdir):
@pytest.fixture
def canned_write_immutable_client():
def stored_write_immutable_client():
with make_app_client(
is_immutable=True,
config={
@ -80,7 +86,7 @@ def canned_write_immutable_client():
@pytest.mark.asyncio
async def test_canned_query_with_named_parameter(ds_client):
async def test_stored_query_with_named_parameter(ds_client):
response = await ds_client.get(
"/fixtures/neighborhood_search.json?text=town&_shape=arrays"
)
@ -94,14 +100,14 @@ async def test_canned_query_with_named_parameter(ds_client):
]
def test_insert(canned_write_client):
response = canned_write_client.post(
def test_insert(stored_write_client):
response = stored_write_client.post(
"/data/add_name",
{"name": "Hello"},
csrftoken_from=True,
cookies={"foo": "bar"},
)
messages = canned_write_client.ds.unsign(
messages = stored_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert messages == [["Query executed, 1 row affected", 1]]
@ -109,9 +115,9 @@ def test_insert(canned_write_client):
assert response.headers["Location"] == "/data/add_name?success"
def test_insert_blocked_cross_site(canned_write_client):
def test_insert_blocked_cross_site(stored_write_client):
# A cross-site POST (browser-originated) must be blocked
response = canned_write_client.post(
response = stored_write_client.post(
"/data/add_name",
{"name": "Hello"},
headers={"sec-fetch-site": "cross-site"},
@ -119,74 +125,72 @@ def test_insert_blocked_cross_site(canned_write_client):
assert 403 == response.status
def test_insert_no_cookies_no_csrf(canned_write_client):
response = canned_write_client.post("/data/add_name", {"name": "Hello"})
def test_insert_no_cookies_no_csrf(stored_write_client):
response = stored_write_client.post("/data/add_name", {"name": "Hello"})
assert 302 == response.status
assert "/data/add_name?success" == response.headers["Location"]
def test_custom_success_message(canned_write_client):
response = canned_write_client.post(
def test_custom_success_message(stored_write_client):
response = stored_write_client.post(
"/data/delete_name",
{"rowid": 1},
cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
cookies={"ds_actor": stored_write_client.actor_cookie({"id": "root"})},
csrftoken_from=True,
)
assert 302 == response.status
messages = canned_write_client.ds.unsign(
messages = stored_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert [["Name deleted", 1]] == messages
def test_insert_error(canned_write_client):
canned_write_client.post("/data/add_name", {"name": "Hello"}, csrftoken_from=True)
response = canned_write_client.post(
def test_insert_error(stored_write_client):
stored_write_client.post("/data/add_name", {"name": "Hello"}, csrftoken_from=True)
response = stored_write_client.post(
"/data/add_name_specify_id",
{"rowid": 1, "name": "Should fail"},
csrftoken_from=True,
)
assert 302 == response.status
assert "/data/add_name_specify_id?error" == response.headers["Location"]
messages = canned_write_client.ds.unsign(
messages = stored_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert [["UNIQUE constraint failed: names.rowid", 3]] == messages
# How about with a custom error message?
canned_write_client.ds.config["databases"]["data"]["queries"][
"add_name_specify_id"
]["on_error_message"] = "ERROR"
response = canned_write_client.post(
update_query(stored_write_client, "add_name_specify_id", on_error_message="ERROR")
response = stored_write_client.post(
"/data/add_name_specify_id",
{"rowid": 1, "name": "Should fail"},
csrftoken_from=True,
)
assert [["ERROR", 3]] == canned_write_client.ds.unsign(
assert [["ERROR", 3]] == stored_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
def test_on_success_message_sql(canned_write_client):
response = canned_write_client.post(
def test_on_success_message_sql(stored_write_client):
response = stored_write_client.post(
"/data/add_name_specify_id",
{"rowid": 5, "name": "Should be OK"},
csrftoken_from=True,
)
assert response.status == 302
assert response.headers["Location"] == "/data/add_name_specify_id"
messages = canned_write_client.ds.unsign(
messages = stored_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert messages == [["Name added: Should be OK with rowid 5", 1]]
def test_error_in_on_success_message_sql(canned_write_client):
response = canned_write_client.post(
def test_error_in_on_success_message_sql(stored_write_client):
response = stored_write_client.post(
"/data/add_name_specify_id_with_error_in_on_success_message_sql",
{"rowid": 1, "name": "Should fail"},
csrftoken_from=True,
)
messages = canned_write_client.ds.unsign(
messages = stored_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert messages == [
@ -194,26 +198,29 @@ def test_error_in_on_success_message_sql(canned_write_client):
]
def test_custom_params(canned_write_client):
response = canned_write_client.get("/data/update_name?extra=foo")
assert '<input type="text" id="qp3" name="extra" value="foo">' in response.text
def test_custom_params(stored_write_client):
response = stored_write_client.get("/data/update_name?extra=foo")
assert (
'<input type="text" id="qp3" name="extra" value="foo" data-parameter-control>'
in response.text
)
def test_canned_query_pages_no_vary_header(canned_write_client):
def test_stored_query_pages_no_vary_header(stored_write_client):
# These pages no longer embed per-cookie CSRF tokens, so they must not
# set Vary: Cookie - they should be cacheable across users.
assert "vary" not in canned_write_client.get("/data").headers
assert "vary" not in canned_write_client.get("/data/update_name").headers
assert "vary" not in stored_write_client.get("/data").headers
assert "vary" not in stored_write_client.get("/data/update_name").headers
def test_json_post_body(canned_write_client):
response = canned_write_client.post(
def test_json_post_body(stored_write_client):
response = stored_write_client.post(
"/data/add_name",
body=json.dumps({"name": ["Hello", "there"]}),
)
assert 302 == response.status
assert "/data/add_name?success" == response.headers["Location"]
rows = canned_write_client.get("/data/names.json?_shape=array").json
rows = stored_write_client.get("/data/names.json?_shape=array").json
assert rows == [{"rowid": 1, "name": "['Hello', 'there']"}]
@ -226,8 +233,8 @@ def test_json_post_body(canned_write_client):
(None, '{"name": "NameGoesHere", "_json": 1}', None),
),
)
def test_json_response(canned_write_client, headers, body, querystring):
response = canned_write_client.post(
def test_json_response(stored_write_client, headers, body, querystring):
response = stored_write_client.post(
"/data/add_name" + (querystring or ""),
body=body,
headers=headers,
@ -239,29 +246,27 @@ def test_json_response(canned_write_client, headers, body, querystring):
"message": "Query executed, 1 row affected",
"redirect": "/data/add_name?success",
}
rows = canned_write_client.get("/data/names.json?_shape=array").json
rows = stored_write_client.get("/data/names.json?_shape=array").json
assert rows == [{"rowid": 1, "name": "NameGoesHere"}]
def test_canned_query_permissions_on_database_page(canned_write_client):
# Without auth only shows three queries
query_names = {
q["name"] for q in canned_write_client.get("/data.json").json["queries"]
}
def test_stored_query_permissions_on_database_page(stored_write_client):
# Without auth shows the five public queries
anon_response = stored_write_client.get("/data.json")
query_names = {q["name"] for q in anon_response.json["queries"]}
assert query_names == {
"add_name_specify_id_with_error_in_on_success_message_sql",
"from_hook",
"update_name",
"add_name_specify_id",
"from_async_hook",
"canned_read",
"stored_read",
"add_name",
}
assert anon_response.json["queries_more"] is False
# With auth shows four
response = canned_write_client.get(
# With auth the database page preview shows the first five queries
response = stored_write_client.get(
"/data.json",
cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
cookies={"ds_actor": stored_write_client.actor_cookie({"id": "root"})},
)
assert response.status == 200
query_names_and_private = sorted(
@ -278,20 +283,43 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
"name": "add_name_specify_id_with_error_in_on_success_message_sql",
"private": False,
},
{"name": "canned_read", "private": False},
{"name": "delete_name", "private": True},
{"name": "from_async_hook", "private": False},
{"name": "from_hook", "private": False},
{"name": "stored_read", "private": False},
]
assert response.json["queries_more"] is True
# The full query list endpoint includes the remaining query
response = stored_write_client.get(
"/data/-/queries.json?_size=10",
cookies={"ds_actor": stored_write_client.actor_cookie({"id": "root"})},
)
assert response.status == 200
query_names_and_private = sorted(
[
{"name": q["name"], "private": q["private"]}
for q in response.json["queries"]
],
key=lambda q: q["name"],
)
assert query_names_and_private == [
{"name": "add_name", "private": False},
{"name": "add_name_specify_id", "private": False},
{
"name": "add_name_specify_id_with_error_in_on_success_message_sql",
"private": False,
},
{"name": "delete_name", "private": True},
{"name": "stored_read", "private": False},
{"name": "update_name", "private": False},
]
def test_canned_query_permissions(canned_write_client):
assert 403 == canned_write_client.get("/data/delete_name").status
assert 200 == canned_write_client.get("/data/update_name").status
cookies = {"ds_actor": canned_write_client.actor_cookie({"id": "root"})}
assert 200 == canned_write_client.get("/data/delete_name", cookies=cookies).status
assert 200 == canned_write_client.get("/data/update_name", cookies=cookies).status
def test_stored_query_permissions(stored_write_client):
assert 403 == stored_write_client.get("/data/delete_name").status
assert 200 == stored_write_client.get("/data/update_name").status
cookies = {"ds_actor": stored_write_client.actor_cookie({"id": "root"})}
assert 200 == stored_write_client.get("/data/delete_name", cookies=cookies).status
assert 200 == stored_write_client.get("/data/update_name", cookies=cookies).status
@pytest.fixture(scope="session")
@ -327,12 +355,16 @@ def magic_parameters_client():
],
)
def test_magic_parameters(magic_parameters_client, magic_parameter, expected_re):
magic_parameters_client.ds.config["databases"]["data"]["queries"]["runme_post"][
"sql"
] = f"insert into logs (line) values (:{magic_parameter})"
magic_parameters_client.ds.config["databases"]["data"]["queries"]["runme_get"][
"sql"
] = f"select :{magic_parameter} as result"
update_query(
magic_parameters_client,
"runme_post",
sql=f"insert into logs (line) values (:{magic_parameter})",
)
update_query(
magic_parameters_client,
"runme_get",
sql=f"select :{magic_parameter} as result",
)
cookies = {
"ds_actor": magic_parameters_client.actor_cookie({"id": "root"}),
"foo": "bar",
@ -366,9 +398,11 @@ def test_magic_parameters(magic_parameters_client, magic_parameter, expected_re)
@pytest.mark.parametrize("use_csrf", [True, False])
@pytest.mark.parametrize("return_json", [True, False])
def test_magic_parameters_csrf_json(magic_parameters_client, use_csrf, return_json):
magic_parameters_client.ds.config["databases"]["data"]["queries"]["runme_post"][
"sql"
] = "insert into logs (line) values (:_header_host)"
update_query(
magic_parameters_client,
"runme_post",
sql="insert into logs (line) values (:_header_host)",
)
qs = ""
if return_json:
qs = "?_json=1"
@ -400,8 +434,8 @@ def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_c
assert response.json["error"].startswith("You did not supply a value for binding")
def test_canned_write_custom_template(canned_write_client):
response = canned_write_client.get("/data/update_name")
def test_stored_write_custom_template(stored_write_client):
response = stored_write_client.get("/data/update_name")
assert response.status == 200
assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text
assert (
@ -419,10 +453,10 @@ def test_canned_write_custom_template(canned_write_client):
)
def test_canned_write_query_disabled_for_immutable_database(
canned_write_immutable_client,
def test_stored_write_query_disabled_for_immutable_database(
stored_write_immutable_client,
):
response = canned_write_immutable_client.get("/fixtures/add")
response = stored_write_immutable_client.get("/fixtures/add")
assert response.status == 200
assert (
"This query cannot be executed because the database is immutable."
@ -430,7 +464,7 @@ def test_canned_write_query_disabled_for_immutable_database(
)
assert '<input type="submit" value="Run SQL" disabled>' in response.text
# Submitting form should get a forbidden error
response = canned_write_immutable_client.post(
response = stored_write_immutable_client.post(
"/fixtures/add",
{"text": "text"},
csrftoken_from=True,

View file

@ -0,0 +1,188 @@
import pytest
from datasette.utils.sqlite import sqlite3
from datasette.utils.sql_analysis import analyze_sql_tables
@pytest.fixture
def conn():
conn = sqlite3.connect(":memory:")
conn.executescript("""
create table dogs (id integer primary key, name text, age integer);
create table cats (id integer primary key, name text);
create table log (message text);
create view dog_names as select id, name from dogs;
create trigger dogs_after_insert after insert on dogs begin
update cats set name = new.name where id = new.id;
insert into log (message) values (new.name);
end;
create trigger dog_names_instead_of_update instead of update on dog_names begin
update dogs set name = new.name where id = old.id;
end;
""")
try:
yield conn
finally:
conn.close()
def as_tuples(analysis):
return [
(
access.operation,
access.database,
access.sqlite_schema,
access.table,
access.columns,
access.source,
)
for access in analysis.table_accesses
]
def test_analyze_select_tables(conn):
analysis = analyze_sql_tables(
conn,
"select dogs.name, cats.name from dogs join cats on dogs.id = cats.id where dogs.age > ?",
(2,),
database_name="data",
)
assert set(as_tuples(analysis)) == {
("read", "data", "main", "cats", ("id", "name"), None),
("read", "data", "main", "dogs", ("age", "id", "name"), None),
}
def test_analyze_uses_sqlite_schema_as_default_database(conn):
analysis = analyze_sql_tables(conn, "select name from dogs")
assert set(as_tuples(analysis)) == {
("read", "main", "main", "dogs", ("name",), None),
}
def test_analyze_insert_tables(conn):
analysis = analyze_sql_tables(
conn,
"insert into dogs (name, age) values (:name, :age)",
{"name": "Cleo", "age": 4},
database_name="data",
)
assert set(as_tuples(analysis)) == {
("insert", "data", "main", "dogs", (), None),
("read", "data", "main", "dogs", ("id", "name"), "dogs_after_insert"),
("update", "data", "main", "cats", ("name",), "dogs_after_insert"),
("read", "data", "main", "cats", ("id",), "dogs_after_insert"),
("insert", "data", "main", "log", (), "dogs_after_insert"),
}
def test_analyze_update_tables(conn):
analysis = analyze_sql_tables(
conn,
"update dogs set age = age + 1 where name = ?",
("Cleo",),
database_name="data",
)
assert set(as_tuples(analysis)) == {
("update", "data", "main", "dogs", ("age",), None),
("read", "data", "main", "dogs", ("age", "name"), None),
}
def test_analyze_delete_tables(conn):
analysis = analyze_sql_tables(
conn,
"delete from dogs where name = ?",
("Cleo",),
database_name="data",
)
assert set(as_tuples(analysis)) == {
("delete", "data", "main", "dogs", (), None),
("read", "data", "main", "dogs", ("name",), None),
}
def test_analyze_insert_select_with_cte(conn):
analysis = analyze_sql_tables(
conn,
"""
with old_dogs as (
select name from dogs where age > :age
)
insert into cats (name)
select name from old_dogs
""",
{"age": 10},
database_name="data",
)
assert set(as_tuples(analysis)) == {
("insert", "data", "main", "cats", (), None),
("read", "data", "main", "dogs", ("age", "name"), "old_dogs"),
}
def test_analyze_view_with_instead_of_trigger(conn):
analysis = analyze_sql_tables(
conn,
"update dog_names set name = :name where id = :id",
{"name": "Zelda", "id": 1},
database_name="data",
)
assert set(as_tuples(analysis)) == {
("update", "data", "main", "dog_names", ("name",), None),
("read", "data", "main", "dogs", ("id", "name"), "dog_names"),
("read", "data", "main", "dog_names", ("id", "name"), "dog_names"),
(
"read",
"data",
"main",
"dog_names",
("id", "name"),
"dog_names_instead_of_update",
),
("update", "data", "main", "dogs", ("name",), "dog_names_instead_of_update"),
("read", "data", "main", "dogs", ("id",), "dog_names_instead_of_update"),
}
def test_analyze_attached_database_tables(conn):
conn.execute("attach database ':memory:' as extra")
conn.execute("create table extra.people (id integer primary key, name text)")
analysis = analyze_sql_tables(
conn,
"insert into extra.people (name) select name from dogs",
database_name="data",
schema_to_database={"extra": "extra_db"},
)
assert set(as_tuples(analysis)) == {
("insert", "extra_db", "extra", "people", (), None),
("read", "data", "main", "dogs", ("name",), None),
}
def test_analyze_clears_authorizer_on_error():
class FakeConnection:
def __init__(self):
self.authorizers = []
def set_authorizer(self, authorizer):
self.authorizers.append(authorizer)
def execute(self, sql, params):
raise sqlite3.OperationalError("bad SQL")
conn = FakeConnection()
with pytest.raises(sqlite3.OperationalError):
analyze_sql_tables(conn, "bad SQL")
assert conn.authorizers[-1] is None