Compare commits

..

No commits in common. "main" and "1.0a30" have entirely different histories.

122 changed files with 2014 additions and 21466 deletions

View file

@ -57,7 +57,7 @@ jobs:
db.route = "alternative-route" db.route = "alternative-route"
' > plugins/alternative_route.py ' > plugins/alternative_route.py
cp fixtures.db fixtures2.db cp fixtures.db fixtures2.db
- name: And the counters writable stored query demo - name: And the counters writable canned query demo
run: | run: |
cat > plugins/counters.py <<EOF cat > plugins/counters.py <<EOF
from datasette import hookimpl from datasette import hookimpl
@ -69,24 +69,23 @@ 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_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_b', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)") await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
for name in ("counter_a", "counter_b", "counter_c"):
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 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
EOF EOF
# - name: Make some modifications to metadata.json # - name: Make some modifications to metadata.json
# run: | # run: |
@ -117,7 +116,7 @@ jobs:
--plugins-dir=plugins \ --plugins-dir=plugins \
--branch=$GITHUB_SHA \ --branch=$GITHUB_SHA \
--version-note=$GITHUB_SHA \ --version-note=$GITHUB_SHA \
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \ --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
--install 'datasette-ephemeral-tables>=0.2.2' \ --install 'datasette-ephemeral-tables>=0.2.2' \
--service "datasette-latest$SUFFIX" \ --service "datasette-latest$SUFFIX" \
--secret $LATEST_DATASETTE_SECRET --secret $LATEST_DATASETTE_SECRET

View file

@ -1,48 +0,0 @@
name: Playwright
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
python-version: "3.14"
allow-prereleases: true
cache: pip
cache-dependency-path: pyproject.toml
- name: Cache uv
uses: actions/cache@v5
with:
path: ~/.cache/uv
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-py3.14-uv-
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-playwright-${{ matrix.browser }}-
- name: Install uv
run: python -m pip install uv
- name: Install dependencies
run: uv sync --group dev --group playwright
- name: Install ${{ matrix.browser }}
run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }}
- name: Run Playwright tests
run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }}

4
.gitignore vendored
View file

@ -1,8 +1,6 @@
build-metadata.json build-metadata.json
datasets.json datasets.json
.playwright-mcp
scratchpad scratchpad
.vscode .vscode
@ -133,4 +131,4 @@ tests/*.dylib
tests/*.so tests/*.so
tests/*.dll tests/*.dll
.idea .idea

View file

@ -11,22 +11,6 @@ export DATASETTE_SECRET := "not_a_secret"
@test *options: init @test *options: init
uv run pytest -n auto {{options}} uv run pytest -n auto {{options}}
# Install Playwright browser support, Chromium by default
@playwright-install browser="chromium":
uv run --group playwright playwright install {{browser}}
# Install all Playwright browsers used by the test suite
@playwright-install-all:
uv run --group playwright playwright install chromium firefox webkit
# Run Playwright tests, Chromium by default
@playwright browser="chromium" *options:
uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}}
# Run Playwright tests against all supported browsers
@playwright-all *options:
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}}
@codespell: @codespell:
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt

View file

@ -19,38 +19,23 @@ import weakref
import pytest import pytest
from datasette.app import Datasette
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar( _active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
"datasette_active_instances", default=None "datasette_active_instances", default=None
) )
_original_init = None _original_init = Datasette.__init__
def _install_tracking(): def _tracking_init(self, *args, **kwargs):
# datasette.app is imported lazily here rather than at module level: _original_init(self, *args, **kwargs)
# as a pytest11 entry point this module is imported during pytest instances = _active_instances.get()
# startup, before pytest-cov starts measuring, so a module-level if instances is not None:
# import would drag in all of datasette and make every import-time instances.append(weakref.ref(self))
# line in the package invisible to coverage
global _original_init
if _original_init is not None:
return
from datasette.app import Datasette
_original_init = Datasette.__init__
def _tracking_init(self, *args, **kwargs):
_original_init(self, *args, **kwargs)
instances = _active_instances.get()
if instances is not None:
instances.append(weakref.ref(self))
Datasette.__init__ = _tracking_init
def pytest_configure(config): Datasette.__init__ = _tracking_init
if _enabled(config):
_install_tracking()
def pytest_addoption(parser): def pytest_addoption(parser):

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextvars import contextvars
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence from typing import TYPE_CHECKING, Any, Dict, Iterable, List
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.permissions import Resource from datasette.permissions import Resource
@ -42,31 +42,12 @@ from jinja2.exceptions import TemplateNotFound
from .events import Event from .events import Event
from .column_types import SQLiteType from .column_types import SQLiteType
from . import stored_queries, write_sql
from .views import Context from .views import Context
from .views.database import ( from .views.database import database_download, DatabaseView, TableCreateView, QueryView
database_download,
DatabaseView,
TableCreateView,
QueryView,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.stored_queries import (
QueryCreateAnalyzeView,
QueryDeleteView,
QueryDefinitionView,
QueryEditView,
GlobalQueryListView,
QueryListView,
QueryParametersView,
QueryStoreView,
QueryUpdateView,
)
from .views.index import IndexView from .views.index import IndexView
from .views.special import ( from .views.special import (
JsonDataView, JsonDataView,
PatternPortfolioView, PatternPortfolioView,
AutocompleteDebugView,
AuthTokenView, AuthTokenView,
ApiExplorerView, ApiExplorerView,
CreateTokenView, CreateTokenView,
@ -83,12 +64,10 @@ from .views.special import (
TableSchemaView, TableSchemaView,
) )
from .views.table import ( from .views.table import (
TableAutocompleteView,
TableInsertView, TableInsertView,
TableUpsertView, TableUpsertView,
TableSetColumnTypeView, TableSetColumnTypeView,
TableDropView, TableDropView,
TableFragmentView,
table_view, table_view,
) )
from .views.row import RowView, RowDeleteView, RowUpdateView from .views.row import RowView, RowDeleteView, RowUpdateView
@ -294,15 +273,6 @@ DEFAULT_NOT_SET = object()
ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params"))
def _permission_cache_key(actor, action, parent, child):
# Key on the full serialized actor so actors differing in any field
# (e.g. token restrictions) never share cache entries
actor_key = (
json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
)
return (actor_key, action, parent, child)
async def favicon(request, send): async def favicon(request, send):
await asgi_send_file( await asgi_send_file(
send, send,
@ -601,9 +571,6 @@ class Datasette:
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log # 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 # 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: def get_jinja_environment(self, request: Request = None) -> Environment:
environment = self._jinja_env environment = self._jinja_env
if request: if request:
@ -764,7 +731,6 @@ class Datasette:
await await_me_maybe(hook) await await_me_maybe(hook)
# Ensure internal tables and metadata are populated before startup hooks # Ensure internal tables and metadata are populated before startup hooks
await self._refresh_schemas() await self._refresh_schemas()
await self._save_queries_from_config()
# Load column_types from config into internal DB # Load column_types from config into internal DB
await self._apply_column_types_config() await self._apply_column_types_config()
for hook in pm.hook.startup(datasette=self): for hook in pm.hook.startup(datasette=self):
@ -1041,180 +1007,6 @@ class Datasette:
[database_name, resource_name, column_name, key, value], [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
):
# Raise Forbidden or QueryWriteRejected if SQL should not run
return await write_sql.ensure_query_write_permissions(
self, database, sql, actor=actor, params=params, analysis=analysis
)
# Column types API # Column types API
async def _get_resource_column_details(self, database: str, resource: str): async def _get_resource_column_details(self, database: str, resource: str):
@ -1446,6 +1238,29 @@ class Datasette:
def app_css_hash(self): def app_css_hash(self):
return self.static_hash("app.css") 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): def _prepare_connection(self, conn, database):
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.text_factory = lambda x: str(x, "utf-8", "replace") conn.text_factory = lambda x: str(x, "utf-8", "replace")
@ -1829,124 +1644,46 @@ class Datasette:
# For global actions, resource can be omitted: # For global actions, resource can be omitted:
can_debug = await datasette.allowed(action="permissions-debug", actor=actor) can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
""" """
results = await self.allowed_many( from datasette.utils.actions_sql import check_permission_for_resource
actions=[action], resource=resource, actor=actor
)
return results[action]
async def allowed_many( # For global actions, resource remains None
self,
*,
actions: Sequence[str],
resource: "Resource" = None,
actor: dict | None = None,
) -> dict[str, bool]:
"""
Check several actions against one resource for one actor.
Resolves every action (plus any also_requires dependencies) with a # Check if this action has also_requires - if so, check that action first
single internal database query, instead of one or two queries per action_obj = self.actions.get(action)
action. Results are stored in the request-scoped permission cache, if action_obj and action_obj.also_requires:
so subsequent datasette.allowed() calls for the same checks within # Must have the required action first
the same request are served from the cache. if not await self.allowed(
action=action_obj.also_requires,
Example: resource=resource,
from datasette.resources import TableResource
results = await datasette.allowed_many(
actions=["edit-schema", "drop-table", "insert-row"],
resource=TableResource(database="data", table="exercise"),
actor=actor, actor=actor,
) ):
# {"edit-schema": True, "drop-table": True, "insert-row": False} return False
"""
from datasette.utils.actions_sql import check_permissions_for_actions
from datasette.permissions import (
_permission_check_cache,
_skip_permission_checks,
)
# For global actions, resource is None # For global actions, resource is None
parent = resource.parent if resource else None parent = resource.parent if resource else None
child = resource.child if resource else None child = resource.child if resource else None
# Expand also_requires dependencies (transitively) so that each result = await check_permission_for_resource(
# dependency is resolved within the same batch datasette=self,
expanded = [] actor=actor,
action=action,
parent=parent,
child=child,
)
def add_action(name): # Log the permission check for debugging
if name in expanded: self._permission_checks.append(
return PermissionCheck(
action_obj = self.actions.get(name) when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
if action_obj is None:
raise ValueError(f"Unknown action: {name}")
expanded.append(name)
if action_obj.also_requires:
add_action(action_obj.also_requires)
requested = list(dict.fromkeys(actions))
for name in requested:
add_action(name)
# Consult the request-scoped cache, unless permission checks are
# being skipped (skip-mode verdicts must never be cached)
skip = _skip_permission_checks.get()
cache = None if skip else _permission_check_cache.get()
final = {}
to_check = []
for name in expanded:
if cache is not None:
key = _permission_cache_key(actor, name, parent, child)
if key in cache:
final[name] = cache[key]
continue
to_check.append(name)
raw = {}
if to_check:
raw = await check_permissions_for_actions(
datasette=self,
actor=actor, actor=actor,
actions=to_check, action=action,
parent=parent, parent=parent,
child=child, child=child,
result=result,
) )
)
def resolve(name): return result
# final verdict = own rules AND verdict of also_requires chain
if name in final:
return final[name]
result = raw[name]
action_obj = self.actions.get(name)
if result and action_obj.also_requires:
result = resolve(action_obj.also_requires)
final[name] = result
return result
for name in expanded:
resolve(name)
# Cache the freshly computed checks
if cache is not None:
for name in to_check:
cache[_permission_cache_key(actor, name, parent, child)] = final[name]
# Log every check (including cache hits) for the debug page,
# dependencies before the actions that required them
when = datetime.datetime.now(datetime.timezone.utc).isoformat()
for name in reversed(expanded):
self._permission_checks.append(
PermissionCheck(
when=when,
actor=actor,
action=name,
parent=parent,
child=child,
result=final[name],
)
)
return {name: final[name] for name in requested}
async def ensure_permission( async def ensure_permission(
self, self,
@ -2019,11 +1756,6 @@ class Datasette:
other_table = fk["other_table"] other_table = fk["other_table"]
other_column = fk["other_column"] other_column = fk["other_column"]
if other_column is None:
other_pks = await db.primary_keys(other_table)
if len(other_pks) != 1:
return {}
other_column = other_pks[0]
visible, _ = await self.check_visibility( visible, _ = await self.check_visibility(
actor, actor,
action="view-table", action="view-table",
@ -2320,8 +2052,6 @@ class Datasette:
and "ds_actor" in request.cookies and "ds_actor" in request.cookies
and request.actor, and request.actor,
"app_css_hash": self.app_css_hash(), "app_css_hash": self.app_css_hash(),
"edit_tools_js_hash": self.static_hash("edit-tools.js"),
"table_js_hash": self.static_hash("table.js"),
"zip": zip, "zip": zip,
"body_scripts": body_scripts, "body_scripts": body_scripts,
"format_bytes": format_bytes, "format_bytes": format_bytes,
@ -2506,10 +2236,6 @@ class Datasette:
JumpView.as_view(self), JumpView.as_view(self),
r"/-/jump(\.(?P<format>json))?$", r"/-/jump(\.(?P<format>json))?$",
) )
add_route(
GlobalQueryListView.as_view(self),
r"/-/queries(\.(?P<format>json))?$",
)
add_route( add_route(
InstanceSchemaView.as_view(self), InstanceSchemaView.as_view(self),
r"/-/schema(\.(?P<format>json|md))?$", r"/-/schema(\.(?P<format>json|md))?$",
@ -2546,10 +2272,6 @@ class Datasette:
wrap_view(PatternPortfolioView, self), wrap_view(PatternPortfolioView, self),
r"/-/patterns$", r"/-/patterns$",
) )
add_route(
AutocompleteDebugView.as_view(self),
r"/-/debug/autocomplete$",
)
add_route( add_route(
wrap_view(database_download, self), wrap_view(database_download, self),
r"/(?P<database>[^\/\.]+)\.db$", r"/(?P<database>[^\/\.]+)\.db$",
@ -2559,54 +2281,14 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
) )
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$") 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( add_route(
DatabaseSchemaView.as_view(self), DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$", r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
) )
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
)
add_route( add_route(
wrap_view(QueryView, self), wrap_view(QueryView, self),
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
) )
add_route(
QueryDefinitionView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
)
add_route(
QueryEditView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/edit$",
)
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( add_route(
wrap_view(table_view, self), wrap_view(table_view, self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
@ -2627,14 +2309,6 @@ class Datasette:
TableSetColumnTypeView.as_view(self), TableSetColumnTypeView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
) )
add_route(
TableFragmentView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
)
add_route(
TableAutocompleteView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/autocomplete$",
)
add_route( add_route(
TableDropView.as_view(self), TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
@ -2721,16 +2395,7 @@ class DatasetteRouter:
if raw_path: if raw_path:
path = raw_path.decode("ascii") path = raw_path.decode("ascii")
path = path.partition("?")[0] path = path.partition("?")[0]
# Give each request a fresh permission check cache, so repeated return await self.route_path(scope, receive, send, path)
# datasette.allowed() checks within the request are memoized but
# results never persist beyond it
from datasette.permissions import _permission_check_cache
cache_token = _permission_check_cache.set({})
try:
return await self.route_path(scope, receive, send, path)
finally:
_permission_check_cache.reset(cache_token)
async def route_path(self, scope, receive, send, path): async def route_path(self, scope, receive, send, path):
# Strip off base_url if present before routing # Strip off base_url if present before routing
@ -2993,22 +2658,19 @@ def wrap_view_function(view_fn, datasette):
def permanent_redirect(path, forward_query_string=False, forward_rest=False): def permanent_redirect(path, forward_query_string=False, forward_rest=False):
def view(request, send): return wrap_view(
redirect_path = ( lambda request, send: Response.redirect(
path path
+ (request.url_vars["rest"] if forward_rest else "") + (request.url_vars["rest"] if forward_rest else "")
+ ( + (
("?" + request.query_string) ("?" + request.query_string)
if forward_query_string and request.query_string if forward_query_string and request.query_string
else "" else ""
) ),
) status=301,
route_path = request.scope.get("route_path") ),
if route_path and request.path.endswith(route_path): datasette=None,
redirect_path = request.path[: -len(route_path)] + redirect_path )
return Response.redirect(redirect_path, status=301)
return wrap_view(view, datasette=None)
_curly_re = re.compile(r"({.*?})") _curly_re = re.compile(r"({.*?})")

View file

@ -21,7 +21,6 @@ from .app import (
SQLITE_LIMIT_ATTACHED, SQLITE_LIMIT_ATTACHED,
pm, pm,
) )
from .inspect import inspect_tables
from .utils import ( from .utils import (
LoadExtension, LoadExtension,
StartupError, StartupError,
@ -155,14 +154,14 @@ async def inspect_(files, sqlite_extensions):
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
data = {} data = {}
for name, database in app.databases.items(): for name, database in app.databases.items():
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {})) counts = await database.table_counts(limit=3600 * 1000)
data[name] = { data[name] = {
"hash": database.hash, "hash": database.hash,
"size": database.size, "size": database.size,
"file": database.path, "file": database.path,
"tables": { "tables": {
table_name: {"count": table["count"]} table_name: {"count": table_count}
for table_name, table in tables.items() for table_name, table_count in counts.items()
}, },
} }
return data return data

View file

@ -6,17 +6,19 @@ class SQLiteType(Enum):
INTEGER = "INTEGER" INTEGER = "INTEGER"
REAL = "REAL" REAL = "REAL"
BLOB = "BLOB" BLOB = "BLOB"
NUMERIC = "NUMERIC" NULL = "NULL"
@classmethod @classmethod
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType": def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
if declared_type is None: if declared_type is None:
return cls.BLOB return cls.NULL
normalized = declared_type.strip().upper() normalized = declared_type.strip().upper()
if not normalized: if not normalized:
return cls.BLOB return cls.NULL
if normalized == cls.NULL.value:
return cls.NULL
if "INT" in normalized: if "INT" in normalized:
return cls.INTEGER return cls.INTEGER
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")): if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
@ -29,7 +31,7 @@ class SQLiteType(Enum):
): ):
return cls.REAL return cls.REAL
return cls.NUMERIC return None
class ColumnType: class ColumnType:

View file

@ -25,14 +25,11 @@ from .utils import (
table_columns, table_columns,
table_column_details, table_column_details,
) )
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables from .utils.sqlite import sqlite_version
from .utils.sqlite import sqlite_hidden_table_names
from .inspect import inspect_hash from .inspect import inspect_hash
connections = threading.local() connections = threading.local()
EXECUTE_WRITE_RETURNING_LIMIT = 10
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file")) AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
@ -238,24 +235,11 @@ class Database:
except OSError: except OSError:
pass pass
async def execute_write( async def execute_write(self, sql, params=None, block=True, request=None):
self,
sql,
params=None,
block=True,
request=None,
return_all=False,
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
):
self._check_not_closed() self._check_not_closed()
if returning_limit < 0:
raise ValueError("returning_limit must be >= 0")
def _inner(conn): def _inner(conn):
cursor = conn.execute(sql, params or []) return conn.execute(sql, params or [])
return ExecuteWriteResult.from_cursor(
cursor, return_all=return_all, returning_limit=returning_limit
)
with trace("sql", database=self.name, sql=sql.strip(), params=params): with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn(_inner, block=block, request=request) results = await self.execute_write_fn(_inner, block=block, request=request)
@ -298,14 +282,13 @@ class Database:
async def execute_isolated_fn(self, fn): async def execute_isolated_fn(self, fn):
self._check_not_closed() self._check_not_closed()
# Open a new connection just for the duration of this function, # Open a new connection just for the duration of this function
# blocking the write queue to avoid any writes occurring during it # blocking the write queue to avoid any writes occurring during it
write = self.is_mutable if self.ds.executor is None:
# non-threaded mode
def _run(): isolated_connection = self.connect(write=True)
isolated_connection = self.connect(write=write)
try: try:
return fn(isolated_connection) result = fn(isolated_connection)
finally: finally:
isolated_connection.close() isolated_connection.close()
try: try:
@ -313,25 +296,10 @@ class Database:
except ValueError: except ValueError:
# Was probably a memory connection # Was probably a memory connection
pass pass
return result
if self.ds.executor is None: else:
# non-threaded mode # Threaded mode - send to write thread
return _run() return await self._send_to_write_thread(fn, isolated_connection=True)
if not write:
# Immutable database - no writes can ever occur, so there is no
# write queue to block; run against a fresh read-only connection
return await asyncio.get_running_loop().run_in_executor(
self.ds.executor, _run
)
# 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): async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
self._check_not_closed() self._check_not_closed()
@ -458,21 +426,20 @@ class Database:
if conn_exception is not None: if conn_exception is not None:
exception = conn_exception exception = conn_exception
elif task.isolated_connection: elif task.isolated_connection:
isolated_connection = self.connect(write=True)
try: try:
isolated_connection = self.connect(write=True) result = task.fn(isolated_connection)
try:
result = task.fn(isolated_connection)
finally:
isolated_connection.close()
try:
self._all_file_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
pass
except Exception as e: except Exception as e:
sys.stderr.write("{}\n".format(e)) sys.stderr.write("{}\n".format(e))
sys.stderr.flush() sys.stderr.flush()
exception = e exception = e
finally:
isolated_connection.close()
try:
self._all_file_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
pass
else: else:
try: try:
if task.transaction: if task.transaction:
@ -727,7 +694,83 @@ class Database:
t for t in db_config["tables"] if db_config["tables"][t].get("hidden") t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
] ]
hidden_tables += await self.execute_fn(sqlite_hidden_table_names) if sqlite_version()[1] >= 37:
hidden_tables += [x[0] for x in await self.execute("""
with shadow_tables as (
select name
from pragma_table_list
where [type] = 'shadow'
order by name
),
core_tables as (
select name
from sqlite_master
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
OR substr(name, 1, 1) == '_'
),
combined as (
select name from shadow_tables
union all
select name from core_tables
)
select name from combined order by 1
""")]
else:
hidden_tables += [x[0] for x in await self.execute("""
WITH base AS (
SELECT name
FROM sqlite_master
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
OR substr(name, 1, 1) == '_'
),
fts_suffixes AS (
SELECT column1 AS suffix
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
),
fts5_names AS (
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
),
fts5_shadow_tables AS (
SELECT
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
FROM fts5_names
JOIN fts_suffixes
),
fts3_suffixes AS (
SELECT column1 AS suffix
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
),
fts3_names AS (
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
),
fts3_shadow_tables AS (
SELECT
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
FROM fts3_names
JOIN fts3_suffixes
),
final AS (
SELECT name FROM base
UNION ALL
SELECT name FROM fts5_shadow_tables
UNION ALL
SELECT name FROM fts3_shadow_tables
)
SELECT name FROM final ORDER BY 1
""")]
# Also hide any FTS tables that have a content= argument
hidden_tables += [x[0] for x in await self.execute("""
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%'
AND sql LIKE '%USING FTS%'
AND sql LIKE '%content=%'
""")]
has_spatialite = await self.execute_fn(detect_spatialite) has_spatialite = await self.execute_fn(detect_spatialite)
if has_spatialite: if has_spatialite:
@ -829,10 +872,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
# Execute the actual write # Execute the actual write
try: try:
result = fn(conn) result = fn(conn)
except Exception as e: except Exception:
# Throw exception into generator so it can handle it # Throw exception into generator so it can handle it
try: try:
gen.throw(e) gen.throw(*sys.exc_info())
except StopIteration: except StopIteration:
pass pass
# Re-raise the original exception # Re-raise the original exception
@ -902,44 +945,6 @@ class MultipleValues(Exception):
pass pass
class ExecuteWriteResult:
def __init__(self, rowcount, lastrowid, description, rows, truncated):
self.rowcount = rowcount
self.lastrowid = lastrowid
self.description = description
self.truncated = truncated
self._rows = rows
@classmethod
def from_cursor(
cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT
):
rows = []
truncated = False
description = cursor.description
lastrowid = cursor.lastrowid
try:
if description is not None:
if return_all:
rows = cursor.fetchall()
else:
rows = cursor.fetchmany(returning_limit + 1)
if len(rows) > returning_limit:
rows = rows[:returning_limit]
truncated = True
rowcount = cursor.rowcount
finally:
cursor.close()
if description is not None and not return_all and truncated:
rowcount = -1
return cls(rowcount, lastrowid, description, rows, truncated)
def fetchall(self):
rows = self._rows
self._rows = []
return rows
class Results: class Results:
def __init__(self, rows, truncated, description): def __init__(self, rows, truncated, description):
self.rows = rows self.rows = rows

View file

@ -48,26 +48,12 @@ def register_actions():
resource_class=DatabaseResource, resource_class=DatabaseResource,
also_requires="view-database", also_requires="view-database",
), ),
Action(
name="execute-write-sql",
abbr="ews",
description="Execute writable SQL queries",
resource_class=DatabaseResource,
also_requires="view-database",
),
Action( Action(
name="create-table", name="create-table",
abbr="ct", abbr="ct",
description="Create tables", description="Create tables",
resource_class=DatabaseResource, 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) # Table-level actions (child-level)
Action( Action(
name="view-table", name="view-table",
@ -118,16 +104,4 @@ def register_actions():
description="View named query results", description="View named query results",
resource_class=QueryResource, 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

@ -76,12 +76,6 @@ class JsonColumnType(ColumnType):
return None return None
class TextareaColumnType(ColumnType):
name = "textarea"
description = "Multiline text"
sqlite_types = (SQLiteType.TEXT,)
@hookimpl @hookimpl
def register_column_types(datasette): def register_column_types(datasette):
return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType] return [UrlColumnType, EmailColumnType, JsonColumnType]

View file

@ -1,24 +0,0 @@
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

@ -37,11 +37,6 @@ DEBUG_MENU_ITEMS = (
"Debug allow rules", "Debug allow rules",
"Explore how allow blocks match actors against permission rules.", "Explore how allow blocks match actors against permission rules.",
), ),
(
"/-/debug/autocomplete",
"Debug autocomplete",
"Try out table autocomplete against a detected label column.",
),
( (
"/-/threads", "/-/threads",
"Debug threads", "Debug threads",

View file

@ -17,6 +17,13 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations 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 # Re-export all hooks and public utilities
from .restrictions import ( from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql, actor_restrictions_sql as actor_restrictions_sql,
@ -26,9 +33,16 @@ from .restrictions import (
from .root import root_user_permissions_sql as root_user_permissions_sql from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql from .config import config_permissions_sql as config_permissions_sql
from .defaults import ( from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check, default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql, 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, 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,48 +67,3 @@ async def default_action_permissions_sql(
return PermissionSQL.allow(reason=reason) return PermissionSQL.allow(reason=reason)
return None 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

@ -1,48 +0,0 @@
from datasette import hookimpl
from datasette.resources import QueryResource
@hookimpl
def query_actions(datasette, actor, database, query_name, request):
# Only stored queries (with a name) can be edited or deleted
if not query_name:
return None
async def inner():
query = await datasette.get_query(database, query_name)
if query is None:
return []
# Config-defined and trusted queries are managed outside the UI
if query.source == "config" or query.is_trusted:
return []
links = []
if await datasette.allowed(
action="update-query",
resource=QueryResource(database, query_name),
actor=actor,
):
links.append(
{
"href": datasette.urls.table(database, query_name) + "/-/edit",
"label": "Edit this query",
"description": (
"Change the title, description, SQL or visibility."
),
}
)
if await datasette.allowed(
action="delete-query",
resource=QueryResource(database, query_name),
actor=actor,
):
links.append(
{
"href": datasette.urls.table(database, query_name) + "/-/delete",
"label": "Delete this query",
"description": "Permanently remove this saved query.",
}
)
return links
return inner

View file

@ -1,118 +0,0 @@
import re
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar
from asyncinject import Registry
def extra_names_from_request(request):
extra_bits = request.args.getlist("_extra")
extras = set()
for bit in extra_bits:
extras.update(part for part in bit.split(",") if part)
return extras
class ExtraScope(Enum):
TABLE = "table"
ROW = "row"
QUERY = "query"
@dataclass(frozen=True)
class ExtraExample:
path: str | None = None
key: str | None = None
value: object | None = None
note: str | None = None
class Provider:
name: ClassVar[str | None] = None
scopes: ClassVar[set[ExtraScope]] = set()
public: ClassVar[bool] = False
@classmethod
def key(cls):
return cls.name or _camel_to_snake(cls.__name__)
@classmethod
def available_for(cls, scope):
return scope in cls.scopes
async def resolve(self, context):
raise NotImplementedError
class Extra(Provider):
description: ClassVar[str | None] = None
example: ClassVar[ExtraExample | None] = None
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {}
public: ClassVar[bool] = True
expensive: ClassVar[bool] = False
docs_note: ClassVar[str | None] = None
@classmethod
def example_for_scope(cls, scope):
return cls.examples.get(scope, cls.example)
class ExtraRegistry:
def __init__(self, classes):
self.classes = list(classes)
self.classes_by_name = {cls.key(): cls for cls in self.classes}
# Lazily-built shared state, keyed by scope. Safe to share across
# requests because Extra instances are stateless and asyncinject's
# Registry keeps per-call state local to each resolve_multi() call.
# If extras classes ever become registerable at runtime (e.g. via a
# plugin hook) these caches will need invalidating.
self._scope_registries = {}
self._allowed_names = {}
def classes_for_scope(self, scope, include_internal=True):
classes = [
cls
for cls in self.classes
if cls.available_for(scope) and (include_internal or cls.public)
]
return classes
def public_classes_for_scope(self, scope):
return self.classes_for_scope(scope, include_internal=False)
def _registry_for_scope(self, scope):
registry = self._scope_registries.get(scope)
if registry is None:
registry = Registry()
for cls in self.classes_for_scope(scope):
registry.register(cls().resolve, name=cls.key())
self._scope_registries[scope] = registry
return registry
def _allowed_names_for_scope(self, scope, include_internal):
key = (scope, include_internal)
names = self._allowed_names.get(key)
if names is None:
names = {
cls.key()
for cls in self.classes_for_scope(
scope, include_internal=include_internal
)
}
self._allowed_names[key] = names
return names
async def resolve(self, requested, context, scope, include_internal=False):
allowed_names = self._allowed_names_for_scope(scope, include_internal)
requested_names = [name for name in requested if name in allowed_names]
resolved = await self._registry_for_scope(scope).resolve_multi(
requested_names, results={"context": context}
)
return {name: resolved[name] for name in requested_names}
def _camel_to_snake(name):
name = re.sub(r"(Extra|Provider)$", "", name)
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()

View file

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

View file

@ -137,6 +137,11 @@ 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 @hookspec
def register_magic_parameters(datasette): def register_magic_parameters(datasette):
"""Return a list of (name, function) magic parameter functions""" """Return a list of (name, function) magic parameter functions"""
@ -159,32 +164,32 @@ def jump_items_sql(datasette, actor, request):
@hookspec @hookspec
def row_actions(datasette, actor, request, database, table, row): def row_actions(datasette, actor, request, database, table, row):
"""Items for the row actions menu""" """Links for the row actions menu"""
@hookspec @hookspec
def table_actions(datasette, actor, database, table, request): def table_actions(datasette, actor, database, table, request):
"""Items for the table actions menu""" """Links for the table actions menu"""
@hookspec @hookspec
def view_actions(datasette, actor, database, view, request): def view_actions(datasette, actor, database, view, request):
"""Items for the view actions menu""" """Links for the view actions menu"""
@hookspec @hookspec
def query_actions(datasette, actor, database, query_name, request, sql, params): def query_actions(datasette, actor, database, query_name, request, sql, params):
"""Items for the query and stored query actions menu""" """Links for the query and canned query actions menu"""
@hookspec @hookspec
def database_actions(datasette, actor, database, request): def database_actions(datasette, actor, database, request):
"""Items for the database actions menu""" """Links for the database actions menu"""
@hookspec @hookspec
def homepage_actions(datasette, actor, request): def homepage_actions(datasette, actor, request):
"""Items for the homepage actions menu""" """Links for the homepage actions menu"""
@hookspec @hookspec
@ -228,8 +233,8 @@ def top_query(datasette, request, database, sql):
@hookspec @hookspec
def top_stored_query(datasette, request, database, query_name): def top_canned_query(datasette, request, database, query_name):
"""HTML to include at the top of the stored query page""" """HTML to include at the top of the canned query page"""
@hookspec @hookspec

View file

@ -8,14 +8,6 @@ _skip_permission_checks = contextvars.ContextVar(
"skip_permission_checks", default=False "skip_permission_checks", default=False
) )
# Request-scoped cache of permission check results. The ASGI router sets
# this to a fresh dict at the start of each request, so cached verdicts
# never outlive a request or leak between actors. Keys are
# (actor_json, action, parent, child) tuples, values are booleans.
_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
"permission_check_cache", default=None
)
class SkipPermissions: class SkipPermissions:
"""Context manager to temporarily skip permission checks. """Context manager to temporarily skip permission checks.
@ -66,16 +58,6 @@ class Resource(ABC):
self.child = child self.child = child
self._private = None # Sentinel to track if private was set self._private = None # Sentinel to track if private was set
def __str__(self) -> str:
return "/".join(
str(part) for part in (self.parent, self.child) if part is not None
)
def __repr__(self) -> str:
return "{}(parent={!r}, child={!r})".format(
self.__class__.__name__, self.parent, self.child
)
@property @property
def private(self) -> bool: def private(self) -> bool:
""" """

View file

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

View file

@ -1,5 +1,4 @@
import json import json
from datasette.extras import extra_names_from_request
from datasette.utils import ( from datasette.utils import (
value_as_boolean, value_as_boolean,
remove_infinites, remove_infinites,
@ -109,7 +108,7 @@ def json_renderer(request, args, data, error, truncated=None):
# Don't include "columns" in output # Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136 # https://github.com/simonw/datasette/issues/2136
if isinstance(data, dict) and "columns" not in extra_names_from_request(request): if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
data.pop("columns", None) data.pop("columns", None)
# Handle _nl option for _shape=array # Handle _nl option for _shape=array

View file

@ -41,7 +41,7 @@ class TableResource(Resource):
class QueryResource(Resource): class QueryResource(Resource):
"""A stored query in a database.""" """A canned query in a database."""
name = "query" name = "query"
parent_class = DatabaseResource parent_class = DatabaseResource
@ -51,8 +51,42 @@ class QueryResource(Resource):
@classmethod @classmethod
async def resources_sql(cls, datasette, actor=None) -> str: async def resources_sql(cls, datasette, actor=None) -> str:
return """ from datasette.plugins import pm
SELECT q.database_name AS parent, q.name AS child from datasette.utils import await_me_maybe
FROM queries q
JOIN catalog_databases cd ON cd.database_name = q.database_name # 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)

View file

@ -706,11 +706,6 @@ button.core[type=button] {
color: #666; color: #666;
padding-right: 0.25em; padding-right: 0.25em;
} }
/* The label may wrap (word-break: break-all on the li) but the count should
stay on one line - https://github.com/simonw/datasette/issues/2754 */
.facet-count {
white-space: nowrap;
}
.facet-info li, .facet-info li,
.facet-info ul { .facet-info ul {
margin: 0; margin: 0;
@ -792,9 +787,9 @@ p.zero-results {
dialog.mobile-column-actions-dialog { dialog.mobile-column-actions-dialog {
--ink: #0f0f0f; --ink: #0f0f0f;
--paper: #eef6ff; --paper: #f5f3ef;
--muted: #6b6b6b; --muted: #6b6b6b;
--rule: #d8e6f5; --rule: #e2dfd8;
--accent: #1a56db; --accent: #1a56db;
--card: #ffffff; --card: #ffffff;
border: none; border: none;
@ -1020,9 +1015,9 @@ dialog.mobile-column-actions-dialog::backdrop {
dialog.set-column-type-dialog { dialog.set-column-type-dialog {
--ink: #0f0f0f; --ink: #0f0f0f;
--paper: #eef6ff; --paper: #f5f3ef;
--muted: #6b6b6b; --muted: #6b6b6b;
--rule: #d8e6f5; --rule: #e2dfd8;
--accent: #1a56db; --accent: #1a56db;
--card: #ffffff; --card: #ffffff;
border: none; border: none;
@ -1109,7 +1104,7 @@ dialog.set-column-type-dialog::backdrop {
padding: 14px 16px; padding: 14px 16px;
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 8px; border-radius: 8px;
background: #fbfdff; background: #fcfbf9;
cursor: pointer; cursor: pointer;
} }
@ -1192,607 +1187,6 @@ dialog.set-column-type-dialog::backdrop {
cursor: wait; cursor: wait;
} }
.row-mutation-status {
margin: 0 0 0.75rem;
padding: 8px 10px;
border-left: 4px solid #54AC8E;
background: rgba(103,201,141,0.12);
color: #222;
}
.row-mutation-status[hidden] {
display: none;
}
.row-mutation-status-error {
border-left-color: #D0021B;
background: rgba(208,2,27,0.12);
}
.table-row-toolbar {
margin: 0 0 0.75rem;
}
button.table-insert-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
button.table-insert-row svg {
display: block;
flex-shrink: 0;
}
dialog.row-delete-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(440px, calc(100vw - 32px));
max-width: 95vw;
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.row-delete-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.row-delete-dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.row-delete-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.row-delete-dialog .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.row-delete-message,
.row-delete-error {
margin: 0;
padding: 16px 24px 0;
}
.row-delete-message {
color: var(--ink);
font-size: 0.95rem;
}
.row-delete-id {
display: inline;
padding: 2px 5px;
border: 1px solid var(--rule);
border-radius: 4px;
background: var(--paper);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
overflow-wrap: anywhere;
}
.row-delete-error {
color: #b91c1c;
font-size: 0.9rem;
}
.row-delete-dialog .modal-footer {
padding: 18px 20px 14px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
margin-top: 18px;
}
.row-delete-dialog .btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
.row-delete-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.row-delete-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.row-delete-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.row-delete-dialog .btn-primary:hover {
background: #1949b8;
}
.row-delete-dialog .btn:disabled {
opacity: 0.65;
cursor: wait;
}
dialog.row-edit-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(720px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(780px, calc(100vh - 32px));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.row-edit-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.row-edit-dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.row-edit-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.row-edit-dialog .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.row-edit-dialog .modal-title .row-dialog-action,
.row-delete-dialog .modal-title .row-dialog-action {
flex: 0 0 auto;
white-space: nowrap;
}
.row-edit-dialog .modal-title code,
.row-delete-dialog .modal-title code {
display: inline;
flex: 0 0 auto;
padding: 2px 5px;
border: 1px solid var(--rule);
border-radius: 4px;
background: var(--paper);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
overflow-wrap: anywhere;
}
.row-edit-dialog .modal-title .row-dialog-label,
.row-delete-dialog .modal-title .row-dialog-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-edit-form {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
}
.row-edit-summary,
.row-edit-loading,
.row-edit-error {
margin: 0;
padding: 12px 24px 0;
}
.row-edit-summary,
.row-edit-loading {
color: var(--muted);
font-size: 0.9rem;
}
.row-edit-error {
border-left: 4px solid #b91c1c;
border-radius: 4px;
background: #fff1f1;
color: #7f1d1d;
font-size: 0.9rem;
margin: 12px 24px 0;
padding: 10px 12px;
}
.row-edit-error:focus {
outline: 3px solid rgba(185, 28, 28, 0.18);
outline-offset: 2px;
}
.row-edit-fields {
display: grid;
gap: 14px;
padding: 16px 24px 24px;
overflow-y: auto;
}
.row-edit-field {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.row-edit-label {
padding-top: 8px;
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.row-edit-control-wrap {
display: grid;
gap: 5px;
}
.row-edit-input {
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 1px solid var(--rule);
border-radius: 5px;
padding: 8px 10px;
color: var(--ink);
background: #fff;
font: inherit;
}
textarea.row-edit-input {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.45;
}
.row-edit-input:focus {
border-color: var(--accent);
outline: 3px solid rgba(26, 86, 219, 0.12);
}
.row-edit-input[aria-invalid="true"] {
border-color: #b42318;
background: #fff8f7;
}
.row-edit-input[aria-invalid="true"]:focus {
border-color: #b42318;
outline-color: rgba(180, 35, 24, 0.16);
}
.row-edit-input[readonly] {
color: var(--muted);
background: var(--paper);
}
.row-edit-default {
display: grid;
grid-template-columns: minmax(0, 1fr) 7.25rem;
align-items: center;
gap: 8px;
min-width: 0;
border: 1px solid var(--rule);
border-radius: 5px;
padding: 7px 8px 7px 10px;
background: var(--paper);
color: var(--ink);
}
.row-edit-default[hidden],
.row-edit-custom-value[hidden] {
display: none;
}
.row-edit-default-text {
min-width: 0;
overflow-wrap: anywhere;
}
.row-edit-default-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
.row-edit-custom-value {
display: grid;
grid-template-columns: minmax(0, 1fr) 7.25rem;
gap: 8px;
align-items: center;
min-height: 45px;
padding-right: 8px;
}
.row-edit-default-button {
appearance: none;
border: 1px solid var(--rule);
border-radius: 4px;
background: #fff;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.78rem;
line-height: 1.2;
padding: 6px 8px;
white-space: nowrap;
width: 100%;
align-self: center;
}
.row-edit-default-button:hover,
.row-edit-default-button:focus {
background: #f8fafc;
}
.row-edit-default-button:focus {
outline: 3px solid rgba(26, 86, 219, 0.12);
outline-offset: 1px;
}
.row-edit-field-meta {
color: var(--muted);
font-size: 0.78rem;
}
.row-edit-field-validation-error {
color: #b42318;
display: block;
margin-top: 2px;
}
.row-edit-field-validation-error[hidden] {
display: none;
}
.row-edit-field-meta-autocomplete {
line-height: 1.2;
min-height: 1.2em;
}
.row-edit-fk-pk {
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.row-edit-fk-link {
overflow-wrap: anywhere;
}
.row-edit-empty {
color: var(--muted);
font-size: 0.9rem;
margin: 0;
}
datasette-autocomplete {
display: block;
position: relative;
max-width: 38rem;
}
datasette-autocomplete input[type="text"],
.debug-autocomplete-form input[type="text"] {
box-sizing: border-box;
width: 100%;
max-width: 38rem;
}
.datasette-autocomplete-list {
background: #fff;
border: 1px solid var(--rule);
border-radius: 5px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
box-sizing: border-box;
left: 0;
max-height: 16rem;
overflow-y: auto;
position: fixed;
right: auto;
top: auto;
z-index: 10000;
}
.datasette-autocomplete-list[hidden] {
display: none;
}
.datasette-autocomplete-option {
cursor: pointer;
padding: 7px 9px;
}
.datasette-autocomplete-option:hover,
.datasette-autocomplete-option[aria-selected="true"] {
background: var(--paper);
}
.datasette-autocomplete-option[aria-selected="true"] {
background: var(--paper);
font-weight: 600;
}
.datasette-autocomplete-status {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.debug-autocomplete-demo {
margin: 1rem 0;
}
.debug-autocomplete-selected {
max-width: 46rem;
}
.row-edit-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.row-edit-dialog .btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
.row-edit-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.row-edit-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.row-edit-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.row-edit-dialog .btn-primary:hover {
background: #1949b8;
}
.row-edit-dialog .btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.row-link-with-actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.row-inline-actions {
display: inline-flex;
gap: 0.2rem;
align-items: center;
}
.row-inline-action {
appearance: none;
border: 1px solid rgba(74, 85, 104, 0.24);
background: transparent;
color: #4a5568;
border-radius: 4px;
cursor: pointer;
display: inline-grid;
place-items: center;
min-height: 24px;
min-width: 24px;
padding: 2px;
position: relative;
}
.row-inline-action:hover,
.row-inline-action:focus {
background: rgba(74, 85, 104, 0.07);
}
.row-inline-action:focus {
outline: 3px solid #b3d4ff;
outline-offset: 1px;
}
.row-inline-action-icon {
display: block;
height: 13px;
width: 13px;
}
@media (max-width: 640px) { @media (max-width: 640px) {
dialog.mobile-column-actions-dialog { dialog.mobile-column-actions-dialog {
width: 95vw; width: 95vw;
@ -1840,68 +1234,6 @@ datasette-autocomplete input[type="text"],
padding-left: 18px; padding-left: 18px;
padding-right: 18px; padding-right: 18px;
} }
dialog.row-delete-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.row-delete-dialog .modal-header,
.row-delete-message,
.row-delete-error {
padding-left: 18px;
padding-right: 18px;
}
.row-delete-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
dialog.row-edit-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.row-edit-dialog .modal-header,
.row-edit-summary,
.row-edit-loading,
.row-edit-fields {
padding-left: 18px;
padding-right: 18px;
}
.row-edit-error {
margin-left: 18px;
margin-right: 18px;
}
.row-edit-field {
grid-template-columns: 1fr;
gap: 5px;
}
.row-edit-label {
padding-top: 0;
}
.row-edit-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
.row-inline-action {
min-height: 30px;
min-width: 30px;
padding: 4px;
}
.row-inline-action-icon {
height: 14px;
width: 14px;
}
} }
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {
@ -1956,10 +1288,6 @@ datasette-autocomplete input[type="text"],
font-size: 0.8em; font-size: 0.8em;
} }
.row-inline-actions {
margin-bottom: 0.35rem;
}
.select-wrapper { .select-wrapper {
width: 100px; width: 100px;
} }
@ -1970,7 +1298,6 @@ datasette-autocomplete input[type="text"],
width: 140px; width: 140px;
} }
button.choose-columns-mobile, button.choose-columns-mobile,
button.table-insert-row,
button.column-actions-mobile { button.column-actions-mobile {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2007,15 +1334,6 @@ datasette-autocomplete input[type="text"],
button.choose-columns-mobile { button.choose-columns-mobile {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.table-row-toolbar {
margin-bottom: 0.75rem;
}
button.table-insert-row {
width: 100%;
margin-bottom: 0;
}
} }
svg.dropdown-menu-icon { svg.dropdown-menu-icon {
@ -2061,32 +1379,18 @@ svg.dropdown-menu-icon {
.dropdown-menu a:link, .dropdown-menu a:link,
.dropdown-menu a:visited, .dropdown-menu a:visited,
.dropdown-menu a:hover, .dropdown-menu a:hover,
.dropdown-menu a:focus, .dropdown-menu a:focus
.dropdown-menu a:active, .dropdown-menu a:active {
.dropdown-menu button.action-menu-button {
text-decoration: none; text-decoration: none;
display: block; display: block;
padding: 4px 8px 2px 8px; padding: 4px 8px 2px 8px;
color: #222; color: #222;
white-space: nowrap; white-space: nowrap;
} }
.dropdown-menu button.action-menu-button { .dropdown-menu a:hover {
appearance: none;
background: none;
border: none;
box-sizing: border-box;
cursor: pointer;
font: inherit;
text-align: left;
width: 100%;
}
.dropdown-menu a:hover,
.dropdown-menu button.action-menu-button:hover,
.dropdown-menu button.action-menu-button:focus {
background-color: #eee; background-color: #eee;
} }
.dropdown-menu .dropdown-description { .dropdown-menu .dropdown-description {
display: block;
margin: 0; margin: 0;
color: #666; color: #666;
font-size: 0.8em; font-size: 0.8em;
@ -2105,15 +1409,11 @@ svg.dropdown-menu-icon {
border-bottom: 5px solid #666; border-bottom: 5px solid #666;
} }
.stored-query-edit-sql { .canned-query-edit-sql {
padding-left: 0.5em; padding-left: 0.5em;
position: relative; position: relative;
top: 1px; top: 1px;
} }
.save-query {
display: inline-block;
margin-left: 0.45em;
}
.blob-download { .blob-download {
display: block; display: block;

View file

@ -1,344 +0,0 @@
(function () {
function autocompleteValueFromRow(row) {
var pks = (row && row.pks) || {};
var keys = Object.keys(pks);
if (!keys.length) {
return "";
}
if (keys.length === 1) {
return String(pks[keys[0]]);
}
return keys
.map(function (key) {
return key + "=" + pks[key];
})
.join(", ");
}
function autocompleteLabelFromRow(row) {
var value = autocompleteValueFromRow(row);
if (row.label && String(row.label) !== value) {
return row.label + " (" + value + ")";
}
return value;
}
if (!window.customElements || customElements.get("datasette-autocomplete")) {
return;
}
class DatasetteAutocomplete extends HTMLElement {
constructor() {
super();
this.input = null;
this.listbox = null;
this.status = null;
this.results = [];
this.activeIndex = -1;
this.fetchId = 0;
this.searchTimer = null;
this.boundInput = this.handleInput.bind(this);
this.boundKeydown = this.handleKeydown.bind(this);
this.boundBlur = this.handleBlur.bind(this);
this.boundFocus = this.handleFocus.bind(this);
this.boundPositionListbox = this.positionListbox.bind(this);
}
connectedCallback() {
if (this.input) {
return;
}
this.input = this.querySelector("input");
if (!this.input) {
return;
}
var inputId =
this.input.id ||
"datasette-autocomplete-" + Math.random().toString(36).slice(2);
this.input.id = inputId;
var listboxId = inputId + "-listbox";
var statusId = inputId + "-status";
this.classList.add("datasette-autocomplete");
this.input.setAttribute("role", "combobox");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("aria-expanded", "false");
this.input.setAttribute("aria-controls", listboxId);
this.input.setAttribute("autocomplete", "off");
this.listbox = document.createElement("div");
this.listbox.className = "datasette-autocomplete-list";
this.listbox.id = listboxId;
this.listbox.setAttribute("role", "listbox");
this.listbox.hidden = true;
this.status = document.createElement("span");
this.status.className = "datasette-autocomplete-status";
this.status.id = statusId;
this.status.setAttribute("role", "status");
this.status.setAttribute("aria-live", "polite");
this.input.setAttribute(
"aria-describedby",
[this.input.getAttribute("aria-describedby"), statusId]
.filter(Boolean)
.join(" "),
);
this.appendChild(this.listbox);
this.appendChild(this.status);
this.input.addEventListener("input", this.boundInput);
this.input.addEventListener("keydown", this.boundKeydown);
this.input.addEventListener("blur", this.boundBlur);
this.input.addEventListener("focus", this.boundFocus);
}
disconnectedCallback() {
if (!this.input) {
return;
}
this.input.removeEventListener("input", this.boundInput);
this.input.removeEventListener("keydown", this.boundKeydown);
this.input.removeEventListener("blur", this.boundBlur);
this.input.removeEventListener("focus", this.boundFocus);
}
handleInput() {
this.scheduleSearch();
}
handleFocus() {
if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) {
this.scheduleSearch();
}
}
handleBlur() {
window.setTimeout(() => this.close(), 150);
}
handleKeydown(ev) {
if (ev.key === "Escape") {
if (!this.listbox.hidden) {
ev.preventDefault();
this.close();
}
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
if (this.listbox.hidden) {
this.scheduleSearch();
} else {
this.setActiveIndex(this.activeIndex + 1);
}
return;
}
if (ev.key === "ArrowUp") {
ev.preventDefault();
if (!this.listbox.hidden) {
this.setActiveIndex(this.activeIndex - 1);
}
return;
}
if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) {
ev.preventDefault();
this.chooseIndex(this.activeIndex);
}
}
scheduleSearch() {
window.clearTimeout(this.searchTimer);
this.searchTimer = window.setTimeout(() => this.search(), 150);
}
async search() {
var query = this.input.value.trim();
var initial = !query && this.hasAttribute("suggest-on-focus");
if (!query && !initial) {
this.close();
this.status.textContent = "";
return;
}
var src = this.getAttribute("src");
if (!src) {
return;
}
var url = new URL(src, location.href);
url.searchParams.set("q", query);
if (initial) {
url.searchParams.set("_initial", "1");
} else {
url.searchParams.delete("_initial");
}
var fetchId = this.fetchId + 1;
this.fetchId = fetchId;
this.status.textContent = "Searching...";
try {
var response = await fetch(url.toString(), {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
var data = await response.json();
if (fetchId !== this.fetchId) {
return;
}
this.results = (data && data.rows) || [];
this.render();
} catch (_error) {
if (fetchId !== this.fetchId) {
return;
}
this.results = [];
this.close();
this.status.textContent = "Could not load suggestions";
}
}
render() {
this.listbox.textContent = "";
this.activeIndex = -1;
if (!this.results.length) {
this.close();
this.status.textContent = "No matches";
return;
}
this.results.forEach((row, index) => {
var option = document.createElement("div");
option.className = "datasette-autocomplete-option";
option.id = this.input.id + "-option-" + index;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", "false");
option.dataset.index = String(index);
option.dataset.value = autocompleteValueFromRow(row);
option.textContent = autocompleteLabelFromRow(row);
option.addEventListener("mousedown", (ev) => {
ev.preventDefault();
this.chooseIndex(index);
});
this.listbox.appendChild(option);
});
this.listbox.hidden = false;
this.input.setAttribute("aria-expanded", "true");
this.status.textContent =
this.results.length + (this.results.length === 1 ? " match" : " matches");
this.positionListbox();
this.setActiveIndex(0);
}
positionListbox() {
if (!this.input || !this.listbox || this.listbox.hidden) {
return;
}
var gap = 3;
var margin = 8;
var inputRect = this.input.getBoundingClientRect();
this.listbox.style.maxHeight = "";
var defaultMaxHeight = parseFloat(
window.getComputedStyle(this.listbox).maxHeight,
);
if (!Number.isFinite(defaultMaxHeight)) {
defaultMaxHeight = 256;
}
var scrollHeight = Math.ceil(this.listbox.scrollHeight);
var desiredHeight = Math.min(scrollHeight, defaultMaxHeight);
var availableBelow = Math.max(
0,
(window.innerHeight || document.documentElement.clientHeight) -
inputRect.bottom -
gap -
margin,
);
this.listbox.style.left = inputRect.left + "px";
this.listbox.style.top = inputRect.bottom + gap + "px";
this.listbox.style.width = inputRect.width + "px";
if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) {
this.listbox.style.maxHeight = "none";
} else {
this.listbox.style.maxHeight =
Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) +
"px";
}
window.addEventListener("resize", this.boundPositionListbox);
document.addEventListener("scroll", this.boundPositionListbox, true);
}
setActiveIndex(index) {
var options = this.listbox.querySelectorAll("[role='option']");
if (!options.length) {
this.activeIndex = -1;
this.input.removeAttribute("aria-activedescendant");
return;
}
if (index < 0) {
index = options.length - 1;
}
if (index >= options.length) {
index = 0;
}
options.forEach((option, optionIndex) => {
option.setAttribute(
"aria-selected",
optionIndex === index ? "true" : "false",
);
});
this.activeIndex = index;
this.input.setAttribute("aria-activedescendant", options[index].id);
}
chooseIndex(index) {
var row = this.results[index];
if (!row) {
return;
}
var value = autocompleteValueFromRow(row);
var label = autocompleteLabelFromRow(row);
this.input.value = value;
this.input.dispatchEvent(new Event("change", { bubbles: true }));
this.close();
this.status.textContent = "Selected " + label;
this.dispatchEvent(
new CustomEvent("datasette-autocomplete-select", {
bubbles: true,
detail: {
row: row,
value: value,
label: label,
},
}),
);
}
close() {
if (this.listbox) {
this.listbox.hidden = true;
this.listbox.textContent = "";
this.listbox.style.left = "";
this.listbox.style.maxHeight = "";
this.listbox.style.top = "";
this.listbox.style.width = "";
}
if (this.input) {
this.input.setAttribute("aria-expanded", "false");
this.input.removeAttribute("aria-activedescendant");
}
window.removeEventListener("resize", this.boundPositionListbox);
document.removeEventListener("scroll", this.boundPositionListbox, true);
this.activeIndex = -1;
}
}
customElements.define("datasette-autocomplete", DatasetteAutocomplete);
})();

View file

@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {
<style> <style>
:host { :host {
--ink: #0f0f0f; --ink: #0f0f0f;
--paper: #eef6ff; --paper: #f5f3ef;
--muted: #6b6b6b; --muted: #6b6b6b;
--rule: #d8e6f5; --rule: #e2dfd8;
--accent: #1a56db; --accent: #1a56db;
--accent-light: #e8effd; --accent-light: #e8effd;
--card: #ffffff; --card: #ffffff;

View file

@ -82,35 +82,6 @@ const datasetteManager = {
return columnActions; return columnActions;
}, },
/**
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
* for specific Datasette column types.
*
* The first plugin to return a control object wins. Returning null or
* undefined means "I do not handle this field".
*/
makeColumnField: (context) => {
for (const [pluginName, plugin] of datasetteManager.plugins) {
if (!plugin.makeColumnField) {
continue;
}
let control = null;
try {
control = plugin.makeColumnField(context);
} catch (error) {
console.error(
`Error in makeColumnField() for plugin ${pluginName}`,
error,
);
continue;
}
if (control) {
return Object.assign({ pluginName }, control);
}
}
return null;
},
makeJumpSections: (context) => { makeJumpSections: (context) => {
let jumpSections = []; let jumpSections = [];

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,7 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog"; var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
var setColumnTypeDialogState = null; var setColumnTypeDialogState = null;
function getParams() { function getParams() {
return new URLSearchParams(location.search); return new URLSearchParams(location.search);
} }

View file

@ -1,581 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Iterable
from .utils import tilde_encode, urlsafe_components
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_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,
)

View file

@ -15,22 +15,14 @@
<div class="hook"></div> <div class="hook"></div>
<ul role="menu"> <ul role="menu">
{% for link in action_links %} {% for link in action_links %}
<li role="none"> <li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
{% if link.get("type") == "button" %} {% if link.description %}
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }} <p class="dropdown-description">{{ link.description }}</p>
{% if link.description %} {% endif %}</a>
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</button>
{% else %}
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
{% if link.description %}
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</a>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</details> </details>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,111 +0,0 @@
<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

@ -1,41 +0,0 @@
<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

@ -12,9 +12,9 @@
<ul class="tight-bullets"> <ul class="tight-bullets">
{% for facet_value in facet_info.results %} {% for facet_value in facet_info.results %}
{% if not facet_value.selected %} {% if not facet_value.selected %}
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span></li> <li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
{% else %} {% else %}
<li>{{ facet_value.label or "-" }} &middot; <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span> <a href="{{ facet_value.toggle_url }}" class="cross">&#x2716;</a></li> <li>{{ facet_value.label or "-" }} &middot; {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">&#x2716;</a></li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if facet_info.truncated %} {% if facet_info.truncated %}

View file

@ -1,138 +0,0 @@
<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-url-static {
color: #39445a;
font-family: var(--font-monospace, monospace);
word-break: break-all;
}
.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-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>

View file

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

View file

@ -1,307 +0,0 @@
<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.dataset.parameterName,
controlState(control)
);
});
}
function createControl(parameter, id, state, namePrefix) {
const control = document.createElement(state.expanded ? "textarea" : "input");
control.id = id;
control.name = `${namePrefix || ""}${parameter}`;
control.value = state.value;
control.setAttribute("data-parameter-control", "");
control.dataset.parameterName = parameter;
if (state.expanded) {
control.rows = 5;
} else {
control.type = "text";
}
return control;
}
function replaceParameterControl(
manager,
control,
button,
expand,
value,
selectionStart
) {
const parameter = control.dataset.parameterName;
const replacement = createControl(
parameter,
control.id,
{
value: value === undefined ? control.value : value,
expanded: expand,
},
manager.namePrefix
);
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(parameter, 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, manager.namePrefix);
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.dataset.parameterName,
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,
namePrefix: section ? section.dataset.parameterNamePrefix || "" : "",
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

@ -1,58 +0,0 @@
<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

@ -1,10 +0,0 @@
{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %}
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameter_name_prefix %} data-parameter-name-prefix="{{ sql_parameter_name_prefix }}"{% endif %}{% 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="{{ sql_parameter_name_prefix }}{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control data-parameter-name="{{ parameter }}">{% 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

@ -22,7 +22,7 @@
</thead> </thead>
<tbody> <tbody>
{% for row in display_rows %} {% for row in display_rows %}
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}> <tr>
{% for cell in row %} {% for cell in row %}
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td> <td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
{% endfor %} {% endfor %}

View file

@ -19,7 +19,7 @@
</p> </p>
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em"> <details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
<summary style="cursor: pointer;">GET</summary> <summary style="cursor: pointer;">GET</summary>
<form class="core" method="get" action="{{ urls.path('-/api') }}" id="api-explorer-get" style="margin-top: 0.7em"> <form class="core" method="get" id="api-explorer-get" style="margin-top: 0.7em">
<div> <div>
<label for="path">API path:</label> <label for="path">API path:</label>
<input type="text" id="path" name="path" style="width: 60%"> <input type="text" id="path" name="path" style="width: 60%">
@ -29,7 +29,7 @@
</details> </details>
<details style="border: 2px solid #ccc; padding: 0.5em"> <details style="border: 2px solid #ccc; padding: 0.5em">
<summary style="cursor: pointer">POST</summary> <summary style="cursor: pointer">POST</summary>
<form class="core" method="post" action="{{ urls.path('-/api') }}" id="api-explorer-post" style="margin-top: 0.7em"> <form class="core" method="post" id="api-explorer-post" style="margin-top: 0.7em">
<div> <div>
<label for="path">API path:</label> <label for="path">API path:</label>
<input type="text" id="path" name="path" style="width: 60%"> <input type="text" id="path" name="path" style="width: 60%">

View file

@ -71,6 +71,6 @@
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %} {% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}" defer></script> <script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search> <navigation-search url="/-/jump"></navigation-search>
</body> </body>
</html> </html>

View file

@ -5,7 +5,6 @@
{% block extra_head %} {% block extra_head %}
{{- super() -}} {{- super() -}}
{% include "_codemirror.html" %} {% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %} {% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
@ -26,13 +25,9 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if allow_execute_sql %} {% if allow_execute_sql %}
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters"> <form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
<h3>Custom SQL query</h3> <h3>Custom SQL query</h3>
<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> <p><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> <p>
<button id="sql-format" type="button" hidden>Format SQL</button> <button id="sql-format" type="button" hidden>Format SQL</button>
<input type="submit" value="Run SQL"> <input type="submit" value="Run SQL">
@ -58,9 +53,6 @@
<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> <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 %} {% endfor %}
</ul> </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 %} {% endif %}
{% if tables %} {% if tables %}
@ -95,11 +87,5 @@
{% endif %} {% endif %}
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %} {% endblock %}

View file

@ -1,78 +0,0 @@
{% extends "base.html" %}
{% block title %}Debug autocomplete{% endblock %}
{% block extra_head %}
{{ super() }}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endblock %}
{% block content %}
<h1>Debug autocomplete</h1>
<form class="core debug-autocomplete-form" action="{{ urls.path('-/debug/autocomplete') }}" method="get">
<p>
<label for="debug-autocomplete-database">Database</label>
<input id="debug-autocomplete-database" type="text" name="database" value="{{ database_name or "" }}">
</p>
<p>
<label for="debug-autocomplete-table">Table</label>
<input id="debug-autocomplete-table" type="text" name="table" value="{{ table_name or "" }}">
</p>
<p><input type="submit" value="Open autocomplete"></p>
</form>
{% if error %}
<p class="message-error">{{ error }}</p>
{% elif autocomplete_url %}
<h2>{{ database_name }} / {{ table_name }}</h2>
{% if label_column %}
<p>Label column: <code>{{ label_column }}</code></p>
{% else %}
<p>No label column detected. Results will use primary key values.</p>
{% endif %}
<div class="debug-autocomplete-demo">
<label for="debug-autocomplete-input">Search rows</label>
<datasette-autocomplete src="{{ autocomplete_url }}">
<input id="debug-autocomplete-input" type="text">
</datasette-autocomplete>
</div>
<h3>Selected row</h3>
<pre class="debug-autocomplete-selected" aria-live="polite">No row selected.</pre>
<script>
document.addEventListener("datasette-autocomplete-select", function (event) {
var output = document.querySelector(".debug-autocomplete-selected");
if (output) {
output.textContent = JSON.stringify(event.detail.row, null, 2);
}
});
</script>
{% else %}
<h2>Suggested tables</h2>
{% if suggestions %}
<p>Showing up to five tables with a detected label column.</p>
<table class="rows-and-columns">
<thead>
<tr>
<th>Database</th>
<th>Table</th>
<th>Label column</th>
</tr>
</thead>
<tbody>
{% for suggestion in suggestions %}
<tr>
<td>{{ suggestion.database }}</td>
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
<td><code>{{ suggestion.label_column }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No tables with detected label columns found.</p>
{% endif %}
<p>Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.</p>
{% endif %}
{% endblock %}

View file

@ -1,299 +0,0 @@
{% 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;
}
.execute-write-submit-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.75rem;
}
.execute-write-submit-row [hidden] {
display: none;
}
form.sql.core input[data-execute-write-submit]:disabled {
background: #d0d7de;
border-color: #b6c0cc;
color: #5f6975;
cursor: not-allowed;
opacity: 1;
}
.execute-write-disabled-reason {
color: #4f5b6d;
font-size: 0.85rem;
}
</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 %}
{% if execute_write_returns_rows %}
<h2>Returned rows</h2>
{% if execute_write_truncated %}
<p class="message-warning">Only the first {{ "{:,}".format(execute_write_display_rows|length) }} returned rows are shown.</p>
{% endif %}
{% set columns = execute_write_columns %}
{% set display_rows = execute_write_display_rows %}
{% set show_zero_results = true %}
{% include "_query_results.html" %}
{% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
{% if write_template_tables %}
<div class="execute-write-template-menu">
<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, table in write_template_tables|dictsort %}
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
{% endfor %}
</select>
{% for operation in write_template_operations %}
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
{% endfor %}
</p>
</details>
</div>
{% else %}
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
{% 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 class="execute-write-submit-row"{% if save_query_base_url %} data-save-query-base-url="{{ save_query_base_url }}"{% endif %}>
<input type="submit" value="Execute" data-execute-write-submit aria-describedby="execute-write-disabled-reason"{% if execute_disabled %} disabled{% endif %}>
<span id="execute-write-disabled-reason" class="execute-write-disabled-reason" data-execute-write-disabled-reason aria-live="polite"{% if not execute_disabled_reason %} hidden{% endif %}>{{ execute_disabled_reason or "" }}</span>
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query" data-save-query-link>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 submitDisabledReason = form
? form.querySelector("[data-execute-write-disabled-reason]")
: null;
const submitRow = form
? form.querySelector(".execute-write-submit-row")
: null;
let saveQueryLink = form
? form.querySelector("[data-save-query-link]")
: null;
function updateSubmitState(data) {
if (submitButton) {
submitButton.disabled = data.execute_disabled;
}
if (!submitDisabledReason) {
return;
}
const reason = data.execute_disabled_reason || "";
submitDisabledReason.textContent = reason;
submitDisabledReason.hidden = !reason;
}
function updateSaveQueryLink(data) {
if (!submitRow || !submitRow.dataset.saveQueryBaseUrl) {
return;
}
const sql = window.editor
? window.editor.state.doc.toString()
: executeWriteSqlInput.value;
if (!sql.trim() || !data.ok || data.execute_disabled) {
if (saveQueryLink) {
saveQueryLink.remove();
saveQueryLink = null;
}
return;
}
if (!saveQueryLink) {
saveQueryLink = document.createElement("a");
saveQueryLink.className = "save-query";
saveQueryLink.setAttribute("data-save-query-link", "");
saveQueryLink.textContent = "Save this query";
submitRow.appendChild(saveQueryLink);
}
const url = new URL(
submitRow.dataset.saveQueryBaseUrl,
window.location.href
);
url.searchParams.set("sql", sql);
saveQueryLink.href = url.pathname + url.search + url.hash;
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
allowExpand: true,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
updateSubmitState(data);
updateSaveQueryLink(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
updateSubmitState({
execute_disabled: true,
execute_disabled_reason: error.message,
});
updateSaveQueryLink({ ok: false, execute_disabled: true });
},
});
});
</script>
{% if write_template_tables %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const tableSelect = document.querySelector("#execute-write-template-table");
const templateButtons = document.querySelectorAll("[data-sql-template]");
function dataKey(operation) {
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
}
function selectedOption() {
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
}
function templateSql(operation) {
const option = selectedOption();
return option ? option.dataset[dataKey(operation)] || "" : "";
}
function updateTemplateButtons() {
templateButtons.forEach((button) => {
button.hidden = !templateSql(button.dataset.sqlTemplate);
});
}
templateButtons.forEach((button) => {
button.addEventListener("click", () => {
const sql = templateSql(button.dataset.sqlTemplate);
if (!sql) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.location.href = url.toString();
});
});
if (tableSelect) {
tableSelect.addEventListener("change", updateTemplateButtons);
}
updateTemplateButtons();
});
</script>
{% endif %}
{% endblock %}

View file

@ -11,7 +11,7 @@
<header class="hd"><nav> <header class="hd"><nav>
<p class="crumbs"> <p class="crumbs">
<a href="{{ base_url }}">home</a> <a href="/">home</a>
</p> </p>
<details class="nav-menu details-menu"> <details class="nav-menu details-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img" <summary><svg aria-labelledby="nav-menu-svg-title" role="img"
@ -22,11 +22,11 @@
</svg></summary> </svg></summary>
<div class="nav-menu-inner"> <div class="nav-menu-inner">
<ul> <ul>
<li><a href="{{ base_url }}-/databases">Databases</a></li> <li><a href="/-/databases">Databases</a></li>
<li><a href="{{ base_url }}-/plugins">Installed plugins</a></li> <li><a href="/-/plugins">Installed plugins</a></li>
<li><a href="{{ base_url }}-/versions">Version info</a></li> <li><a href="/-/versions">Version info</a></li>
</ul> </ul>
<form class="nav-menu-logout" action="{{ base_url }}-/logout" method="post"> <form class="nav-menu-logout" action="/-/logout" method="post">
<button class="button-as-link">Log out</button> <button class="button-as-link">Log out</button>
</form> </form>
</div> </div>
@ -48,9 +48,9 @@
<header class="hd"> <header class="hd">
<nav> <nav>
<p class="crumbs"> <p class="crumbs">
<a href="{{ base_url }}">home</a> / <a href="/">home</a> /
<a href="{{ base_url }}fixtures">fixtures</a> / <a href="/fixtures">fixtures</a> /
<a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a> <a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
</p> </p>
<div class="actor"> <div class="actor">
<strong>testuser</strong> <strong>testuser</strong>
@ -80,16 +80,16 @@
<a href="https://github.com/simonw/datasette"> <a href="https://github.com/simonw/datasette">
About Datasette</a> About Datasette</a>
</p> </p>
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="{{ base_url }}fixtures">fixtures</a></h2> <h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="/fixtures">fixtures</a></h2>
<p> <p>
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views 1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
</p> </p>
<p><a href="{{ base_url }}fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="{{ base_url }}fixtures/sortable" title="201 rows">sortable</a>, <a href="{{ base_url }}fixtures/facetable" title="15 rows">facetable</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="{{ base_url }}fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="{{ base_url }}fixtures">...</a></p> <p><a href="/fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="/fixtures/sortable" title="201 rows">sortable</a>, <a href="/fixtures/facetable" title="15 rows">facetable</a>, <a href="/fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="/fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="/fixtures">...</a></p>
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="{{ base_url }}data">data</a></h2> <h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="/data">data</a></h2>
<p> <p>
6 rows in 2 tables 6 rows in 2 tables
</p> </p>
<p><a href="{{ base_url }}data/names" title="6 rows">names</a>, <a href="{{ base_url }}data/foo">foo</a></p> <p><a href="/data/names" title="6 rows">names</a>, <a href="/data/foo">foo</a></p>
</section> </section>
<h2 class="pattern-heading">.bd for /database</h2> <h2 class="pattern-heading">.bd for /database</h2>
@ -134,7 +134,7 @@
<a href="https://github.com/simonw/datasette"> <a href="https://github.com/simonw/datasette">
About Datasette</a> About Datasette</a>
</p> </p>
<form class="sql" action="{{ base_url }}fixtures" method="get"> <form class="sql" action="/fixtures" method="get">
<h3>Custom SQL query</h3> <h3>Custom SQL query</h3>
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p> <p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
<p> <p>
@ -143,17 +143,17 @@
</p> </p>
</form> </form>
<div class="db-table"> <div class="db-table">
<h2><a href="{{ base_url }}fixtures/123_starts_with_digits">123_starts_with_digits</a></h2> <h2><a href="/fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
<p><em>content</em></p> <p><em>content</em></p>
<p>0 rows</p> <p>0 rows</p>
</div> </div>
<div class="db-table"> <div class="db-table">
<h2><a href="{{ base_url }}fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2> <h2><a href="/fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
<p><em>pk, content</em></p> <p><em>pk, content</em></p>
<p>0 rows</p> <p>0 rows</p>
</div> </div>
<div class="db-table"> <div class="db-table">
<h2><a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a></h2> <h2><a href="/fixtures/attraction_characteristic">attraction_characteristic</a></h2>
<p><em>pk, name</em></p> <p><em>pk, name</em></p>
<p>2 rows</p> <p>2 rows</p>
</div> </div>
@ -202,7 +202,7 @@
<h3>3 rows <h3>3 rows
where characteristic_id = 2 where characteristic_id = 2
</h3> </h3>
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get"> <form class="filters" action="/fixtures/roadside_attraction_characteristics" method="get">
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div> <div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
<div class="filter-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
@ -290,16 +290,16 @@
<h3>2 extra where clauses</h3> <h3>2 extra where clauses</h3>
<ul> <ul>
<li><code>planet_int=1</code> [<a href="{{ base_url }}fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li> <li><code>planet_int=1</code> [<a href="/fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
<li><code>state='CA'</code> [<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1">remove</a>]</li> <li><code>state='CA'</code> [<a href="/fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
</ul> </ul>
</div> </div>
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where &#34;characteristic_id&#34; = :p0 order by rowid limit 101" href="{{ base_url }}fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&amp;p0=2">&#x270e; <span class="underlined">View and edit SQL</span></a></p> <p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where &#34;characteristic_id&#34; = :p0 order by rowid limit 101" href="/fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&amp;p0=2">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
<p class="export-links">This data as <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">json</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&amp;_labels=on&amp;_size=max">CSV</a> (<a href="#export">advanced</a>)</p> <p class="export-links">This data as <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">json</a>, <a href="/fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&amp;_labels=on&amp;_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
<p class="suggested-facets"> <p class="suggested-facets">
Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_array=tags#facet-tags">tags</a> (array) Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_array=tags#facet-tags">tags</a> (array)
@ -311,7 +311,7 @@
<p class="facet-info-name"> <p class="facet-info-name">
<strong>tags (array)</strong> <strong>tags (array)</strong>
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created" class="cross"></a> <a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created" class="cross"></a>
</p> </p>
<ul> <ul>
@ -336,7 +336,7 @@
<p class="facet-info-name"> <p class="facet-info-name">
<strong>created</strong> <strong>created</strong>
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet_array=tags" class="cross"></a> <a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet_array=tags" class="cross"></a>
</p> </p>
<ul> <ul>
@ -361,7 +361,7 @@
<p class="facet-info-name"> <p class="facet-info-name">
<strong>city_id</strong> <strong>city_id</strong>
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=created&amp;_facet_array=tags" class="cross"></a> <a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=created&amp;_facet_array=tags" class="cross"></a>
</p> </p>
<ul> <ul>
@ -387,45 +387,45 @@
Link Link
</th> </th>
<th class="col-rowid" scope="col"> <th class="col-rowid" scope="col">
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort_desc=rowid" rel="nofollow">rowid&nbsp;</a> <a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort_desc=rowid" rel="nofollow">rowid&nbsp;</a>
</th> </th>
<th class="col-attraction_id" scope="col"> <th class="col-attraction_id" scope="col">
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=attraction_id" rel="nofollow">attraction_id</a> <a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=attraction_id" rel="nofollow">attraction_id</a>
</th> </th>
<th class="col-characteristic_id" scope="col"> <th class="col-characteristic_id" scope="col">
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=characteristic_id" rel="nofollow">characteristic_id</a> <a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=characteristic_id" rel="nofollow">characteristic_id</a>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/1">1</a></td> <td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/1">1</a></td>
<td class="col-rowid">1</td> <td class="col-rowid">1</td>
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/1">The Mystery Spot</a>&nbsp;<em>1</em></td> <td class="col-attraction_id"><a href="/fixtures/roadside_attractions/1">The Mystery Spot</a>&nbsp;<em>1</em></td>
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td> <td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr> </tr>
<tr> <tr>
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/2">2</a></td> <td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/2">2</a></td>
<td class="col-rowid">2</td> <td class="col-rowid">2</td>
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/2">Winchester Mystery House</a>&nbsp;<em>2</em></td> <td class="col-attraction_id"><a href="/fixtures/roadside_attractions/2">Winchester Mystery House</a>&nbsp;<em>2</em></td>
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td> <td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr> </tr>
<tr> <tr>
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/3">3</a></td> <td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/3">3</a></td>
<td class="col-rowid">3</td> <td class="col-rowid">3</td>
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a>&nbsp;<em>4</em></td> <td class="col-attraction_id"><a href="/fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a>&nbsp;<em>4</em></td>
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td> <td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div id="export" class="advanced-export"> <div id="export" class="advanced-export">
<h3>Advanced export</h3> <h3>Advanced export</h3>
<p>JSON shape: <p>JSON shape:
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">default</a>, <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">default</a>,
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array">array</a>, <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array">array</a>,
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array&amp;_nl=on">newline-delimited</a> <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array&amp;_nl=on">newline-delimited</a>
</p> </p>
<form action="{{ base_url }}fixtures/roadside_attraction_characteristics.csv" method="get"> <form action="/fixtures/roadside_attraction_characteristics.csv" method="get">
<p> <p>
CSV options: CSV options:
<label><input type="checkbox" name="_dl"> download file</label> <label><input type="checkbox" name="_dl"> download file</label>
@ -445,7 +445,7 @@
<h2 class="pattern-heading">.bd for /database/table/row</h2> <h2 class="pattern-heading">.bd for /database/table/row</h2>
<section class="content"> <section class="content">
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1> <h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
<p>This data as <a href="{{ base_url }}fixtures/roadside_attractions/2.json">json</a></p> <p>This data as <a href="/fixtures/roadside_attractions/2.json">json</a></p>
<table class="rows-and-columns"> <table class="rows-and-columns">
<thead> <thead>
<tr> <tr>
@ -479,7 +479,7 @@
<h2>Links from other tables</h2> <h2>Links from other tables</h2>
<ul> <ul>
<li> <li>
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?attraction_id=2"> <a href="/fixtures/roadside_attraction_characteristics?attraction_id=2">
1 row</a> 1 row</a>
from attraction_id in roadside_attraction_characteristics from attraction_id in roadside_attraction_characteristics
</li> </li>

View file

@ -14,10 +14,9 @@
</style> </style>
{% endif %} {% endif %}
{% include "_codemirror.html" %} {% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %} {% endblock %}
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %} {% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
{% block crumbs %} {% block crumbs %}
{{ crumbs.nav(request=request, database=database) }} {{ crumbs.nav(request=request, database=database) }}
@ -25,19 +24,19 @@
{% block content %} {% block content %}
{% if stored_query_write and db_is_immutable %} {% if canned_query_write and db_is_immutable %}
<p class="message-error">This query cannot be executed because the database is immutable.</p> <p class="message-error">This query cannot be executed because the database is immutable.</p>
{% endif %} {% endif %}
<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> <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>
{% set action_links, action_title = query_actions(), "Query actions" %} {% set action_links, action_title = query_actions(), "Query actions" %}
{% include "_action_menu.html" %} {% include "_action_menu.html" %}
{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} {% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<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"> <form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
<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 %} <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> <span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
{% endif %}</h3> {% endif %}</h3>
@ -46,43 +45,56 @@
{% endif %} {% endif %}
{% if not hide_sql %} {% if not hide_sql %}
{% if editable and allow_execute_sql %} {% if editable and allow_execute_sql %}
<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 %} <p><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> >{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
{% else %} {% else %}
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre> <pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
{% endif %} {% endif %}
{% else %} {% else %}
{% if not stored_query %} {% if not canned_query %}
<input type="hidden" name="sql" <input type="hidden" name="sql"
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}" value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
> >
{% endif %} {% endif %}
{% endif %} {% endif %}
{% set parameter_names = named_parameter_values.keys()|list %} {% if named_parameter_values %}
{% set parameter_values = named_parameter_values %} <h3>Query parameters</h3>
{% set sql_parameters_allow_expand = false %} {% for name, value in named_parameter_values.items() %}
{% include "_sql_parameters.html" %} <p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
{% endfor %}
{% endif %}
<p> <p>
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %} {% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}> <input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
{{ show_hide_hidden }} {{ show_hide_hidden }}
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %} {% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
</p> </p>
</form> </form>
{% if display_rows %} {% if display_rows %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p> <p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
<div class="table-wrapper"><table class="rows-and-columns">
<thead>
<tr>
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for column, td in zip(columns, row) %}
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
{% if not canned_query_write and not error %}
<p class="zero-results">0 results</p>
{% endif %}
{% endif %} {% endif %}
{% set show_zero_results = not stored_query_write and not error %}
{% include "_query_results.html" %}
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %} {% endblock %}

View file

@ -1,163 +0,0 @@
{% extends "base.html" %}
{% block title %}Create query{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
{% include "_query_form_styles.html" %}
{% 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>
<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

@ -1,82 +0,0 @@
{% extends "base.html" %}
{% block title %}Delete query: {{ query.name }}{% endblock %}
{% block extra_head %}
{{- super() -}}
<style>
.query-delete-page {
max-width: 48rem;
}
.query-delete-summary {
background-color: #f7f7f9;
border: 1px solid #d7dde5;
border-radius: 4px;
margin: 0.75rem 0 1.25rem;
padding: 0.75rem 1rem;
}
.query-delete-summary dt {
color: #4f5b6d;
font-size: 0.82rem;
font-weight: 700;
}
.query-delete-summary dd {
margin: 0 0 0.6rem;
}
.query-delete-summary dd pre {
margin: 0.2rem 0 0;
white-space: pre-wrap;
}
.query-delete-actions {
align-items: center;
display: flex;
gap: 1rem;
}
.query-delete-form input[type=submit] {
background: linear-gradient(180deg, #d73a31 0%, #b42318 100%);
border-color: #b42318;
font-weight: 700;
}
.query-delete-form input[type=submit]:hover,
.query-delete-form input[type=submit]:focus {
background: linear-gradient(180deg, #c3342b 0%, #971c14 100%);
border-color: #971c14;
}
</style>
{% endblock %}
{% block body_class %}query-delete db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-delete-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Delete query: {{ query.title or query.name }}</h1>
<p>Are you sure you want to delete this saved query? This cannot be undone.</p>
<dl class="query-delete-summary">
<dt>URL</dt>
<dd><a href="{{ query_url }}">{{ query_url }}</a></dd>
{% if query.description %}
<dt>Description</dt>
<dd>{{ query.description }}</dd>
{% endif %}
<dt>SQL</dt>
<dd><pre>{{ query.sql }}</pre></dd>
</dl>
<form class="core query-delete-form" action="{{ query_url }}/-/delete" method="post">
<p class="query-delete-actions">
<input type="submit" value="Delete query">
<a href="{{ query_url }}">Cancel</a>
</p>
</form>
</div>
{% endblock %}

View file

@ -1,133 +0,0 @@
{% extends "base.html" %}
{% block title %}Edit query: {{ name }}{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
{% include "_query_form_styles.html" %}
{% endblock %}
{% block body_class %}query-edit 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 }}">Edit query: {{ title or name }}</h1>
<form class="sql core query-create-form" action="{{ query_url }}/-/edit" 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>URL</label> <span class="query-create-url-static">{{ query_url }}</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 and edited by you, their owner.</span>
</p>
<p class="query-create-submit"><input type="submit" value="Save changes" data-query-create-submit{% if save_disabled %} disabled{% endif %}> <a href="{{ query_url }}">Cancel</a></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 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

@ -1,281 +0,0 @@
{% 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>
{% if queries %}
<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>
<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

@ -4,13 +4,6 @@
{% block extra_head %} {% block extra_head %}
{{- super() -}} {{- super() -}}
{% if row_mutation_ui %}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
{% endif %}
<style> <style>
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {
{% for column in columns %} {% for column in columns %}

View file

@ -4,13 +4,8 @@
{% block extra_head %} {% block extra_head %}
{{- super() -}} {{- super() -}}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
<script src="{{ urls.static('column-chooser.js') }}" defer></script> <script src="{{ urls.static('column-chooser.js') }}" defer></script>
{% if table_page_data.foreignKeys %} <script src="{{ urls.static('table.js') }}" defer></script>
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script> <script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script> <script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style> <style>
@ -163,19 +158,6 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
</script> </script>
{% endif %} {% endif %}
{% if table_insert_ui %}
<div class="table-row-toolbar">
<button type="button" class="core table-insert-row" data-table-action="insert-row">
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M8 12h8"></path>
<path d="M12 8v8"></path>
</svg>
<span>Insert row</span>
</button>
</div>
{% endif %}
{% include custom_table_templates %} {% include custom_table_templates %}
{% if next_url %} {% if next_url %}

View file

@ -27,10 +27,8 @@ def get_task_id():
@contextmanager @contextmanager
def trace_child_tasks(): def trace_child_tasks():
token = trace_task_id.set(get_task_id()) token = trace_task_id.set(get_task_id())
try: yield
yield trace_task_id.reset(token)
finally:
trace_task_id.reset(token)
@contextmanager @contextmanager

View file

@ -410,10 +410,6 @@ def escape_css_string(s):
def escape_sqlite(s): def escape_sqlite(s):
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words): if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
return s return s
elif "]" in s:
# SQLite does not support escaping ] inside [bracket] quoting, so fall
# back to double-quote quoting (doubling any embedded ") - #2677
return '"{}"'.format(s.replace('"', '""'))
else: else:
return f"[{s}]" return f"[{s}]"
@ -841,8 +837,7 @@ def path_with_format(
*, request=None, path=None, format=None, extra_qs=None, replace_format=None *, request=None, path=None, format=None, extra_qs=None, replace_format=None
): ):
qs = extra_qs or {} qs = extra_qs or {}
if path is None and request: path = request.path if request else path
path = request.path
if replace_format and path.endswith(f".{replace_format}"): if replace_format and path.endswith(f".{replace_format}"):
path = path[: -(1 + len(replace_format))] path = path[: -(1 + len(replace_format))]
if "." in path: if "." in path:

View file

@ -21,8 +21,6 @@ The core pattern is:
- Across levels, child beats parent beats global - Across levels, child beats parent beats global
""" """
import asyncio
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from datasette.utils.permissions import gather_permission_sql_from_hooks from datasette.utils.permissions import gather_permission_sql_from_hooks
@ -243,14 +241,6 @@ 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 # Continue with the cascading logic
query_parts.extend( query_parts.extend(
@ -497,153 +487,6 @@ async def build_permission_rules_sql(
return rules_union, all_params, restriction_sqls return rules_union, all_params, restriction_sqls
async def check_permissions_for_actions(
*,
datasette: "Datasette",
actor: dict | None,
actions: list[str],
parent: str | None,
child: str | None,
) -> dict[str, bool]:
"""
Check several actions for one actor and resource in a single query.
Args:
datasette: The Datasette instance
actor: The actor dict (or None)
actions: List of action names to check
parent: The parent resource identifier (e.g., database name, or None)
child: The child resource identifier (e.g., table name, or None)
Returns:
Dict mapping each action name to True (allowed) or False (denied)
Each action contributes its own tagged block of permission rules
(gathered from the permission_resources_sql hook, with parameters
namespaced per action to avoid collisions) plus an optional
restriction allowlist CTE. One internal database query resolves
the winning rule per action using the same specificity-then-deny
ordering as the rest of the permission system.
Note: this resolves each action independently - also_requires
dependencies are handled by the caller (Datasette.allowed_many).
"""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
for action in actions:
if not datasette.actions.get(action):
raise ValueError(f"Unknown action: {action}")
# Dedupe while preserving order
unique_actions = list(dict.fromkeys(actions))
if not unique_actions:
return {}
# Gather hook results for each action concurrently - hooks within a
# single action still run sequentially, preserving existing semantics
gathered = await asyncio.gather(
*(
gather_permission_sql_from_hooks(
datasette=datasette, actor=actor, action=action
)
for action in unique_actions
)
)
if any(result is SKIP_PERMISSION_CHECKS for result in gathered):
return {action: True for action in unique_actions}
params = {"_check_parent": parent, "_check_child": child}
ctes = []
result_rows = []
verdicts = {}
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
prefix = f"a{i}_"
rule_parts = []
restriction_parts = []
for permission_sql in permission_sqls:
sql = permission_sql.sql
restriction_sql = permission_sql.restriction_sql
# Namespace this block's params so identical names used for
# different actions cannot collide
for key in permission_sql.params or {}:
new_key = prefix + key
params[new_key] = permission_sql.params[key]
pattern = re.compile(":" + re.escape(key) + r"(?![A-Za-z0-9_])")
if sql:
sql = pattern.sub(":" + new_key, sql)
if restriction_sql:
restriction_sql = pattern.sub(":" + new_key, restriction_sql)
if restriction_sql:
restriction_parts.append(restriction_sql)
# Skip plugins that only provide restriction_sql (no permission rules)
if sql is None:
continue
rule_parts.append(
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
)
if not rule_parts:
# No rules from any plugin - default deny. Restrictions can
# only restrict, never grant, so no SQL is needed at all
verdicts[action] = False
continue
ctes.append(f"a{i}_rules AS (\n" + "\nUNION ALL\n".join(rule_parts) + "\n)")
# Winning rule for this action: most specific depth first, then
# deny-beats-allow, then source_plugin as a stable tie-break
verdict_sql = f"""COALESCE((
SELECT allow FROM (
SELECT allow, source_plugin,
CASE
WHEN child IS NOT NULL THEN 2
WHEN parent IS NOT NULL THEN 1
ELSE 0
END AS depth
FROM a{i}_rules
WHERE (parent IS NULL OR parent = :_check_parent)
AND (child IS NULL OR child = :_check_child)
ORDER BY
depth DESC,
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
source_plugin
LIMIT 1
)
), 0)"""
if restriction_parts:
# Database-level restrictions (parent, NULL) match all children
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_parts
)
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
verdict_sql = f"""({verdict_sql}) AND EXISTS (
SELECT 1 FROM a{i}_restriction r
WHERE (r.parent = :_check_parent OR r.parent IS NULL)
AND (r.child = :_check_child OR r.child IS NULL)
)"""
result_rows.append(f"({i}, ({verdict_sql}))")
if result_rows:
ctes.append(
"results(action_idx, is_allowed) AS (VALUES\n"
+ ",\n".join(result_rows)
+ "\n)"
)
query = (
"WITH\n" + ",\n".join(ctes) + "\nSELECT action_idx, is_allowed FROM results"
)
result = await datasette.get_internal_database().execute(query, params)
for row in result.rows:
verdicts[unique_actions[row[0]]] = bool(row[1])
return verdicts
async def check_permission_for_resource( async def check_permission_for_resource(
*, *,
datasette: "Datasette", datasette: "Datasette",
@ -664,12 +507,77 @@ async def check_permission_for_resource(
Returns: Returns:
True if the actor is allowed, False otherwise True if the actor is allowed, False otherwise
This builds the cascading permission query and checks if the specific
resource is in the allowed set.
""" """
results = await check_permissions_for_actions( rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
datasette=datasette, datasette, actor, action
actor=actor,
actions=[action],
parent=parent,
child=child,
) )
return results[action]
# If no rules (empty SQL), default deny
if not rules_union:
return False
# Add parameters for the resource we're checking
all_params["_check_parent"] = parent
all_params["_check_child"] = child
# If there are restriction filters, check if the resource passes them first
if restriction_sqls:
# Check if resource is in restriction allowlist
# Database-level restrictions (parent, NULL) should match all children (parent, *)
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
restriction_check = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_sqls
)
restriction_query = f"""
WITH restriction_list AS (
{restriction_check}
)
SELECT EXISTS (
SELECT 1 FROM restriction_list
WHERE (parent = :_check_parent OR parent IS NULL)
AND (child = :_check_child OR child IS NULL)
) AS in_allowlist
"""
result = await datasette.get_internal_database().execute(
restriction_query, all_params
)
if result.rows and not result.rows[0][0]:
# Resource not in restriction allowlist - deny
return False
query = f"""
WITH
all_rules AS (
{rules_union}
),
matched_rules AS (
SELECT ar.*,
CASE
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
ELSE 0 -- root/global
END AS depth
FROM all_rules ar
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
AND (ar.child IS NULL OR ar.child = :_check_child)
),
winner AS (
SELECT *
FROM matched_rules
ORDER BY
depth DESC, -- specificity first (higher depth wins)
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
source_plugin -- stable tie-break
LIMIT 1
)
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
"""
# Execute the query against the internal database
result = await datasette.get_internal_database().execute(query, all_params)
if result.rows:
return bool(result.rows[0][0])
return False

View file

@ -155,10 +155,6 @@ class Request:
body = await self.post_body() body = await self.post_body()
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True)) return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
async def json(self):
body = await self.post_body()
return json.loads(body)
async def form( async def form(
self, self,
files: bool = False, files: bool = False,
@ -334,11 +330,9 @@ async def asgi_send_html(send, html, status=200, headers=None):
async def asgi_send_redirect(send, location, status=302): async def asgi_send_redirect(send, location, status=302):
# Prevent open redirect vulnerability: collapse leading slashes and # Prevent open redirect vulnerability: strip multiple leading slashes
# backslashes down to a single slash. //example.com is a protocol-relative # //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)
# URL, and browsers normalise backslashes to slashes so /\example.com would location = re.sub(r"^/+", "/", location)
# be treated as //example.com - https://github.com/simonw/datasette/issues/2680
location = re.sub(r"^[/\\]+", "/", location)
await asgi_send( await asgi_send(
send, send,
"", "",

View file

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

@ -1,550 +0,0 @@
from dataclasses import dataclass
from typing import Literal
from datasette.utils.sqlite import SQLiteTableType, sqlite3, sqlite_table_type
SQLOperation = Literal[
"read",
"insert",
"update",
"delete",
"select",
"function",
"create",
"alter",
"drop",
"begin",
"commit",
"rollback",
"savepoint",
"attach",
"detach",
"pragma",
"analyze",
"reindex",
"vacuum",
"unknown",
]
SQLTargetType = Literal[
"table",
"index",
"view",
"trigger",
"virtual-table",
"schema",
"statement",
"transaction",
"database",
"pragma",
"function",
"unknown",
]
SQLTableOperation = Literal["read", "insert", "update", "delete"]
SQLSchemaOperation = Literal["create", "drop"]
SQLSchemaTargetType = Literal["index", "table", "trigger", "view", "virtual-table"]
@dataclass(frozen=True)
class Operation:
operation: SQLOperation
target_type: SQLTargetType
database: str | None
table: str | None
sqlite_schema: str | None
table_kind: SQLiteTableType | None = None
target: str | None = None
columns: tuple[str, ...] = ()
source: str | None = None
internal: bool = False
@dataclass(frozen=True)
class SQLAnalysis:
operations: tuple[Operation, ...]
# Hashable dict key for grouping repeated authorizer callbacks while collecting columns.
@dataclass(frozen=True)
class OperationKey:
operation: SQLOperation
target_type: SQLTargetType
database: str | None
table: str | None
sqlite_schema: str | None
target: str | None
source: str | None
internal: bool
_ACTION_TO_OPERATION: dict[int, SQLTableOperation] = {
sqlite3.SQLITE_READ: "read",
sqlite3.SQLITE_INSERT: "insert",
sqlite3.SQLITE_UPDATE: "update",
sqlite3.SQLITE_DELETE: "delete",
}
# Values are (operation, target_type) pairs used to construct Operation objects.
_CREATE_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
sqlite3.SQLITE_CREATE_INDEX: ("create", "index"),
sqlite3.SQLITE_CREATE_TABLE: ("create", "table"),
sqlite3.SQLITE_CREATE_TRIGGER: ("create", "trigger"),
sqlite3.SQLITE_CREATE_VIEW: ("create", "view"),
}
_DROP_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
sqlite3.SQLITE_DROP_INDEX: ("drop", "index"),
sqlite3.SQLITE_DROP_TABLE: ("drop", "table"),
sqlite3.SQLITE_DROP_TRIGGER: ("drop", "trigger"),
sqlite3.SQLITE_DROP_VIEW: ("drop", "view"),
}
def _add_schema_action(
action_name: str,
operation: SQLSchemaOperation,
target_type: SQLSchemaTargetType,
) -> None:
action_value = getattr(sqlite3, action_name, None)
if action_value is not None:
actions = _CREATE_ACTIONS if operation == "create" else _DROP_ACTIONS
actions[action_value] = (operation, target_type)
_TEMP_SCHEMA_ACTIONS: tuple[
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
] = (
("SQLITE_CREATE_TEMP_INDEX", "create", "index"),
("SQLITE_CREATE_TEMP_TABLE", "create", "table"),
("SQLITE_CREATE_TEMP_TRIGGER", "create", "trigger"),
("SQLITE_CREATE_TEMP_VIEW", "create", "view"),
("SQLITE_DROP_TEMP_INDEX", "drop", "index"),
("SQLITE_DROP_TEMP_TABLE", "drop", "table"),
("SQLITE_DROP_TEMP_TRIGGER", "drop", "trigger"),
("SQLITE_DROP_TEMP_VIEW", "drop", "view"),
)
for schema_action in _TEMP_SCHEMA_ACTIONS:
_add_schema_action(*schema_action)
_VTABLE_SCHEMA_ACTIONS: tuple[
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
] = (
("SQLITE_CREATE_VTABLE", "create", "virtual-table"),
("SQLITE_DROP_VTABLE", "drop", "virtual-table"),
)
for schema_action in _VTABLE_SCHEMA_ACTIONS:
_add_schema_action(*schema_action)
_SQLITE_SCHEMA_TABLES = {
"sqlite_master",
"sqlite_schema",
"sqlite_temp_master",
"sqlite_temp_schema",
}
_SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
"length",
"like",
"printf",
"sqlite_drop_column",
"sqlite_rename_column",
"sqlite_rename_quotefix",
"sqlite_rename_table",
"sqlite_rename_test",
"substr",
}
_AUTHORIZER_ACTION_NAMES = {
getattr(sqlite3, name): name
for name in (
"SQLITE_CREATE_INDEX",
"SQLITE_CREATE_TABLE",
"SQLITE_CREATE_TEMP_INDEX",
"SQLITE_CREATE_TEMP_TABLE",
"SQLITE_CREATE_TEMP_TRIGGER",
"SQLITE_CREATE_TEMP_VIEW",
"SQLITE_CREATE_TRIGGER",
"SQLITE_CREATE_VIEW",
"SQLITE_DELETE",
"SQLITE_DROP_INDEX",
"SQLITE_DROP_TABLE",
"SQLITE_DROP_TEMP_INDEX",
"SQLITE_DROP_TEMP_TABLE",
"SQLITE_DROP_TEMP_TRIGGER",
"SQLITE_DROP_TEMP_VIEW",
"SQLITE_DROP_TRIGGER",
"SQLITE_DROP_VIEW",
"SQLITE_INSERT",
"SQLITE_PRAGMA",
"SQLITE_READ",
"SQLITE_SELECT",
"SQLITE_TRANSACTION",
"SQLITE_UPDATE",
"SQLITE_ATTACH",
"SQLITE_DETACH",
"SQLITE_ALTER_TABLE",
"SQLITE_REINDEX",
"SQLITE_ANALYZE",
"SQLITE_CREATE_VTABLE",
"SQLITE_DROP_VTABLE",
"SQLITE_FUNCTION",
"SQLITE_SAVEPOINT",
"SQLITE_RECURSIVE",
)
if hasattr(sqlite3, name)
}
def _allow_authorizer_action(*args):
return sqlite3.SQLITE_OK
def analyze_sql_tables(
conn,
sql: str,
params=None,
*,
database_name: str | None = None,
schema_to_database: dict[str, str] | None = None,
) -> SQLAnalysis:
"""
Return operations performed 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 operation
callbacks observed while SQLite compiles the statement.
"""
operations: dict[OperationKey, 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 record(
operation: SQLOperation,
target_type: SQLTargetType,
*,
database: str | None,
table: str | None,
sqlite_schema: str | None,
target: str | None,
source: str | None,
column: str | None = None,
internal: bool = False,
):
key = OperationKey(
operation=operation,
target_type=target_type,
database=database,
table=table,
sqlite_schema=sqlite_schema,
target=target,
source=source,
internal=internal,
)
columns = operations.setdefault(key, set())
if column is not None:
columns.add(column)
def authorizer(action, arg1, arg2, sqlite_schema, source):
operation = _ACTION_TO_OPERATION.get(action)
if operation is not None and arg1 is not None:
target_type = "schema" if arg1 in _SQLITE_SCHEMA_TABLES else "table"
column = (
arg2 if operation in ("read", "update") and arg2 is not None else None
)
record(
operation,
target_type,
database=database_for_schema(sqlite_schema),
table=arg1 if target_type == "table" else None,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
column=column,
)
return sqlite3.SQLITE_OK
create_operation = _CREATE_ACTIONS.get(action)
if create_operation is not None and arg1 is not None:
operation, target_type = create_operation
related_table = arg2 if target_type in {"index", "trigger"} else arg1
record(
operation,
target_type,
database=database_for_schema(sqlite_schema),
table=related_table,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
drop_operation = _DROP_ACTIONS.get(action)
if drop_operation is not None and arg1 is not None:
operation, target_type = drop_operation
related_table = arg2 if target_type in {"index", "trigger"} else arg1
record(
operation,
target_type,
database=database_for_schema(sqlite_schema),
table=related_table,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_ALTER_TABLE and arg2 is not None:
record(
"alter",
"table",
database=database_for_schema(arg1),
table=arg2,
sqlite_schema=arg1,
target=arg2,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_TRANSACTION and arg1 is not None:
record(
arg1.lower(),
"transaction",
database=None,
table=None,
sqlite_schema=None,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_ATTACH and arg1 is not None:
record(
"attach",
"database",
database=None,
table=None,
sqlite_schema=None,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_DETACH and arg1 is not None:
record(
"detach",
"database",
database=None,
table=None,
sqlite_schema=None,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_PRAGMA and arg1 is not None:
record(
"pragma",
"pragma",
database=None,
table=None,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_ANALYZE:
record(
"analyze",
"database" if arg1 is None else "table",
database=database_for_schema(sqlite_schema),
table=arg1,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_REINDEX and arg1 is not None:
record(
"reindex",
"index",
database=database_for_schema(sqlite_schema),
table=None,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_SELECT:
record(
"select",
"statement",
database=None,
table=None,
sqlite_schema=sqlite_schema,
target=None,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_FUNCTION and arg2 is not None:
record(
"function",
"function",
database=None,
table=None,
sqlite_schema=sqlite_schema,
target=arg2,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_SAVEPOINT and arg1 is not None:
record(
"savepoint",
"transaction",
database=None,
table=None,
sqlite_schema=sqlite_schema,
target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
source=source,
)
return sqlite3.SQLITE_OK
action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
record(
"unknown",
"unknown",
database=database_for_schema(sqlite_schema),
table=None,
sqlite_schema=sqlite_schema,
target=action_name,
source=source,
)
return sqlite3.SQLITE_OK
table_kind_cache: dict[tuple[str | None, str], SQLiteTableType | None] = {}
conn.set_authorizer(authorizer)
try:
explain_rows = conn.execute(
"EXPLAIN " + sql, params if params is not None else {}
).fetchall()
# Passing None before these lookups leaves a failing callback installed
# on Python 3.10, so use a permissive callback until they are complete.
conn.set_authorizer(_allow_authorizer_action)
if not operations:
vacuum_row = next((row for row in explain_rows if row[1] == "Vacuum"), None)
if vacuum_row is not None:
schema_by_index = {
row[0]: row[1] for row in conn.execute("PRAGMA database_list")
}
sqlite_schema = schema_by_index.get(vacuum_row[2])
database = database_for_schema(sqlite_schema)
record(
"vacuum",
"database",
database=database,
table=None,
sqlite_schema=sqlite_schema,
target=database,
source=None,
)
else:
record(
"unknown",
"statement",
database=database_name,
table=None,
sqlite_schema=None,
target=None,
source=None,
)
for key in operations:
if (
key.target_type == "table"
and key.operation in {"read", "insert", "update", "delete"}
and key.table is not None
):
cache_key = (key.sqlite_schema, key.table)
if cache_key not in table_kind_cache:
table_kind_cache[cache_key] = sqlite_table_type(
conn, key.table, schema=key.sqlite_schema
)
finally:
conn.set_authorizer(None)
has_schema_operation = any(
key.target_type in {"table", "index", "view", "trigger", "virtual-table"}
and key.operation in {"create", "alter", "drop"}
for key in operations
)
dropped_tables = {
(key.database, key.table)
for key in operations
if key.operation == "drop" and key.target_type == "table"
}
def key_is_drop_table_delete(key: OperationKey) -> bool:
return (
key.operation == "delete"
and key.target_type == "table"
and (key.database, key.table) in dropped_tables
)
has_user_table_access_in_schema_operation = any(
key.operation in {"read", "insert", "update", "delete"}
and key.target_type == "table"
and not key.internal
and not key_is_drop_table_delete(key)
for key in operations
)
def operation_is_internal(key: OperationKey) -> bool:
if key.internal or (has_schema_operation and key.target_type == "schema"):
return True
if has_schema_operation and key.operation == "reindex":
return True
if (
has_schema_operation
and not has_user_table_access_in_schema_operation
and key.operation == "function"
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
):
return True
if key_is_drop_table_delete(key):
return True
return False
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
if (
key.target_type != "table"
or key.operation not in {"read", "insert", "update", "delete"}
or key.table is None
):
return None
return table_kind_cache[(key.sqlite_schema, key.table)]
return SQLAnalysis(
operations=tuple(
Operation(
operation=key.operation,
target_type=key.target_type,
database=key.database,
table=key.table,
sqlite_schema=key.sqlite_schema,
table_kind=table_kind_for(key),
target=key.target,
columns=tuple(sorted(columns)),
source=key.source,
internal=operation_is_internal(key),
)
for key, columns in operations.items()
)
)

View file

@ -1,6 +1,3 @@
import re
from typing import Literal
using_pysqlite3 = False using_pysqlite3 = False
try: try:
import pysqlite3 as sqlite3 import pysqlite3 as sqlite3
@ -13,19 +10,6 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
sqlite3.enable_callback_tracebacks(True) sqlite3.enable_callback_tracebacks(True)
_cached_sqlite_version = None _cached_sqlite_version = None
_cached_supports_returning = None
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
_VIRTUAL_TABLE_MODULE_RE = re.compile(
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
re.IGNORECASE | re.DOTALL,
)
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
"fts3": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
"fts4": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
"fts5": ("_data", "_idx", "_docsize", "_content", "_config"),
"rtree": ("_node", "_parent", "_rowid"),
"rtree_i32": ("_node", "_parent", "_rowid"),
}
def sqlite_version(): def sqlite_version():
@ -52,146 +36,5 @@ def supports_table_xinfo():
return sqlite_version() >= (3, 26, 0) return sqlite_version() >= (3, 26, 0)
def supports_table_list():
return sqlite_version() >= (3, 37, 0)
def supports_generated_columns(): def supports_generated_columns():
return sqlite_version() >= (3, 31, 0) return sqlite_version() >= (3, 31, 0)
def supports_returning():
global _cached_supports_returning
if _cached_supports_returning is None:
conn = sqlite3.connect(":memory:")
try:
conn.execute("create table t (id integer primary key)")
conn.execute("insert into t default values returning id").fetchone()
_cached_supports_returning = True
except sqlite3.DatabaseError:
_cached_supports_returning = False
finally:
conn.close()
return _cached_supports_returning
def sqlite_table_type(
conn,
table: str,
*,
schema: str | None = "main",
) -> SQLiteTableType | None:
if supports_table_list():
try:
query = "select type from pragma_table_list where name = ?"
params: tuple[str, ...] = (table,)
if schema is not None:
query += " and schema = ?"
params = (table, schema)
row = conn.execute(query, params).fetchone()
if row is not None and row[0] in {"table", "view", "virtual", "shadow"}:
return row[0]
except sqlite3.DatabaseError:
pass
return _sqlite_table_type_from_schema(conn, table, schema=schema)
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
schema_table = _sqlite_schema_table(schema)
try:
rows = conn.execute(
"select name, sql from {} where type = 'table'".format(schema_table)
).fetchall()
except sqlite3.DatabaseError:
return []
hidden_tables = []
content_fts_tables = []
for name, sql in rows:
if (
name in {"sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"}
or name.startswith("_")
or sqlite_table_type(conn, name, schema=schema) == "shadow"
):
hidden_tables.append(name)
elif _is_fts_content_virtual_table(sql):
content_fts_tables.append(name)
return sorted(hidden_tables) + content_fts_tables
def _sqlite_table_type_from_schema(
conn,
table: str,
*,
schema: str | None = "main",
) -> SQLiteTableType | None:
schema_table = _sqlite_schema_table(schema)
try:
row = conn.execute(
"select type, sql from {} where name = ?".format(schema_table),
(table,),
).fetchone()
except sqlite3.DatabaseError:
return None
if row is None:
return None
object_type, sql = row
if object_type == "view":
return "view"
if object_type != "table":
return None
if _virtual_table_module(sql) is not None:
return "virtual"
if _is_known_shadow_table(conn, table, schema=schema):
return "shadow"
return "table"
def _is_known_shadow_table(
conn,
table: str,
*,
schema: str | None = "main",
) -> bool:
schema_table = _sqlite_schema_table(schema)
try:
rows = conn.execute(
"select name, sql from {} where type = 'table'".format(schema_table)
).fetchall()
except sqlite3.DatabaseError:
return False
for virtual_table, sql in rows:
module = _virtual_table_module(sql)
if module is None:
continue
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
if table == virtual_table + suffix:
return True
return False
def _sqlite_schema_table(schema: str | None) -> str:
if schema is None or schema == "main":
return "sqlite_master"
if schema == "temp":
return "sqlite_temp_master"
return "{}.sqlite_master".format(_quote_identifier(schema))
def _quote_identifier(value: str) -> str:
return '"{}"'.format(value.replace('"', '""'))
def _virtual_table_module(sql: str | None) -> str | None:
if not sql:
return None
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
if match is None:
return None
return match.group(1).strip("\"'[]`").lower()
def _is_fts_content_virtual_table(sql: str | None) -> bool:
return (
_virtual_table_module(sql) in {"fts3", "fts4", "fts5"}
and "content=" in sql.lower()
)

View file

@ -1,2 +1,2 @@
__version__ = "1.0a34" __version__ = "1.0a30"
__version_info__ = tuple(__version__.split(".")) __version_info__ = tuple(__version__.split("."))

View file

@ -153,13 +153,7 @@ class BaseView:
if self.has_json_alternate: if self.has_json_alternate:
alternate_url_json = self.ds.absolute_url( alternate_url_json = self.ds.absolute_url(
request, request,
self.ds.urls.path( self.ds.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
template_context["alternate_url_json"] = alternate_url_json template_context["alternate_url_json"] = alternate_url_json
headers.update( headers.update(
@ -353,21 +347,13 @@ class DataView(BaseView):
if it_can_render: if it_can_render:
renderers[key] = self.ds.urls.path( renderers[key] = self.ds.urls.path(
path_with_format( path_with_format(
request=request, request=request, format=key, extra_qs={**url_labels_extra}
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
) )
) )
url_csv_args = {"_size": "max", **url_labels_extra} url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path( url_csv = self.ds.urls.path(
path_with_format( path_with_format(request=request, format="csv", extra_qs=url_csv_args)
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
) )
url_csv_path = url_csv.split("?")[0] url_csv_path = url_csv.split("?")[0]
context = { context = {

View file

@ -11,11 +11,8 @@ import sqlite_utils
import textwrap import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
from datasette.write_sql import QueryWriteRejected
from datasette.utils import ( from datasette.utils import (
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
@ -38,12 +35,6 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm from datasette.plugins import pm
from .base import BaseView, DatasetteError, View, _error, stream_csv from .base import BaseView, DatasetteError, View, _error, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_extras import (
QueryExtraContext,
resolve_query_extras,
table_extra_registry,
)
from . import Context from . import Context
@ -66,11 +57,9 @@ class DatabaseView(View):
sql = (request.args.get("sql") or "").strip() sql = (request.args.get("sql") or "").strip()
if sql: if sql:
redirect_url = datasette.urls.database(database) + "/-/query" redirect_url = "/" + request.url_vars.get("database") + "/-/query"
if request.url_vars.get("format"): if request.url_vars.get("format"):
redirect_url = path_with_format( redirect_url += "." + request.url_vars.get("format")
path=redirect_url, format=request.url_vars.get("format")
)
redirect_url += "?" + request.query_string redirect_url += "?" + request.query_string
response = Response.redirect(redirect_url) response = Response.redirect(redirect_url)
if datasette.cors: if datasette.cors:
@ -103,34 +92,26 @@ class DatabaseView(View):
tables = await get_tables(datasette, request, db, allowed_dict) tables = await get_tables(datasette, request, db, allowed_dict)
queries_page = await datasette.list_queries( # Get allowed queries using the new permission system
database, allowed_query_page = await datasette.allowed_resources(
actor=request.actor, "view-query",
limit=5, request.actor,
include_private=True, parent=database,
) include_is_private=True,
stored_queries = queries_page.queries limit=1000,
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(): async def database_actions():
# Resolve the registered database-level actions for this
# database in one batched query, seeding the request permission
# cache so that allowed() calls made inside the plugin hooks
# below are served from the cache
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
links = [] links = []
for hook in pm.hook.database_actions( for hook in pm.hook.database_actions(
datasette=datasette, datasette=datasette,
@ -159,9 +140,7 @@ class DatabaseView(View):
"tables": tables, "tables": tables,
"hidden_count": len([t for t in tables if t["hidden"]]), "hidden_count": len([t for t in tables if t["hidden"]]),
"views": sql_views, "views": sql_views,
"queries": [stored_query_to_dict(query) for query in stored_queries], "queries": canned_queries,
"queries_more": queries_more,
"queries_count": queries_count,
"allow_execute_sql": allow_execute_sql, "allow_execute_sql": allow_execute_sql,
"table_columns": ( "table_columns": (
await _table_columns(datasette, database) if allow_execute_sql else {} await _table_columns(datasette, database) if allow_execute_sql else {}
@ -178,13 +157,7 @@ class DatabaseView(View):
assert format_ == "html" assert format_ == "html"
alternate_url_json = datasette.absolute_url( alternate_url_json = datasette.absolute_url(
request, request,
datasette.urls.path( datasette.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
templates = (f"database-{to_css_class(database)}.html", "database.html") templates = (f"database-{to_css_class(database)}.html", "database.html")
environment = datasette.get_jinja_environment(request) environment = datasette.get_jinja_environment(request)
@ -200,9 +173,7 @@ class DatabaseView(View):
tables=tables, tables=tables,
hidden_count=len([t for t in tables if t["hidden"]]), hidden_count=len([t for t in tables if t["hidden"]]),
views=sql_views, views=sql_views,
queries=stored_queries, queries=canned_queries,
queries_more=queries_more,
queries_count=queries_count,
allow_execute_sql=allow_execute_sql, allow_execute_sql=allow_execute_sql,
table_columns=( table_columns=(
await _table_columns(datasette, database) await _table_columns(datasette, database)
@ -250,11 +221,7 @@ class DatabaseContext(Context):
tables: list = field(metadata={"help": "List of table objects in the database"}) tables: list = field(metadata={"help": "List of table objects in the database"})
hidden_count: int = field(metadata={"help": "Count of hidden tables"}) hidden_count: int = field(metadata={"help": "Count of hidden tables"})
views: list = field(metadata={"help": "List of view objects in the database"}) views: list = field(metadata={"help": "List of view objects in the database"})
queries: list = field(metadata={"help": "List of stored query objects"}) queries: list = field(metadata={"help": "List of canned 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( allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"} metadata={"help": "Boolean indicating if custom SQL can be executed"}
) )
@ -299,8 +266,8 @@ class QueryContext(Context):
query: dict = field( query: dict = field(
metadata={"help": "The SQL query object containing the `sql` string"} metadata={"help": "The SQL query object containing the `sql` string"}
) )
stored_query: str = field( canned_query: str = field(
metadata={"help": "The name of the stored query if this is a stored query"} metadata={"help": "The name of the canned query if this is a canned query"}
) )
private: bool = field( private: bool = field(
metadata={"help": "Boolean indicating if this is a private database"} metadata={"help": "Boolean indicating if this is a private database"}
@ -308,13 +275,13 @@ class QueryContext(Context):
# urls: dict = field( # urls: dict = field(
# metadata={"help": "Object containing URL helpers like `database()`"} # metadata={"help": "Object containing URL helpers like `database()`"}
# ) # )
stored_query_write: bool = field( canned_query_write: bool = field(
metadata={ metadata={
"help": "Boolean indicating if this is a stored query that allows writes" "help": "Boolean indicating if this is a canned query that allows writes"
} }
) )
metadata: dict = field( metadata: dict = field(
metadata={"help": "Metadata about the database or the stored query"} metadata={"help": "Metadata about the database or the canned query"}
) )
db_is_immutable: bool = field( db_is_immutable: bool = field(
metadata={"help": "Boolean indicating if this database is immutable"} metadata={"help": "Boolean indicating if this database is immutable"}
@ -335,15 +302,12 @@ class QueryContext(Context):
allow_execute_sql: bool = field( allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"} 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"}) tables: list = field(metadata={"help": "List of table objects in the database"})
named_parameter_values: dict = field( named_parameter_values: dict = field(
metadata={"help": "Dictionary of parameter names/values"} metadata={"help": "Dictionary of parameter names/values"}
) )
edit_sql_url: str = field( edit_sql_url: str = field(
metadata={"help": "URL to edit the SQL for a stored query"} metadata={"help": "URL to edit the SQL for a canned query"}
) )
display_rows: list = field(metadata={"help": "List of result rows to display"}) display_rows: list = field(metadata={"help": "List of result rows to display"})
columns: list = field(metadata={"help": "List of column names"}) columns: list = field(metadata={"help": "List of column names"})
@ -367,8 +331,8 @@ class QueryContext(Context):
top_query: callable = field( top_query: callable = field(
metadata={"help": "Callable to render the top_query slot"} metadata={"help": "Callable to render the top_query slot"}
) )
top_stored_query: callable = field( top_canned_query: callable = field(
metadata={"help": "Callable to render the top_stored_query slot"} metadata={"help": "Callable to render the top_canned_query slot"}
) )
query_actions: callable = field( query_actions: callable = field(
metadata={ metadata={
@ -459,47 +423,21 @@ class QueryView(View):
db = await datasette.resolve_database(request) db = await datasette.resolve_database(request)
# We must be a stored query # We must be a canned query
table_found = False table_found = False
try: try:
await datasette.resolve_table(request) await datasette.resolve_table(request)
table_found = True table_found = True
except TableNotFound as table_not_found: except TableNotFound as table_not_found:
stored_query = await datasette.get_query( canned_query = await datasette.get_canned_query(
table_not_found.database_name, table_not_found.table table_not_found.database_name, table_not_found.table, request.actor
) )
if stored_query is None: if canned_query is None:
raise raise
if table_found: if table_found:
# That should not have happened # That should not have happened
raise DatasetteError("Unexpected table found on POST", status=404) 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")
try:
await _ensure_stored_query_execution_permissions(
datasette, db, stored_query, request.actor
)
except QueryWriteRejected as ex:
if request.headers.get("accept") == "application/json" or request.args.get(
"_json"
):
return Response.json(
{
"ok": False,
"message": ex.message,
"redirect": None,
},
status=403,
)
datasette.add_message(request, ex.message, datasette.ERROR)
return Response.redirect(stored_query.on_error_redirect or request.path)
# If database is immutable, return an error # If database is immutable, return an error
if not db.is_mutable: if not db.is_mutable:
raise Forbidden("Database is immutable") raise Forbidden("Database is immutable")
@ -524,18 +462,20 @@ class QueryView(View):
or request.args.get("_json") or request.args.get("_json")
or params.get("_json") or params.get("_json")
) )
params_for_query = MagicParameters(stored_query.sql, params, request, datasette) params_for_query = MagicParameters(
canned_query["sql"], params, request, datasette
)
await params_for_query.execute_params() await params_for_query.execute_params()
ok = None ok = None
redirect_url = None redirect_url = None
try: try:
cursor = await db.execute_write( cursor = await db.execute_write(
stored_query.sql, params_for_query, request=request canned_query["sql"], params_for_query, request=request
) )
# success message can come from on_success_message or on_success_message_sql # success message can come from on_success_message or on_success_message_sql
message = None message = None
message_type = datasette.INFO message_type = datasette.INFO
on_success_message_sql = stored_query.on_success_message_sql on_success_message_sql = canned_query.get("on_success_message_sql")
if on_success_message_sql: if on_success_message_sql:
try: try:
message_result = ( message_result = (
@ -547,21 +487,18 @@ class QueryView(View):
message = "Error running on_success_message_sql: {}".format(ex) message = "Error running on_success_message_sql: {}".format(ex)
message_type = datasette.ERROR message_type = datasette.ERROR
if not message: if not message:
if stored_query.on_success_message: message = canned_query.get(
message = stored_query.on_success_message "on_success_message"
elif cursor.rowcount == -1: ) or "Query executed, {} row{} affected".format(
message = "Query executed" cursor.rowcount, "" if cursor.rowcount == 1 else "s"
else: )
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
redirect_url = stored_query.on_success_redirect redirect_url = canned_query.get("on_success_redirect")
ok = True ok = True
except Exception as ex: except Exception as ex:
message = stored_query.on_error_message or str(ex) message = canned_query.get("on_error_message") or str(ex)
message_type = datasette.ERROR message_type = datasette.ERROR
redirect_url = stored_query.on_error_redirect redirect_url = canned_query.get("on_error_redirect")
ok = False ok = False
if should_return_json: if should_return_json:
return Response.json( return Response.json(
@ -594,59 +531,53 @@ class QueryView(View):
# Create lookup dict for quick access # Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables_page.resources} allowed_dict = {r.child: r for r in allowed_tables_page.resources}
# Are we a stored query? # Are we a canned query?
stored_query = None canned_query = None
stored_query_write = False canned_query_write = False
if "table" in request.url_vars: if "table" in request.url_vars:
try: try:
await datasette.resolve_table(request) await datasette.resolve_table(request)
except TableNotFound as table_not_found: except TableNotFound as table_not_found:
# Was this actually a stored query? # Was this actually a canned query?
stored_query = await datasette.get_query( canned_query = await datasette.get_canned_query(
table_not_found.database_name, table_not_found.table table_not_found.database_name, table_not_found.table, request.actor
) )
if stored_query is None: if canned_query is None:
raise raise
stored_query_write = stored_query.is_write canned_query_write = bool(canned_query.get("write"))
private = False private = False
if stored_query: if canned_query:
# Respect stored query permissions # Respect canned query permissions
visible, private = await datasette.check_visibility( visible, private = await datasette.check_visibility(
request.actor, request.actor,
action="view-query", action="view-query",
resource=QueryResource(database=database, query=stored_query.name), resource=QueryResource(database=database, query=canned_query["name"]),
) )
if not visible: if not visible:
raise Forbidden("You do not have permission to view this query") 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: else:
visible, private = await datasette.check_visibility( await datasette.ensure_permission(
request.actor,
action="execute-sql", action="execute-sql",
resource=DatabaseResource(database=database), resource=DatabaseResource(database=database),
actor=request.actor,
) )
if not visible:
raise Forbidden("execute-sql")
# Flattened because of ?sql=&name1=value1&name2=value2 feature # Flattened because of ?sql=&name1=value1&name2=value2 feature
params = {key: request.args.get(key) for key in request.args} params = {key: request.args.get(key) for key in request.args}
sql = None sql = None
if stored_query: if canned_query:
sql = stored_query.sql sql = canned_query["sql"]
elif "sql" in params: elif "sql" in params:
sql = params.pop("sql") sql = params.pop("sql")
# Extract any :named parameters # Extract any :named parameters
named_parameters = [] named_parameters = []
if stored_query and stored_query.parameters: if canned_query and canned_query.get("params"):
named_parameters = stored_query.parameters named_parameters = canned_query["params"]
if not named_parameters and sql: if not named_parameters:
named_parameters = derive_named_parameters(sql) named_parameters = derive_named_parameters(sql)
named_parameter_values = { named_parameter_values = {
named_parameter: params.get(named_parameter) or "" named_parameter: params.get(named_parameter) or ""
@ -671,13 +602,13 @@ class QueryView(View):
params_for_query = params params_for_query = params
if sql and not stored_query_write: if not canned_query_write:
try: try:
if not stored_query: if not canned_query:
# For regular queries we only allow SELECT, plus other rules # For regular queries we only allow SELECT, plus other rules
validate_sql_select(sql) validate_sql_select(sql)
else: else:
# Stored queries can run magic parameters # Canned queries can run magic parameters
params_for_query = MagicParameters(sql, params, request, datasette) params_for_query = MagicParameters(sql, params, request, datasette)
await params_for_query.execute_params() await params_for_query.execute_params()
results = await datasette.execute( results = await datasette.execute(
@ -713,17 +644,8 @@ class QueryView(View):
except DatasetteError: except DatasetteError:
raise raise
async def query_metadata():
if stored_query:
metadata = stored_query_to_dict(stored_query)
metadata.pop("source", None)
return metadata
return await datasette.get_database_metadata(database)
# Handle formats from plugins # Handle formats from plugins
if format_ == "csv": if format_ == "csv":
if not sql:
raise DatasetteError("?sql= is required", status=400)
async def fetch_data_for_csv(request, _next=None): async def fetch_data_for_csv(request, _next=None):
results = await db.execute(sql, params, truncate=True) results = await db.execute(sql, params, truncate=True)
@ -732,25 +654,6 @@ class QueryView(View):
return await stream_csv(datasette, fetch_data_for_csv, request, db.name) return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
elif format_ in datasette.renderers.keys(): elif format_ in datasette.renderers.keys():
data = {"ok": True, "rows": rows, "columns": columns}
extras = extra_names_from_request(request)
if extras:
query_extra_context = QueryExtraContext(
datasette=datasette,
request=request,
db=db,
database_name=database,
private=private,
rows=rows,
columns=columns,
sql=sql,
params=named_parameter_values,
query_name=stored_query.name if stored_query else None,
metadata=await query_metadata(),
extras=extras,
extra_registry=table_extra_registry,
)
data.update(await resolve_query_extras(extras, query_extra_context))
# Dispatch request to the correct output format renderer # Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming) # (CSV is not handled here due to streaming)
result = call_with_supported_arguments( result = call_with_supported_arguments(
@ -759,7 +662,7 @@ class QueryView(View):
columns=columns, columns=columns,
rows=rows, rows=rows,
sql=sql, sql=sql,
query_name=stored_query.name if stored_query else None, query_name=canned_query["name"] if canned_query else None,
database=database, database=database,
table=None, table=None,
request=request, request=request,
@ -768,7 +671,7 @@ class QueryView(View):
error=query_error, error=query_error,
# These will be deprecated in Datasette 1.0: # These will be deprecated in Datasette 1.0:
args=request.args, args=request.args,
data=data, data={"ok": True, "rows": rows, "columns": columns},
) )
if asyncio.iscoroutine(result): if asyncio.iscoroutine(result):
result = await result result = await result
@ -791,33 +694,19 @@ class QueryView(View):
elif format_ == "html": elif format_ == "html":
headers = {} headers = {}
templates = [f"query-{to_css_class(database)}.html", "query.html"] templates = [f"query-{to_css_class(database)}.html", "query.html"]
if stored_query: if canned_query:
templates.insert( templates.insert(
0, 0,
f"query-{to_css_class(database)}-{to_css_class(stored_query.name)}.html", f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html",
) )
environment = datasette.get_jinja_environment(request) environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates) template = environment.select_template(templates)
alternate_url_json = datasette.absolute_url( alternate_url_json = datasette.absolute_url(
request, request,
datasette.urls.path( datasette.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
data = { data = {}
"ok": query_error is None,
"rows": rows,
"columns": columns,
"query": {"sql": sql, "params": params},
"query_name": stored_query.name if stored_query else None,
"database": database,
"table": None,
}
headers.update( headers.update(
{ {
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format( "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
@ -825,7 +714,8 @@ class QueryView(View):
) )
} }
) )
metadata = await query_metadata() metadata = await datasette.get_database_metadata(database)
renderers = {} renderers = {}
for key, (_, can_render) in datasette.renderers.items(): for key, (_, can_render) in datasette.renderers.items():
it_can_render = call_with_supported_arguments( it_can_render = call_with_supported_arguments(
@ -843,11 +733,7 @@ class QueryView(View):
it_can_render = await await_me_maybe(it_can_render) it_can_render = await await_me_maybe(it_can_render)
if it_can_render: if it_can_render:
renderers[key] = datasette.urls.path( renderers[key] = datasette.urls.path(
path_with_format( path_with_format(request=request, format=key)
request=request,
path=request.scope.get("route_path"),
format=key,
)
) )
allow_execute_sql = await datasette.allowed( allow_execute_sql = await datasette.allowed(
@ -855,14 +741,9 @@ class QueryView(View):
resource=DatabaseResource(database=database), resource=DatabaseResource(database=database),
actor=request.actor, actor=request.actor,
) )
allow_store_query = await datasette.allowed(
action="store-query",
resource=DatabaseResource(database=database),
actor=request.actor,
)
show_hide_hidden = "" show_hide_hidden = ""
if stored_query and stored_query.hide_sql: if canned_query and canned_query.get("hide_sql"):
if bool(params.get("_show_sql")): if bool(params.get("_show_sql")):
show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_link = path_with_removed_args(request, {"_show_sql"})
show_hide_text = "hide" show_hide_text = "hide"
@ -890,38 +771,24 @@ class QueryView(View):
# - No magic parameters, so no :_ in the SQL string # - No magic parameters, so no :_ in the SQL string
edit_sql_url = None edit_sql_url = None
is_validated_sql = False is_validated_sql = False
if sql: try:
try: validate_sql_select(sql)
validate_sql_select(sql) is_validated_sql = True
is_validated_sql = True except InvalidSql:
except InvalidSql: pass
pass if allow_execute_sql and is_validated_sql and ":_" not in sql:
if allow_execute_sql and is_validated_sql and ":_" not in sql: edit_sql_url = (
edit_sql_url = (
datasette.urls.database(database)
+ "/-/query"
+ "?"
+ urlencode(
{
**{
"sql": sql,
},
**named_parameter_values,
}
)
)
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) datasette.urls.database(database)
+ "/-/queries/store?" + "/-/query"
+ urlencode({"sql": sql}) + "?"
+ urlencode(
{
**{
"sql": sql,
},
**named_parameter_values,
}
)
) )
async def query_actions(): async def query_actions():
@ -930,7 +797,7 @@ class QueryView(View):
datasette=datasette, datasette=datasette,
actor=request.actor, actor=request.actor,
database=database, database=database,
query_name=stored_query.name if stored_query else None, query_name=canned_query["name"] if canned_query else None,
request=request, request=request,
sql=sql, sql=sql,
params=params, params=params,
@ -950,17 +817,16 @@ class QueryView(View):
"sql": sql, "sql": sql,
"params": params, "params": params,
}, },
stored_query=stored_query.name if stored_query else None, canned_query=canned_query["name"] if canned_query else None,
private=private, private=private,
stored_query_write=stored_query_write, canned_query_write=canned_query_write,
db_is_immutable=not db.is_mutable, db_is_immutable=not db.is_mutable,
error=query_error, error=query_error,
hide_sql=hide_sql, hide_sql=hide_sql,
show_hide_link=datasette.urls.path(show_hide_link), show_hide_link=datasette.urls.path(show_hide_link),
show_hide_text=show_hide_text, show_hide_text=show_hide_text,
editable=not stored_query, editable=not canned_query,
allow_execute_sql=allow_execute_sql, allow_execute_sql=allow_execute_sql,
save_query_url=save_query_url,
tables=await get_tables(datasette, request, db, allowed_dict), tables=await get_tables(datasette, request, db, allowed_dict),
named_parameter_values=named_parameter_values, named_parameter_values=named_parameter_values,
edit_sql_url=edit_sql_url, edit_sql_url=edit_sql_url,
@ -976,14 +842,11 @@ class QueryView(View):
renderers=renderers, renderers=renderers,
url_csv=datasette.urls.path( url_csv=datasette.urls.path(
path_with_format( path_with_format(
request=request, request=request, format="csv", extra_qs={"_size": "max"}
path=request.scope.get("route_path"),
format="csv",
extra_qs={"_size": "max"},
) )
), ),
show_hide_hidden=markupsafe.Markup(show_hide_hidden), show_hide_hidden=markupsafe.Markup(show_hide_hidden),
metadata=metadata, metadata=canned_query or metadata,
alternate_url_json=alternate_url_json, alternate_url_json=alternate_url_json,
select_templates=[ select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}" f"{'*' if template_name == template.name else ''}{template_name}"
@ -992,12 +855,12 @@ class QueryView(View):
top_query=make_slot_function( top_query=make_slot_function(
"top_query", datasette, request, database=database, sql=sql "top_query", datasette, request, database=database, sql=sql
), ),
top_stored_query=make_slot_function( top_canned_query=make_slot_function(
"top_stored_query", "top_canned_query",
datasette, datasette,
request, request,
database=database, database=database,
query_name=stored_query.name if stored_query else None, query_name=canned_query["name"] if canned_query else None,
), ),
query_actions=query_actions, query_actions=query_actions,
), ),
@ -1093,8 +956,9 @@ class TableCreateView(BaseView):
): ):
return _error(["Permission denied"], 403) return _error(["Permission denied"], 403)
body = await request.post_body()
try: try:
data = await request.json() data = json.loads(body)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)]) return _error(["Invalid JSON: {}".format(e)])
@ -1309,6 +1173,22 @@ class TableCreateView(BaseView):
return Response.json(details, status=201) 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): async def display_rows(datasette, database, request, rows, columns):
display_rows = [] display_rows = []
truncate_cells = datasette.setting("truncate_cells_html") truncate_cells = datasette.setting("truncate_cells_html")

View file

@ -1,474 +0,0 @@
import re
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 .database import display_rows as display_query_rows
from .query_helpers import (
QueryValidationError,
SQL_PARAMETER_FORM_PREFIX,
_analysis_is_write,
_analysis_rows,
_analysis_rows_with_permissions,
_block_framing,
_coerce_execute_write_payload,
_derived_query_parameters,
_execute_write_analysis_data,
_execute_write_disabled_reason,
_inserted_row_url,
_json_or_form_payload,
_prepare_execute_write,
_table_columns,
_wants_json,
)
WRITE_TEMPLATE_LABELS = {
"insert": "Insert row",
"update": "Update rows",
"delete": "Delete rows",
}
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
def _parameter_names(columns):
seen = set()
names = {}
for column in columns:
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
base = base.strip("_") or "value"
if base[0].isdigit():
base = "p_{}".format(base)
name = base
index = 2
while name in seen:
name = "{}_{}".format(base, index)
index += 1
seen.add(name)
names[column] = name
return names
def _quote_identifier(identifier):
return '"{}"'.format(identifier.replace('"', '""'))
def _preferred_where_column(table, columns):
lower_table_id = "{}_id".format(table.lower())
return (
next((column for column in columns if column.lower() == "id"), None)
or next(
(column for column in columns if column.lower() == lower_table_id), None
)
or columns[0]
)
def _auto_incrementing_primary_key(columns):
primary_keys = [column for column in columns if column.is_pk]
if len(primary_keys) != 1:
return None
primary_key = primary_keys[0]
if primary_key.type and primary_key.type.lower() == "integer":
return primary_key.name
return None
def _insert_template_sql(table, columns):
column_names = [column.name for column in columns]
auto_pk = _auto_incrementing_primary_key(columns)
insert_columns = [column for column in column_names if column != auto_pk]
if not insert_columns:
return "insert into {}\ndefault values".format(_quote_identifier(table))
names = _parameter_names(insert_columns)
return "\n".join(
(
"insert into {} (".format(_quote_identifier(table)),
",\n".join(
" {}".format(_quote_identifier(column)) for column in insert_columns
),
")",
"values (",
",\n".join(" :{}".format(names[column]) for column in insert_columns),
")",
)
)
def _update_template_sql(table, columns):
column_names = [column.name for column in columns]
names = _parameter_names(column_names)
where_column = _preferred_where_column(table, column_names)
set_columns = [column for column in column_names if column != where_column]
if not set_columns:
return "\n".join(
(
"update {}".format(_quote_identifier(table)),
"set {} = :new_{}".format(
_quote_identifier(where_column), names[where_column]
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
return "\n".join(
(
"update {}".format(_quote_identifier(table)),
"set "
+ ",\n".join(
"{}{} = :{}".format(
" " if index else "",
_quote_identifier(column),
names[column],
)
for index, column in enumerate(set_columns)
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
def _delete_template_sql(table, columns):
column_names = [column.name for column in columns]
names = _parameter_names(column_names)
where_column = _preferred_where_column(table, column_names)
return "\n".join(
(
"delete from {}".format(_quote_identifier(table)),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
def _template_sqls_for_table(table, columns):
return {
"insert": _insert_template_sql(table, columns),
"update": _update_template_sql(table, columns),
"delete": _delete_template_sql(table, columns),
}
async def _template_sql_allowed(datasette, db, sql, actor):
params = {parameter: "" for parameter in _derived_query_parameters(sql)}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError:
return False
if not _analysis_is_write(analysis):
return False
analysis_rows = await _analysis_rows_with_permissions(datasette, analysis, actor)
return _execute_write_disabled_reason(sql, None, analysis_rows) is None
async def _write_template_tables(
datasette, db, table_columns, hidden_table_names, actor
):
write_template_tables = {}
for table in table_columns:
if table in hidden_table_names or not table_columns[table]:
continue
column_details = [
column
for column in await db.table_column_details(table)
if not column.hidden
]
if not column_details:
continue
templates = {}
for operation, sql in _template_sqls_for_table(table, column_details).items():
if await _template_sql_allowed(datasette, db, sql, actor):
templates[operation] = sql
if templates:
write_template_tables[table] = {
"templates": templates,
}
return write_template_tables
def _write_template_operations(write_template_tables):
operations = []
for operation in WRITE_TEMPLATE_OPERATIONS:
if any(
operation in table["templates"] for table in write_template_tables.values()
):
operations.append(
{
"name": operation,
"label": WRITE_TEMPLATE_LABELS[operation],
}
)
return operations
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,
execute_write_returns_rows=False,
execute_write_columns=None,
execute_write_display_rows=None,
execute_write_truncated=False,
status=200,
):
parameter_values = parameter_values or {}
execution_links = execution_links or []
execute_write_columns = execute_write_columns or []
execute_write_display_rows = execute_write_display_rows or []
parameter_names = []
analysis_rows = []
table_columns = await _table_columns(self.ds, db.name)
hidden_table_names = set(await db.hidden_table_names())
write_template_tables = await _write_template_tables(
self.ds, db, table_columns, hidden_table_names, request.actor
)
write_template_operations = _write_template_operations(write_template_tables)
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
execute_disabled_reason = _execute_write_disabled_reason(
sql, analysis_error, analysis_rows
)
if allow_save_query:
save_query_base_url = self.ds.urls.database(db.name) + "/-/queries/store"
if not execute_disabled_reason:
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": analysis_rows,
"execution_message": execution_message,
"execution_links": execution_links,
"execution_ok": execution_ok,
"execute_write_returns_rows": execute_write_returns_rows,
"execute_write_columns": execute_write_columns,
"execute_write_display_rows": execute_write_display_rows,
"execute_write_truncated": execute_write_truncated,
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
"table_columns": table_columns,
"write_template_tables": write_template_tables,
"write_template_operations": write_template_operations,
"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))
if ex.flash:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
request,
db,
sql=sql or "",
parameter_values=provided_params,
analysis_error=None if ex.flash else ex.message,
execution_message=None if ex.flash else ex.message,
execution_ok=False,
status=ex.status,
)
wants_json = _wants_json(request, is_json, data)
try:
execute_write_kwargs = {"request": request}
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
except sqlite3.DatabaseError as ex:
message = str(ex)
if wants_json:
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,
)
if cursor.rowcount == -1:
message = "Query executed"
else:
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
if wants_json:
data = {
"ok": True,
"message": message,
"rowcount": cursor.rowcount,
"rows": [],
"truncated": False,
"analysis": _analysis_rows(analysis),
}
if cursor.description is not None:
data["rows"] = [dict(row) for row in cursor.fetchall()]
data["truncated"] = cursor.truncated
return _block_framing(Response.json(data))
inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor)
execution_links = (
[{"href": inserted_row_url, "label": "View row"}]
if inserted_row_url
else []
)
execute_write_returns_rows = cursor.description is not None
execute_write_columns = []
execute_write_display_rows = []
if execute_write_returns_rows:
execute_write_columns = [
description[0] for description in cursor.description
]
execute_write_display_rows = await display_query_rows(
self.ds,
db.name,
request,
cursor.fetchall(),
execute_write_columns,
)
return await self._render_form(
request,
db,
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,
execute_write_returns_rows=execute_write_returns_rows,
execute_write_columns=execute_write_columns,
execute_write_display_rows=execute_write_display_rows,
execute_write_truncated=cursor.truncated,
)
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

@ -1,638 +0,0 @@
import json
import re
from datasette.resources import DatabaseResource
from datasette.stored_queries import (
StoredQuery,
)
from datasette.write_sql import (
IgnoreWriteSqlOperation,
QueryWriteRejected,
RequireWriteSqlPermissions,
decision_for_write_sql_operation,
operation_is_write,
)
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
from datasette.utils.sql_analysis import Operation, SQLAnalysis
_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",
}
SQL_PARAMETER_FORM_PREFIX = "_sql_param_"
class QueryValidationError(Exception):
def __init__(self, message, status=400, *, flash=False):
self.message = message
self.status = status
self.flash = flash
super().__init__(message)
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: SQLAnalysis) -> bool:
return any(operation_is_write(operation) for operation in analysis.operations)
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 QueryWriteRejected as ex:
raise QueryValidationError(ex.message, status=403, flash=True) from ex
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 _display_operations(analysis: SQLAnalysis) -> list[Operation]:
operations = []
for operation in analysis.operations:
if isinstance(
decision_for_write_sql_operation(operation), IgnoreWriteSqlOperation
):
continue
operations.append(operation)
return operations
def _analysis_rows(analysis: SQLAnalysis) -> list[dict[str, object]]:
rows = []
for operation in _display_operations(analysis):
decision = decision_for_write_sql_operation(operation)
required_permission = (
", ".join(permission.action for permission in decision.permissions)
if isinstance(decision, RequireWriteSqlPermissions)
else ""
)
rows.append(
{
"operation": operation.operation,
"database": operation.database,
"table": operation.table or operation.target,
"required_permission": required_permission,
"source": operation.source,
}
)
return rows
async def _analysis_rows_with_permissions(
datasette, analysis: SQLAnalysis, actor
) -> list[dict[str, object]]:
rows = _analysis_rows(analysis)
is_write = _analysis_is_write(analysis)
for row, operation in zip(rows, _display_operations(analysis)):
decision = decision_for_write_sql_operation(operation)
if isinstance(decision, RequireWriteSqlPermissions):
row["allowed"] = True
for permission in decision.permissions:
if not await datasette.allowed(
action=permission.action,
resource=permission.resource,
actor=actor,
):
row["allowed"] = False
break
elif is_write:
row["allowed"] = False
else:
row["allowed"] = None
return rows
def _execute_write_disabled_reason(sql, analysis_error, analysis_rows):
if not (sql and sql.strip()):
return "Enter writable SQL before executing."
if analysis_error:
return analysis_error
if any(row.get("allowed") is False for row in analysis_rows):
return "You do not have permission for every operation listed above."
return None
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 = {}
for key, value in data.items():
if key in {"sql", "csrftoken", "_json"}:
continue
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
params[key] = value
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 QueryWriteRejected as ex:
raise QueryValidationError(ex.message, status=403, flash=True) from ex
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))
execute_disabled_reason = _execute_write_disabled_reason(
sql, analysis_error, analysis_rows
)
return {
"ok": analysis_error is None,
"parameters": parameter_names,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
}
async def _query_create_analysis_data(datasette, db, sql, actor):
has_sql = bool(sql and sql.strip())
parameter_names = []
analysis_rows = []
analysis_error = None
analysis: SQLAnalysis | None = 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": _analysis_is_write(analysis) if analysis else False,
"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 _query_edit_form_context(
datasette,
request,
db,
existing: StoredQuery,
*,
sql=None,
title=None,
description=None,
is_private=None,
):
sql = existing.sql if sql is None else sql
title = existing.title if title is None else title
description = existing.description if description is None else description
is_private = existing.is_private if is_private is None else is_private
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
return {
"database": db.name,
"database_color": db.color,
"name": existing.name,
"sql": sql,
"title": title or "",
"description": description or "",
"is_private": is_private,
"query_url": datasette.urls.table(db.name, existing.name),
**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 = [
operation
for operation in analysis.operations
if operation.operation == "insert"
and operation.target_type == "table"
and not operation.internal
and operation.source is None
and operation.database == db.name
]
if len(direct_inserts) != 1:
return None
table = direct_inserts[0].table
if table is None:
return None
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

@ -7,7 +7,6 @@ from datasette.utils import (
await_me_maybe, await_me_maybe,
CustomRow, CustomRow,
make_slot_function, make_slot_function,
path_from_row_pks,
to_css_class, to_css_class,
escape_sqlite, escape_sqlite,
) )
@ -15,13 +14,7 @@ from datasette.plugins import pm
import json import json
import markupsafe import markupsafe
import sqlite_utils import sqlite_utils
from datasette.extras import extra_names_from_request from .table import display_columns_and_rows, _get_extras
from .table import (
display_columns_and_rows,
_table_page_data,
row_label_from_label_column,
)
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
class RowView(DataView): class RowView(DataView):
@ -54,7 +47,6 @@ class RowView(DataView):
pks = resolved.pks pks = resolved.pks
async def template_data(): async def template_data():
is_table = await db.table_exists(table)
# Reorder columns so primary keys come first # Reorder columns so primary keys come first
pk_set = set(pks) pk_set = set(pks)
pk_cols = [d for d in results.description if d[0] in pk_set] pk_cols = [d for d in results.description if d[0] in pk_set]
@ -123,60 +115,7 @@ class RowView(DataView):
"<strong>{}</strong>".format(cell["value"]) "<strong>{}</strong>".format(cell["value"])
) )
label_column = await db.label_column_for_table(table) if is_table else None
row_path = path_from_row_pks(rows[0], pks, False)
pk_path = path_from_row_pks(rows[0], pks, False, False)
row_label = row_label_from_label_column(expanded_rows[0], label_column)
for display_row in display_rows:
display_row.pk_path = pk_path
display_row.row_path = row_path
display_row.row_label = row_label
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = "{} {}".format(pk_path, row_label)
row_action_permissions = {}
if is_table and db.is_mutable:
row_action_permissions = await self.ds.allowed_many(
actions=["update-row", "delete-row"],
resource=TableResource(database=database, table=table),
actor=request.actor,
)
row_actions = [] row_actions = []
if row_action_permissions.get("update-row"):
attrs = {
"aria-label": "Edit row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "edit",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Edit row",
"description": "Open a dialog to edit this row.",
"attrs": attrs,
}
)
if row_action_permissions.get("delete-row"):
attrs = {
"aria-label": "Delete row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "delete",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Delete row",
"description": "Open a confirmation dialog to delete this row.",
"attrs": attrs,
}
)
for hook in pm.hook.row_actions( for hook in pm.hook.row_actions(
datasette=self.ds, datasette=self.ds,
actor=request.actor, actor=request.actor,
@ -203,16 +142,6 @@ class RowView(DataView):
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html", f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
"_table.html", "_table.html",
], ],
"row_mutation_ui": any(row_action_permissions.values()),
"table_page_data": await _table_page_data(
self.ds,
request,
db,
database,
table,
not is_table,
None,
),
"row_actions": row_actions, "row_actions": row_actions,
"top_row": make_slot_function( "top_row": make_slot_function(
"top_row", "top_row",
@ -235,27 +164,60 @@ class RowView(DataView):
"primary_key_values": pk_values, "primary_key_values": pk_values,
} }
extras = extra_names_from_request(request) # Handle _extra parameter (new style)
extras = _get_extras(request)
# Also support legacy _extras parameter for backward compatibility
if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
extras.add("foreign_key_tables")
# Process extras # Process extras
row_extra_context = RowExtraContext( if "foreign_key_tables" in extras:
datasette=self.ds, data["foreign_key_tables"] = await self.foreign_key_tables(
request=request, database, table, pk_values
db=db, )
database_name=database,
table_name=table, if "render_cell" in extras:
private=private, # Call render_cell plugin hook for each cell
rows=rows, ct_map = await self.ds.get_column_types(database, table)
columns=columns, rendered_rows = []
pks=pks, for row in rows:
pk_values=pk_values, rendered_row = {}
sql=resolved.sql, for value, column in zip(row, columns):
params=resolved.params, ct = ct_map.get(column)
extras=extras, plugin_display_value = None
extra_registry=table_extra_registry, # Try column type render_cell first
foreign_key_tables=self.foreign_key_tables, if ct:
) candidate = await ct.render_cell(
data.update(await resolve_row_extras(extras, row_extra_context)) value=value,
column=column,
table=table,
database=database,
datasette=self.ds,
request=request,
)
if candidate is not None:
plugin_display_value = candidate
if plugin_display_value is None:
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table,
pks=resolved.pks,
database=database,
datasette=self.ds,
request=request,
column_type=ct,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
plugin_display_value = candidate
break
if plugin_display_value:
rendered_row[column] = str(plugin_display_value)
rendered_rows.append(rendered_row)
data["render_cell"] = rendered_rows
return ( return (
data, data,
@ -318,27 +280,6 @@ class RowError(Exception):
self.error = error self.error = error
ROW_FLASH_LABEL_MAX_LENGTH = 80
def _truncated_row_flash_label(label):
label = " ".join(str(label).split())
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
return label
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
async def _row_flash_message(db, action, resolved, row=None):
pk_label = ", ".join(resolved.pk_values)
label_column = await db.label_column_for_table(resolved.table)
label = row_label_from_label_column(row or resolved.row, label_column)
if label:
label = _truncated_row_flash_label(label)
if label and label != pk_label:
return "{} row {} ({})".format(action, pk_label, label)
return "{} row {}".format(action, pk_label)
async def _resolve_row_and_check_permission(datasette, request, permission): async def _resolve_row_and_check_permission(datasette, request, permission):
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
@ -393,15 +334,6 @@ class RowDeleteView(BaseView):
) )
) )
if request.args.get("_redirect_to_table"):
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
self.ds.add_message(
request,
await _row_flash_message(resolved.db, "Deleted", resolved),
self.ds.INFO,
)
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
return Response.json({"ok": True}, status=200) return Response.json({"ok": True}, status=200)
@ -418,8 +350,9 @@ class RowUpdateView(BaseView):
if not ok: if not ok:
return resolved return resolved
body = await request.post_body()
try: try:
data = await request.json() data = json.loads(body)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)]) return _error(["Invalid JSON: {}".format(e)])
@ -462,13 +395,11 @@ class RowUpdateView(BaseView):
return _error([str(e)], 400) return _error([str(e)], 400)
result = {"ok": True} result = {"ok": True}
returned_row = None
if data.get("return"): if data.get("return"):
results = await resolved.db.execute( results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True resolved.sql, resolved.params, truncate=True
) )
returned_row = results.dicts()[0] result["row"] = results.dicts()[0]
result["row"] = returned_row
await self.ds.track_event( await self.ds.track_event(
UpdateRowEvent( UpdateRowEvent(
@ -479,19 +410,4 @@ class RowUpdateView(BaseView):
) )
) )
if request.args.get("_message"):
message_row = returned_row
if message_row is None:
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
message_row = results.first()
self.ds.add_message(
request,
await _row_flash_message(
resolved.db, "Updated", resolved, row=message_row
),
self.ds.INFO,
)
return Response.json(result, status=200) return Response.json(result, status=200)

View file

@ -67,7 +67,7 @@ class JsonDataView(BaseView):
context = { context = {
"filename": self.filename, "filename": self.filename,
"data": data, "data": data,
"data_json": json.dumps(data, indent=2, default=repr), "data_json": json.dumps(data, indent=4, default=repr),
} }
# Add has_debug_permission if this view requires permissions-debug # Add has_debug_permission if this view requires permissions-debug
if self.permission == "permissions-debug": if self.permission == "permissions-debug":
@ -91,110 +91,6 @@ class PatternPortfolioView(View):
) )
class AutocompleteDebugView(BaseView):
name = "autocomplete_debug"
has_json_alternate = False
async def _suggested_tables(self, request):
scanned = 0
reached_scan_limit = False
suggestions = []
for database_name, db in self.ds.databases.items():
if scanned >= 100 or len(suggestions) >= 5:
break
remaining = 100 - scanned
results = await db.execute(
"select name from sqlite_master where type = 'table' order by name limit ?",
[remaining],
)
for row in results.rows:
table_name = row["name"]
scanned += 1
if scanned >= 100:
reached_scan_limit = True
visible, _ = await self.ds.check_visibility(
request.actor,
action="view-table",
resource=TableResource(database=database_name, table=table_name),
)
if not visible:
if scanned >= 100:
break
continue
label_column = await db.label_column_for_table(table_name)
if label_column:
suggestions.append(
{
"database": database_name,
"table": table_name,
"label_column": label_column,
"url": self.ds.urls.path(
"-/debug/autocomplete?"
+ urllib.parse.urlencode(
{
"database": database_name,
"table": table_name,
}
)
),
}
)
if len(suggestions) >= 5:
break
if scanned >= 100:
break
return suggestions, scanned, reached_scan_limit
async def get(self, request):
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
database_name = request.args.get("database")
table_name = request.args.get("table")
context = {
"database_name": database_name,
"table_name": table_name,
}
if database_name or table_name:
if not database_name or not table_name:
context["error"] = "Both database and table are required."
elif database_name not in self.ds.databases:
context["error"] = "Database not found."
else:
db = self.ds.databases[database_name]
if not await db.table_exists(table_name):
context["error"] = "Table not found."
else:
await self.ds.ensure_permission(
action="view-table",
resource=TableResource(
database=database_name,
table=table_name,
),
actor=request.actor,
)
context.update(
{
"autocomplete_url": "{}/-/autocomplete".format(
self.ds.urls.table(database_name, table_name)
),
"label_column": await db.label_column_for_table(table_name),
}
)
else:
suggestions, scanned, reached_scan_limit = await self._suggested_tables(
request
)
context.update(
{
"suggestions": suggestions,
"scanned": scanned,
"reached_scan_limit": reached_scan_limit,
}
)
return await self.render(["debug_autocomplete.html"], request, context)
class AuthTokenView(BaseView): class AuthTokenView(BaseView):
name = "auth_token" name = "auth_token"
has_json_alternate = False has_json_alternate = False
@ -601,13 +497,11 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
if action_obj.resource_class is None: if action_obj.resource_class is None:
resource_obj = None resource_obj = None
elif action_obj.takes_parent and action_obj.takes_child: elif action_obj.takes_parent and action_obj.takes_child:
# Child-level resource (e.g., TableResource, QueryResource). The child # Child-level resource (e.g., TableResource, QueryResource)
# argument is named differently per resource class (table, query, ...), resource_obj = action_obj.resource_class(database=parent, table=child)
# so pass positionally - https://github.com/simonw/datasette/issues/2756
resource_obj = action_obj.resource_class(parent, child)
elif action_obj.takes_parent: elif action_obj.takes_parent:
# Parent-level resource (e.g., DatabaseResource) # Parent-level resource (e.g., DatabaseResource)
resource_obj = action_obj.resource_class(parent) resource_obj = action_obj.resource_class(database=parent)
else: else:
# This shouldn't happen given validation in Action.__post_init__ # This shouldn't happen given validation in Action.__post_init__
return {"error": f"Invalid action configuration: {action}"}, 500 return {"error": f"Invalid action configuration: {action}"}, 500
@ -998,15 +892,14 @@ class ApiExplorerView(BaseView):
raise Forbidden("You do not have permission to view this instance") raise Forbidden("You do not have permission to view this instance")
def api_path(link): def api_path(link):
return "{}#{}".format( return "/-/api#{}".format(
self.ds.urls.path("/-/api"),
urllib.parse.urlencode( urllib.parse.urlencode(
{ {
key: json.dumps(value, indent=2) if key == "json" else value key: json.dumps(value, indent=2) if key == "json" else value
for key, value in link.items() for key, value in link.items()
if key in ("path", "method", "json") if key in ("path", "method", "json")
} }
), )
) )
return await self.render( return await self.render(

View file

@ -1,644 +0,0 @@
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_edit_form_context,
_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 QueryEditView(BaseView):
name = "query-edit"
has_json_alternate = False
async def _load(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)
return db, query_name, existing
async def _render_form(
self,
request,
db,
existing,
*,
sql=None,
title=None,
description=None,
is_private=None,
status=200,
):
response = await self.render(
["query_edit.html"],
request,
await _query_edit_form_context(
self.ds,
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
),
)
response.status = status
return response
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return _error(["Trusted queries cannot be edited"], 403)
return await self._render_form(request, db, existing)
async def post(self, request):
db, query_name, existing = await self._load(request)
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 edited"], 403)
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
return _error(["Invalid form submission"], 400)
sql = data.get("sql")
sql = existing.sql if sql is None else sql.strip()
title = data.get("title") or ""
description = data.get("description") or ""
is_private = _as_bool(data.get("is_private"))
update = {
"title": title,
"description": description,
"is_private": is_private,
}
if sql != existing.sql:
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
self.ds.add_message(
request,
"Permission denied: need execute-sql to change the SQL",
self.ds.ERROR,
)
return await self._render_form(
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
status=403,
)
update["sql"] = sql
try:
update_kwargs = await _prepare_query_update(
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
status=ex.status,
)
await self.ds.update_query(db.name, query_name, **update_kwargs)
self.ds.add_message(request, "Query updated", self.ds.INFO)
return Response.redirect(
self.ds.urls.path(self.ds.urls.table(db.name, query_name))
)
class QueryDeleteView(BaseView):
name = "query-delete"
has_json_alternate = False
async def _load(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)
return db, query_name, existing
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
return await self.render(
["query_delete.html"],
request,
{
"database": db.name,
"database_color": db.color,
"query": stored_query_to_dict(existing),
"query_url": self.ds.urls.table(db.name, query_name),
},
)
async def post(self, request):
db, query_name, existing = await self._load(request)
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)
data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name)
if is_json:
return Response.json({"ok": True})
self.ds.add_message(
request,
"Query “{}” deleted".format(existing.title or query_name),
self.ds.INFO,
)
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,253 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .permissions import Resource
from .resources import DatabaseResource, TableResource
from .utils import named_parameters, sqlite3
from .utils.asgi import Forbidden
from .utils.sql_analysis import Operation, SQLAnalysis
if TYPE_CHECKING:
from .app import Datasette
class QueryWriteRejected(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
@dataclass(frozen=True)
class PermissionRequirement:
action: str
resource: Resource
PermissionRequirements = tuple[PermissionRequirement, ...]
class WriteSqlOperationDecision:
"""What Datasette should do with one operation in user-supplied write SQL."""
@dataclass(frozen=True)
class IgnoreWriteSqlOperation(WriteSqlOperationDecision):
reason: str
@dataclass(frozen=True)
class RequireWriteSqlPermissions(WriteSqlOperationDecision):
permissions: PermissionRequirements
@dataclass(frozen=True)
class RejectWriteSqlOperation(WriteSqlOperationDecision):
message: str
@dataclass(frozen=True)
class UnsupportedWriteSqlOperation(WriteSqlOperationDecision):
message: str
def row_mutation_requirements(database: str, table: str) -> PermissionRequirements:
resource = TableResource(database=database, table=table)
return tuple(
PermissionRequirement(action=action, resource=resource)
for action in ("insert-row", "update-row", "delete-row")
)
def decision_for_write_sql_operation(
operation: Operation,
) -> WriteSqlOperationDecision:
unsupported_message = (
f"Unsupported SQL operation: {operation.operation} {operation.target_type}"
)
if operation.internal:
return IgnoreWriteSqlOperation("internal SQLite operation")
if operation.operation == "select":
return IgnoreWriteSqlOperation("select statement")
if operation.operation == "vacuum":
return RejectWriteSqlOperation("VACUUM is not allowed in user-supplied SQL")
if operation.operation in {"insert", "update", "delete"}:
if operation.table_kind == "virtual":
return RejectWriteSqlOperation(
"Writes to virtual tables are not allowed in user-supplied SQL"
)
if operation.table_kind == "shadow":
return RejectWriteSqlOperation(
"Writes to shadow tables are not allowed in user-supplied SQL"
)
if operation.operation == "function":
return IgnoreWriteSqlOperation("SQL function")
if (
operation.operation == "read"
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="view-table",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
if (
operation.operation in {"insert", "update"}
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
row_mutation_requirements(
database=operation.database,
table=operation.table,
)
)
if (
operation.operation == "delete"
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="delete-row",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
if operation.operation == "create" and operation.target_type == "table":
if operation.database is None:
return UnsupportedWriteSqlOperation(unsupported_message)
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="create-table",
resource=DatabaseResource(database=operation.database),
),
)
)
if (
operation.operation == "alter"
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="alter-table",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
if (
operation.operation == "drop"
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="drop-table",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
if (
operation.operation in {"create", "drop"}
and operation.target_type == "index"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="alter-table",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
return UnsupportedWriteSqlOperation(unsupported_message)
def operation_is_write(operation: Operation) -> bool:
return operation.operation in {
"insert",
"update",
"delete",
"create",
"alter",
"drop",
"begin",
"commit",
"rollback",
"savepoint",
"attach",
"detach",
"pragma",
"analyze",
"reindex",
"vacuum",
"unknown",
}
async def ensure_query_write_permissions(
datasette: Datasette,
database: str,
sql: str,
*,
actor: dict[str, object] | None = None,
params: dict[str, object] | None = None,
analysis: SQLAnalysis | None = None,
) -> SQLAnalysis:
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 operation in analysis.operations:
decision = decision_for_write_sql_operation(operation)
if isinstance(decision, IgnoreWriteSqlOperation):
continue
if isinstance(decision, RejectWriteSqlOperation):
raise QueryWriteRejected(decision.message)
if isinstance(decision, UnsupportedWriteSqlOperation):
raise Forbidden(decision.message)
permissions = decision.permissions
if operation.database != database:
raise Forbidden("Writable queries may not access attached databases")
for permission in permissions:
if not await datasette.allowed(
action=permission.action,
resource=permission.resource,
actor=actor,
):
raise Forbidden(
f"Permission denied: need {permission.action} "
f"on {permission.resource}"
)
return analysis

View file

@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
How permissions are resolved 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. ``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 the entire Datasette instance
* Access to specific databases * Access to specific databases
* Access to specific tables and views * Access to specific tables and views
* Access to specific :ref:`queries <queries>` * Access to specific :ref:`canned_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. 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: .. _authentication_permissions_query:
Access to specific queries Access to specific canned queries
-------------------------- ---------------------------------
: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. :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.
To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`: To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
.. [[[cog .. [[[cog
config_example(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. 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 <queries>` - within a specific database:: Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <canned_queries>` - within a specific database::
datasette create-token root --resource mydatabase mytable insert-row datasette create-token root --resource mydatabase mytable insert-row
@ -1285,46 +1285,12 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
view-query view-query
---------- ----------
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. 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`.
``resource`` - ``datasette.resources.QueryResource(database, query)`` ``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string) ``database`` is the name of the database (string)
``query`` is the name of the query (string) ``query`` is the name of the canned 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: .. _actions_insert_row:
@ -1413,23 +1379,13 @@ Actor is allowed to drop a database table.
execute-sql execute-sql
----------- -----------
Actor is allowed to run arbitrary read-only SQL queries against a specific database using the :ref:`custom SQL query page <pages_custom_sql_queries>`, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100 Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
``resource`` - ``datasette.resources.DatabaseResource(database)`` ``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string) ``database`` is the name of the database (string)
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`. 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 using the :ref:`write SQL queries page <pages_execute_write>`, subject to table-level write permissions such as ``insert-row``, ``update-row`` and ``delete-row``. SQL functions are allowed and are not separately restricted by Datasette permissions.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_permissions_debug: .. _actions_permissions_debug:
permissions-debug permissions-debug

View file

@ -4,123 +4,6 @@
Changelog Changelog
========= =========
.. _v1_0_a34:
1.0a34 (2026-06-16)
-------------------
The big feature in this alpha is tools to **insert, edit and delete** rows within the Datasette interface. These features are available on table pages, and edit and delete are also available as action items on the row page.
The edit interface takes :ref:`custom column types <table_configuration_column_types>` into account. Plugins that define their own column types can use JavaScript to customize how those column types are presented in the edit interface.
- ``datasette.allowed_many()`` method for :ref:`resolving multiple permission checks at once <datasette_allowed_many>`. (:pr:`2775`)
- Permission checks are now cached on a per-request basis, speeding up table pages with multiple plugins that check permissions in order to populate the :ref:`table actions menu <plugin_hook_table_actions>`.
- Fixed a warning about ``gen.throw(*sys.exc_info())``. (:issue:`2776`)
- New default custom column type ``textarea`` for multi-line text content. This is rendered as a ``<textarea>`` input in the edit UI.
- The ``json`` column type now implements client-side validation in the edit UI.
- The :ref:`makeColumnField() <javascript_plugins_makeColumnField>` JavaScript plugin hook allows plugins to define custom fields in the edit interface for their custom column types.
- New UI for inserting, editing, and deleting rows within Datasette. (:issue:`2780`)
- New ``/<database>/<table>/-/autocomplete?q=term`` :ref:`autocomplete JSON API <TableAutocompleteView>` for rapid autocomplete search against the contents of a table. This is used by the edit interface to select related rows for foreign keys. You can try it out on the ``/-/debug/autocomplete`` debug page.
- New ``/<database>/<table>/-/fragment`` :ref:`HTML fragment endpoint <TableFragmentView>` for returning the HTML used to display a specific row.
- ``await request.json()`` utility method for consuming the request body as JSON. (:issue:`2767`)
- Database, table, query and row action menus can now be modified by plugins to :ref:`display buttons in addition to links <plugin_actions>`. (:issue:`2782`)
- Datasette :ref:`now uses Playwright <contributing_playwright>` for browser automation tests as part of the test suite. (:issue:`2779`)
.. _v1_0_a33:
1.0a33 (2026-06-11)
-------------------
Stored queries can now be edited and deleted through the web interface, and the JSON API ``?_extra=`` mechanism has been extended to cover row and query pages in addition to tables. This release also fixes two security issues: an identifier-quoting bug involving table and column names that contain ``]``, and an open redirect.
Editing and deleting stored queries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The stored query page gained a "Query actions" menu with **Edit this query** and **Delete this query** links for actors with the necessary permissions. The owner of a query can always edit or delete it; for queries that are not private, any actor with the :ref:`update-query <actions_update_query>` or :ref:`delete-query <actions_delete_query>` permission can do so too. Private queries remain editable and deletable only by their owner. See :ref:`stored_queries` for details. (:issue:`2735`)
``?_extra=`` support for row and query pages
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Row and query JSON pages now support the same ``?_extra=`` mechanism as table pages. Row pages can request extras such as ``foreign_key_tables``, ``query``, ``metadata`` and ``database_color``; arbitrary SQL and stored query pages can request extras such as ``columns``, ``query``, ``metadata`` and ``private``. The implementation was refactored into a registry of extra classes shared by all three page types.
New generated reference documentation describes every ``?_extra=`` parameter available on table, row and query JSON pages, with example output captured from a live Datasette instance at documentation build time. See :ref:`json_api_extra` for the full list.
You can explore the new extras using this `Datasette extras API explorer tool <https://tools.simonwillison.net/datasette-extras-explorer>`__.
Other improvements and fixes to the extras mechanism:
- Extras that exist to serve the HTML interface (``filters``, ``actions``, ``display_rows``) are no longer advertised or reachable through the JSON API, where requesting them previously returned a 500 serialization error.
- The pre-1.0 ``?_extras=`` (plural) parameter on row pages has been removed - use ``?_extra=foreign_key_tables`` instead.
Security fixes
~~~~~~~~~~~~~~
- Fixed an identifier-quoting bug in ``datasette.utils.escape_sqlite()``. Datasette uses this helper when constructing SQL around table and column names; identifiers containing ``]`` could break out of SQLite bracket quoting and alter the generated SQL, for example by adding a ``UNION SELECT``. Identifiers containing ``]`` are now quoted using double quotes instead. (:issue:`2677`)
- Fixed an open redirect vulnerability. Requesting a path such as ``/\example.com/`` produced a redirect with a ``Location: /\example.com`` header - browsers normalize backslashes to forward slashes, turning that into the protocol-relative URL ``//example.com`` and redirecting the user off-site. Any run of leading slashes and backslashes in a redirect path is now collapsed to a single slash. (:issue:`2680`)
Bug fixes
~~~~~~~~~
- ``can_render()`` callbacks registered by the :ref:`register_output_renderer() <plugin_register_output_renderer>` plugin hook now receive the result ``rows`` and ``columns`` for stored queries. Previously renderers that inspect the available columns - such as `datasette-atom <https://github.com/simonw/datasette-atom>`__ and `datasette-ics <https://github.com/simonw/datasette-ics>`__ - never appeared as export options on stored query pages. (:issue:`2711`)
- Fixed a 500 error from the :ref:`/-/check <PermissionCheckView>` permission debugging endpoint when checking query actions such as ``view-query``, ``update-query`` and ``delete-query``. (:issue:`2756`)
- Write queries that use a named parameter called ``:sql`` no longer fail with an error. (:issue:`2761`)
- :ref:`db.execute_isolated_fn() <database_execute_isolated_fn>` now works against immutable databases, using a read-only connection that bypasses the write thread. It previously always attempted to open a writable connection, which would fail - breaking features built on top of it, such as the SQL analysis step used when storing a query. An exception raised while opening the connection for an isolated function no longer crashes the write thread. (:issue:`2768`)
- Facet counts are now displayed on the same line as the facet value instead of wrapping onto a second line. (:issue:`2754`)
- Datasette's pytest plugin no longer imports the rest of Datasette at pytest startup time. This means plugin test suites using ``pytest-cov`` now correctly record coverage of code that runs when ``datasette`` modules are first imported.
.. _v1_0_a32:
1.0a32 (2026-05-31)
-------------------
SQLite INSERT ... RETURNING clauses are now supported by ``/db/-/execute-write``, plus several fixes relating to the :ref:`base_url setting <setting_base_url>`.
- ``INSERT``/``UPDATE``/``DELETE`` statements that use SQLite's ``RETURNING`` clause now work correctly in the new ``/db/-/execute-write`` interface. Datasette fetches returned rows before committing the write transaction, displays them in the HTML UI and includes them in the ``"rows"`` key for the JSON API response. (:issue:`2762`, :pr:`2763`)
- ``Database.execute_write()`` now returns an ``ExecuteWriteResult`` object instead of the raw ``sqlite3.Cursor`` returned by ``conn.execute()``. The new object exposes ``.rowcount``, ``.lastrowid``, ``.description``, ``.truncated`` and ``.fetchall()``, and adds ``return_all=`` and ``returning_limit=`` options for controlling how rows from ``RETURNING`` statements are buffered. (:pr:`2763`)
- Fixed the ``/-/jump`` navigation search endpoint when Datasette is served with a configured ``base_url``. (:issue:`2757`)
- Fixed JSON and CSV export links, plus ``Link:`` alternate headers, on table, row and query pages when ``base_url`` is configured. These could previously be prefixed twice. (:issue:`2759`)
- Fixed several other ``base_url`` handling bugs, including the API explorer form actions and share links, the ``/-/patterns`` development page, permanent redirects such as ``/-`` to ``/-/`` and database query redirects from ``/<database>?sql=...`` to ``/<database>/-/query?sql=...``.
.. _v1_0_a31:
1.0a31 (2026-05-28)
-------------------
Datasette now offers users with the necessary permissions the ability to both **execute write queries** against their database and to **save stored queries** (renamed from "canned queries") both privately and for use by other members of their Datasette instance.
The ability to write is controlled by the new ``execute-write-sql`` permission, but the user also needs the relevant ``insert-row``/``update-row``/``delete-row``/``create-table``/etc permissions for the query they are trying to execute.
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, includes starter templates for ``INSERT``, ``UPDATE`` and ``DELETE`` statements and links to a newly inserted row when a single-row insert succeeds. This is also available as a :ref:`JSON API <ExecuteWriteView>`. (: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`)
- The write SQL analyzer now uses a deny-by-default model for unsupported operations. Reads from source tables require :ref:`view-table <actions_view_table>` permission, schema changes require :ref:`create-table <actions_create_table>`, :ref:`alter-table <actions_alter_table>` or :ref:`drop-table <actions_drop_table>` as appropriate, and row mutation statements require the full ``insert-row``, ``update-row`` and ``delete-row`` permission set. SQL functions are allowed and are not separately permission-gated. (:issue:`2748`)
- User-supplied write SQL rejects both ``VACUUM`` operations and writes to SQLite virtual or shadow tables. These restrictions also apply to untrusted stored write queries; trusted queries in ``datasette.yml`` skip these filters. (:issue:`2748`)
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 API methods available to plugins: ``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 lists at ``/-/queries`` and ``/<database>/-/queries``. Those pages 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`)
- Configured queries from ``datasette.yaml`` are trusted by default, so they can execute with ``view-query`` permission alone. They can opt out of that behavior using ``is_trusted: false`` but cannot be made private; private queries are only available for user-created stored queries. (: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`)
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`)
- The ``datasette inspect`` command now correctly records row counts for tables with more than 10,000 rows. (:issue:`2712`)
.. _v1_0_a30: .. _v1_0_a30:
1.0a30 (2026-05-24) 1.0a30 (2026-05-24)
@ -130,7 +13,7 @@ The "Jump to" menu, activated by hitting ``/`` or through the application menu,
- New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`) - New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`)
- The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``. - The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``.
- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL. ``JumpSQL`` queries run against Datasette's internal database by default, or can target another database using the optional ``database=`` argument. (:issue:`2731`) - New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL. ``JumpSQL`` queries run against Datasette's internal database by default, or can target another database using the optional ``database=`` argument.
- ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog. - ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog.
- New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query. - New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query.
- Debug menu links now appear in the jump-to menu instead of the top-right app menu, with descriptions for each debug item. - Debug menu links now appear in the jump-to menu instead of the top-right app menu, with descriptions for each debug item.
@ -766,7 +649,7 @@ For more information and workarounds, read `the security advisory <https://githu
Also in this alpha: 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`) - 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:`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:`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`)
- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`) - 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`) - Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
@ -1160,7 +1043,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 ``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`) - 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`) - :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:`queries_options`. (:issue:`1422`) - 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 ``--cpu`` option for :ref:`datasette publish cloudrun <publish_cloud_run>`. (:issue:`1420`) - 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`) - 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`) - ``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`)
@ -1534,7 +1417,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/>`__. 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:`queries_json_api`. (:issue:`880`) - Writable canned queries now expose a JSON API, see :ref:`canned_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`) - 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`) - ``register_output_renderer()`` render functions can now return a ``Response``. (:issue:`953`)
- New ``--upgrade`` option for ``datasette install``. (:issue:`945`) - New ``--upgrade`` option for ``datasette install``. (:issue:`945`)
@ -1626,7 +1509,7 @@ Magic parameters for canned queries, a log out feature, improved plugin document
Magic parameters for canned queries Magic parameters for canned queries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Canned queries now support :ref:`queries_magic_parameters`, which can be used to insert or select automatically generated values. For example:: Canned queries now support :ref:`canned_queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
insert into logs insert into logs
(user_id, timestamp) (user_id, timestamp)
@ -1657,7 +1540,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_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_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`)
- ``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_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`) - :ref:`plugin_hook_forbidden` is a hook for customizing how Datasette responds to 403 forbidden errors. (:issue:`812`)
Smaller changes Smaller changes
@ -1732,7 +1615,7 @@ A new debug page at ``/-/permissions`` shows recent permission checks, to help a
Writable canned queries Writable canned queries
~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~
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. 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.
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`): 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`):
@ -1751,7 +1634,7 @@ Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 intr
} }
} }
See :ref:`queries_writable` for more details. See :ref:`canned_queries_writable` for more details.
Flash messages Flash messages
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
@ -1806,7 +1689,7 @@ Smaller changes
- New ``request.cookies`` property. - 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 - ``/-/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. - ``request.post_vars()`` method no longer discards empty values.
- New "params" canned query key for explicitly setting named parameters, see :ref:`queries_named_parameters`. (:issue:`797`) - New "params" canned query key for explicitly setting named parameters, see :ref:`canned_queries_named_parameters`. (:issue:`797`)
- ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object. - ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object.
- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`) - Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)
- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`) - Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)
@ -1840,7 +1723,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`) * 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`) * 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`) * 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:`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:`canned_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`) * 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`) * 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`)
@ -2357,7 +2240,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>`_ - Documentation for :ref:`datasette publish and datasette package <publishing>`, closes `#337 <https://github.com/simonw/datasette/issues/337>`_
- Fixed compatibility with Python 3.7 - 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] - ``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 <queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_ - Title and description metadata can now be set for :ref:`canned SQL queries <canned_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>`_ - 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>`_ - ``?_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>`_ - URLs displayed in the results of custom SQL queries are now URLified, closes `#298 <https://github.com/simonw/datasette/issues/298>`_

View file

@ -87,7 +87,6 @@ This is equivalent to a ``datasette.yaml`` file containing the following:
} }
.. [[[end]]] .. [[[end]]]
.. _configuration_reference: .. _configuration_reference:
``datasette.yaml`` reference ``datasette.yaml`` reference
@ -434,12 +433,12 @@ Here is a simple example:
:ref:`authentication_permissions_config` has the full details. :ref:`authentication_permissions_config` has the full details.
.. _configuration_reference_queries: .. _configuration_reference_canned_queries:
Queries configuration Canned queries configuration
~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
: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: :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:
.. [[[cog .. [[[cog
from metadata_doc import config_example, config_example from metadata_doc import config_example, config_example
@ -484,7 +483,7 @@ Queries configuration
} }
.. [[[end]]] .. [[[end]]]
See the :ref:`queries documentation <queries>` for more, including how to configure :ref:`writable queries <queries_writable>`. See the :ref:`canned queries documentation <canned_queries>` for more, including how to configure :ref:`writable canned queries <canned_queries_writable>`.
.. _configuration_reference_css_js: .. _configuration_reference_css_js:
@ -1102,9 +1101,9 @@ These configure :ref:`full-text search <full_text_search>` for a table or view.
``column_types`` ``column_types``
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
You can assign semantic column types to columns, which affect how values are rendered, validated, transformed, and edited. Built-in column types include ``url``, ``email``, ``json``, and ``textarea``. Plugins can register additional column types using the :ref:`register_column_types <plugin_register_column_types>` plugin hook. You can assign semantic column types to columns, which affect how values are rendered, validated, and transformed. Built-in column types include ``url``, ``email``, and ``json``. Plugins can register additional column types using the :ref:`register_column_types <plugin_register_column_types>` plugin hook.
Column types can optionally declare which SQLite column types they apply to using ``sqlite_types``. Datasette will reject incompatible assignments. The built-in ``url``, ``email``, ``json``, and ``textarea`` column types are all restricted to ``TEXT`` columns. Column types can optionally declare which SQLite column types they apply to using ``sqlite_types``. Datasette will reject incompatible assignments. The built-in ``url``, ``email``, and ``json`` column types are all restricted to ``TEXT`` columns.
The simplest form maps column names to type name strings: The simplest form maps column names to type name strings:
@ -1119,7 +1118,6 @@ The simplest form maps column names to type name strings:
website: url website: url
contact: email contact: email
extra_data: json extra_data: json
notes: textarea
""").strip() """).strip()
) )
.. ]]] .. ]]]
@ -1136,7 +1134,6 @@ The simplest form maps column names to type name strings:
website: url website: url
contact: email contact: email
extra_data: json extra_data: json
notes: textarea
.. tab:: datasette.json .. tab:: datasette.json
@ -1150,8 +1147,7 @@ The simplest form maps column names to type name strings:
"column_types": { "column_types": {
"website": "url", "website": "url",
"contact": "email", "contact": "email",
"extra_data": "json", "extra_data": "json"
"notes": "textarea"
} }
} }
} }
@ -1215,3 +1211,4 @@ For column types that accept additional configuration, use an object with ``type
} }
} }
.. [[[end]]] .. [[[end]]]

View file

@ -62,65 +62,6 @@ You can run the tests faster using multiple CPU cores with `pytest-xdist <https:
uv run pytest -m "serial" uv run pytest -m "serial"
.. _contributing_playwright:
Running Playwright tests
~~~~~~~~~~~~~~~~~~~~~~~~
Datasette includes a small number of browser automation tests using Playwright_.
These tests are skipped by default, so you can run the main test suite with
``uv run pytest`` without installing Playwright or any browser binaries.
.. _Playwright: https://playwright.dev/python/
The Playwright tests use a separate dependency group. The easiest way to run
them is using ``just``. First install the browser engine you want to test
against. Chromium is used by default:
.. code-block:: bash
just playwright-install
Then run the Playwright test module:
.. code-block:: bash
just playwright
You can also run the same tests against Firefox or WebKit by installing that
browser engine and passing it to ``just playwright``:
.. code-block:: bash
just playwright-install firefox
just playwright firefox
just playwright-install webkit
just playwright webkit
To install every supported browser engine and run the tests against all of
them, use:
.. code-block:: bash
just playwright-install-all
just playwright-all
You can pass extra ``pytest`` options after the browser name:
.. code-block:: bash
just playwright chromium -k permissions
just playwright-all -x
If you are not using ``just``, the equivalent Chromium commands are:
.. code-block:: bash
uv run --group playwright playwright install chromium
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium
.. _contributing_using_fixtures: .. _contributing_using_fixtures:
Using fixtures Using fixtures

View file

@ -29,7 +29,7 @@ The custom SQL template (``/dbname?sql=...``) gets this:
<body class="query db-dbname"> <body class="query db-dbname">
A stored query template (``/dbname/queryname``) gets this: A canned query template (``/dbname/queryname``) gets this:
.. code-block:: html .. code-block:: html
@ -193,8 +193,8 @@ The lookup rules Datasette uses are as follows::
query-mydatabase.html query-mydatabase.html
query.html query.html
Stored query page (/mydatabase/query-name): Canned query page (/mydatabase/canned-query):
query-mydatabase-query-name.html query-mydatabase-canned-query.html
query-mydatabase.html query-mydatabase.html
query.html query.html
@ -230,7 +230,7 @@ will look something like this::
<!-- Templates considered: *query-mydb-tz.html, query-mydb.html, query.html --> <!-- Templates considered: *query-mydb-tz.html, query-mydb.html, query.html -->
This example is from the stored query page for a query called "tz" in the This example is from the canned query page for a query called "tz" in the
database called "mydb". The asterisk shows which template was selected - so in 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 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 used that - but if that template had not been found, it would have tried for
@ -274,28 +274,13 @@ Here is an example of a custom ``_table.html`` template:
.. code-block:: jinja .. code-block:: jinja
{% for row in display_rows %} {% for row in display_rows %}
<div data-row="{{ row.row_path }}"> <div>
<h2>{{ row["title"] }}</h2> <h2>{{ row["title"] }}</h2>
<p>{{ row["description"] }}<lp> <p>{{ row["description"] }}<lp>
<p>Category: {{ row.display("category_id") }}</p> <p>Category: {{ row.display("category_id") }}</p>
</div> </div>
{% endfor %} {% endfor %}
If your custom table template should support Datasette's row editing UI, include
``data-row="{{ row.row_path }}"`` on the outer element that represents each row.
This does not need to be a ``<tr>``: it can be a ``<div>``, ``<li>`` or any other
element that wraps the HTML for that row. Datasette uses this attribute to find
the element to remove after a delete, or replace after an edit. Any edit or
delete controls should be rendered inside that same element.
The ``_action_menu.html`` template renders the action menus used by database,
table, query and row pages. Plugin-provided actions can be link dictionaries
with ``href`` and ``label`` keys, or button dictionaries using ``{"type":
"button", "label": "...", "attrs": {...}}`` for JavaScript-backed interactions.
Both shapes can include an optional ``description`` key. Custom
``_action_menu.html`` templates should preserve support for both link and button
action items.
.. _custom_pages: .. _custom_pages:
Custom pages Custom pages

View file

@ -106,9 +106,6 @@ The object also has the following awaitable methods:
``await request.post_vars()`` - dictionary ``await request.post_vars()`` - dictionary
Returns a dictionary of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. For multipart forms or file uploads, use ``request.form()`` instead. Returns a dictionary of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. For multipart forms or file uploads, use ``request.form()`` instead.
``await request.json()`` - Any
Returns the parsed JSON body of a request submitted by ``POST``.
``await request.post_body()`` - bytes ``await request.post_body()`` - bytes
Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data. Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.
@ -515,43 +512,6 @@ Example usage:
The method returns ``True`` if the permission is granted, ``False`` if denied. The method returns ``True`` if the permission is granted, ``False`` if denied.
Results are cached for the duration of the current request, so checking the same ``(actor, action, resource)`` combination twice within one request only does the underlying permission resolution work once.
.. _datasette_allowed_many:
await .allowed_many(\*, actions, resource, actor=None)
------------------------------------------------------
``actions`` - list of strings
The names of the actions to permission check.
``resource`` - Resource object
A Resource object representing the database, table, or other resource that each action is checked against. Omit for global actions.
``actor`` - dictionary, optional
The authenticated actor. This is usually ``request.actor``. Defaults to ``None`` for unauthenticated requests.
Checks several actions against the same resource for the same actor, returning a dictionary mapping each action name to ``True`` or ``False``. The whole batch - including any actions pulled in through ``also_requires`` dependencies - is resolved with a single SQL query against the internal database, so this is much faster than calling :ref:`datasette.allowed() <datasette_allowed>` once per action.
Example usage:
.. code-block:: python
from datasette.resources import TableResource
results = await datasette.allowed_many(
actions=["insert-row", "delete-row", "drop-table"],
resource=TableResource(
database="fixtures", table="facetable"
),
actor=request.actor,
)
# {"insert-row": True, "delete-row": True, "drop-table": False}
Each result is stored in the per-request permission check cache, so subsequent ``datasette.allowed()`` calls for the same checks within the same request are served from that cache. Datasette uses this before running the ``table_actions`` and ``database_actions`` plugin hooks: it resolves every registered table-level action against the current table and every database-level action against its database first, which means ``allowed()`` calls made by those plugin hooks are usually served from the cache instead of triggering additional queries.
Actions for which no plugin provides any permission rules are resolved to ``False`` directly, without being included in the SQL query at all.
.. _datasette_allowed_resources: .. _datasette_allowed_resources:
await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None) await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None)
@ -765,7 +725,7 @@ The builder methods are:
- ``allow_all(action)`` - allow an action across all databases and resources - ``allow_all(action)`` - allow an action across all databases and resources
- ``allow_database(database, action)`` - allow an action on a specific database - ``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:`stored query <stored_queries>`) within a 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
Each method returns the ``TokenRestrictions`` instance so calls can be chained. Each method returns the ``TokenRestrictions`` instance so calls can be chained.
@ -877,10 +837,10 @@ await .get_resource_metadata(self, database_name, resource_name)
``database_name`` - string ``database_name`` - string
The name of the database to query. The name of the database to query.
``resource_name`` - string ``resource_name`` - string
The name of the resource (table, view, or stored query) inside ``database_name`` to query. The name of the resource (table, view, or canned query) inside ``database_name`` to query.
Returns metadata keys and values for the specified "resource" as a dictionary. Returns metadata keys and values for the specified "resource" as a dictionary.
A "resource" in this context can be a table, view, or stored query. A "resource" in this context can be a table, view, or canned query.
Internally queries the ``metadata_resources`` table inside the :ref:`internal database <internals_internal>`. Internally queries the ``metadata_resources`` table inside the :ref:`internal database <internals_internal>`.
.. _datasette_get_column_metadata: .. _datasette_get_column_metadata:
@ -891,7 +851,7 @@ await .get_column_metadata(self, database_name, resource_name, column_name)
``database_name`` - string ``database_name`` - string
The name of the database to query. The name of the database to query.
``resource_name`` - string ``resource_name`` - string
The name of the resource (table, view, or stored query) inside ``database_name`` to query. The name of the resource (table, view, or canned query) inside ``database_name`` to query.
``column_name`` - string ``column_name`` - string
The name of the column inside ``resource_name`` to query. The name of the column inside ``resource_name`` to query.
@ -937,7 +897,7 @@ await .set_resource_metadata(self, database_name, resource_name, key, value)
``database_name`` - string ``database_name`` - string
The database the metadata entry belongs to. The database the metadata entry belongs to.
``resource_name`` - string ``resource_name`` - string
The resource (table, view, or stored query) the metadata entry belongs to. The resource (table, view, or canned query) the metadata entry belongs to.
``key`` - string ``key`` - string
The metadata entry key to insert (ex ``title``, ``description``, etc.) The metadata entry key to insert (ex ``title``, ``description``, etc.)
``value`` - string ``value`` - string
@ -955,7 +915,7 @@ await .set_column_metadata(self, database_name, resource_name, column_name, key,
``database_name`` - string ``database_name`` - string
The database the metadata entry belongs to. The database the metadata entry belongs to.
``resource_name`` - string ``resource_name`` - string
The resource (table, view, or stored query) the metadata entry belongs to. The resource (table, view, or canned query) the metadata entry belongs to.
``column-name`` - string ``column-name`` - string
The column the metadata entry belongs to. The column the metadata entry belongs to.
``key`` - string ``key`` - string
@ -967,200 +927,6 @@ Adds a new metadata entry for the specified column.
Any previous column-level metadata entry with the same ``key`` will be overwritten. 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>`. 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: .. _datasette_column_types:
Column types Column types
@ -1968,8 +1734,8 @@ Example usage:
.. _database_execute_write: .. _database_execute_write:
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10) await db.execute_write(sql, params=None, block=True)
-------------------------------------------------------------------------------------------------------- ----------------------------------------------------
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received. SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
@ -1977,30 +1743,7 @@ This method can be used to queue up a non-SELECT SQL query to be executed agains
You can pass additional SQL parameters as a tuple or dictionary. You can pass additional SQL parameters as a tuple or dictionary.
The optional ``request=`` argument is used internally by Datasette to pass request context to :ref:`write_wrapper plugin hooks <plugin_hook_write_wrapper>`. The method will block until the operation is completed, and the return value will be the return from calling ``conn.execute(...)`` using the underlying ``sqlite3`` Python library.
The method will block until the operation is completed, and the return value will be an ``ExecuteWriteResult`` object. This imitates a subset of the ``sqlite3.Cursor`` object:
``.rowcount``
The number of rows modified by the statement, or ``-1`` if that number is unavailable.
``.lastrowid``
The row ID of the last modified row, as returned by ``sqlite3.Cursor.lastrowid``.
``.description``
The same column metadata exposed by Python's `sqlite3.Cursor.description <https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.description>`__: one tuple per returned column, or ``None`` if the statement does not return rows.
``.truncated``
``True`` if the statement returned more rows than ``returning_limit``.
``.fetchall()``
Returns any rows buffered by Datasette from the statement, such as rows from SQLite's ``RETURNING`` clause. This may be limited by ``returning_limit`` unless ``return_all=True`` was used. This method empties the buffer, so calling it again will return an empty list.
SQLite statements using ``RETURNING`` must have their rows consumed before the transaction can commit. Datasette will fetch up to ``returning_limit + 1`` rows before committing, store up to ``returning_limit`` rows on the result object and set ``.truncated`` if there were more. The default ``returning_limit`` is ``10``.
When ``.truncated`` is ``True``, ``.rowcount`` will be ``-1``. SQLite only reports the final row count for a ``RETURNING`` statement after every returned row has been fetched, and Datasette has deliberately stopped fetching rows after ``returning_limit`` to avoid buffering a potentially large result in memory.
If you need to retrieve every row returned by a statement, pass ``return_all=True``. This will buffer all returned rows in memory before committing.
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task. If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
@ -2405,26 +2148,6 @@ The internal database schema is as follows:
config TEXT, config TEXT,
PRIMARY KEY (database_name, resource_name, column_name) 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]]] .. [[[end]]]
@ -2496,8 +2219,8 @@ Note that the space character is a special case: it will be replaced with a ``+`
.. _internals_utils_call_with_supported_arguments: .. _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. 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.
@ -2524,8 +2247,8 @@ This is useful in plugins that want to define callback functions that only decla
.. _internals_utils_async_call_with_supported_arguments: .. _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. 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 /-/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 stored 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 canned 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. 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.
@ -201,15 +201,6 @@ Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name. When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name.
.. _AutocompleteDebugView:
/-/debug/autocomplete
---------------------
The debug tool at ``/-/debug/autocomplete`` can be used to try out the autocomplete component against a specific table. Pass ``?database=db&table=table`` to display an autocomplete field backed by that table's ``/-/autocomplete`` endpoint.
Without those query string arguments, the page lists up to five tables with detected label columns, scanning at most 100 tables.
.. _JsonDataView_threads: .. _JsonDataView_threads:
/-/threads /-/threads

View file

@ -46,9 +46,6 @@ The ``datasetteManager`` object
``registerPlugin(name, implementation)`` ``registerPlugin(name, implementation)``
Call this to register a plugin, passing its name and implementation Call this to register a plugin, passing its name and implementation
``makeColumnField(context)``
Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs.
``selectors`` - object ``selectors`` - object
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>` An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
@ -63,26 +60,22 @@ The ``implementation`` object passed to this method should include a ``version``
.. _javascript_plugins_makeJumpSections: .. _javascript_plugins_makeJumpSections:
makeJumpSections(context) makeJumpSections()
~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the ``/`` jump menu, before the user starts typing a search. This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the ``/`` jump menu, before the user starts typing a search.
It should return an array of objects, each with the following: Each object should have the following:
``id`` - string ``id`` - string
A unique string ID for the section, for example ``agent-chat`` A unique string ID for the section, for example ``agent-chat``
``render(node, context)`` - function ``render(node, context)`` - function
A function that will be called with a DOM node to render the section into A function that will be called with a DOM node to render the section into
Datasette passes a ``context`` object to both ``makeJumpSections(context)`` and ``render(node, context)``. It has the following keys: The ``context`` object has the following keys:
``navigationSearch`` ``navigationSearch``
The ``<navigation-search>`` custom element instance. The ``<navigation-search>`` custom element instance.
``container`` - only for ``render()``
The ``.results-container`` element used by the jump menu.
``input`` - only for ``render()``
The ``.search-input`` element used by the jump menu.
This example shows how a plugin might add a button for starting a new chat: This example shows how a plugin might add a button for starting a new chat:
@ -91,11 +84,11 @@ This example shows how a plugin might add a button for starting a new chat:
document.addEventListener('datasette_init', function(ev) { document.addEventListener('datasette_init', function(ev) {
ev.detail.registerPlugin('agent-plugin', { ev.detail.registerPlugin('agent-plugin', {
version: 0.1, version: 0.1,
makeJumpSections: (context) => { makeJumpSections: () => {
return [ return [
{ {
id: 'agent-chat', id: 'agent-chat',
render: (node, context) => { render: node => {
node.innerHTML = '<button type="button">Start a new chat</button>'; node.innerHTML = '<button type="button">Start a new chat</button>';
node.querySelector('button').addEventListener('click', () => { node.querySelector('button').addEventListener('click', () => {
location.href = '/-/agent/new'; location.href = '/-/agent/new';
@ -195,285 +188,6 @@ This example plugin adds two menu items - one to copy the column name to the cli
}); });
}); });
.. _javascript_plugins_makeColumnField:
makeColumnField(context)
~~~~~~~~~~~~~~~~~~~~~~~~
This method, if present, can provide a custom form field for a column in Datasette's row insert and edit dialogs.
It is designed for plugins that :ref:`register custom column types <plugin_register_column_types>` using the Python ``register_column_types()`` plugin hook. For example, a plugin that defines a ``file`` column type can use ``makeColumnField()`` to replace a plain text input with a file picker, and a plugin that defines a rich text column type can use it to enhance the field with an editor.
Datasette calls ``makeColumnField(context)`` on each registered JavaScript plugin when it renders an editable insert/edit field. Plugins should inspect the ``context`` object and only return a control object if they can handle that field. Otherwise, use a bare ``return;``.
The first plugin to return a truthy control object is used for that field. Plugins are called in registration order. If a plugin raises an exception, Datasette logs the error to the browser console and continues to the next plugin.
The row dialog tracks the value that will be sent to the insert/update API. The ``context`` object describes the column and form environment; custom controls should read and write field values using the ``field`` helper object passed to ``render(field)``.
Context object
^^^^^^^^^^^^^^
``makeColumnField(context)`` is called with a context object describing the field. The current context object has these keys:
``mode`` - string
``"insert"`` or ``"edit"``.
``database`` - string or null
The database name.
``table`` - string or null
The table name.
``tableUrl`` - string or null
The path to the table page, including any configured :ref:`base URL prefix <setting_base_url>`.
``column`` - string
The column name.
``columnType`` - object or null
The configured Datasette column type for this column, if one exists. This is ``null`` if no column type has been configured.
If present, this object has exactly these keys:
``type`` - string
The :ref:`registered column type name <plugin_register_column_types>`, matching the ``name`` attribute of the Python ``ColumnType`` subclass.
``config`` - object
Configuration for this specific column type assignment. This is ``{}`` if no configuration has been set.
``sqliteType`` - string or null
The SQLite affinity for this column, if known. This is one of ``"TEXT"``, ``"INTEGER"``, ``"REAL"``, ``"BLOB"``, ``"NUMERIC"`` or ``null`` if Datasette could not determine the affinity.
``notNull`` - boolean
True if the column is defined as ``NOT NULL``.
``isPk`` - boolean
True if this column is part of the table's primary key.
``defaultExpression`` - string or null
The SQLite default expression for the column, if available. This is ``null`` if the column has no SQLite default. For example, a column defined with ``DEFAULT (datetime('now'))`` will have ``"datetime('now')"`` here. This is the expression from the table schema, not the actual value SQLite will insert.
``form`` - ``HTMLFormElement`` or null
The row insert/edit form element.
``dialog`` - ``HTMLDialogElement`` or null
The modal dialog element.
Returned control object
^^^^^^^^^^^^^^^^^^^^^^^
A plugin that wants to handle a field should return an object. Datasette currently recognizes these properties:
``useTextarea`` - boolean, optional
If true, Datasette creates a ``<textarea>`` as the underlying ``field.input`` before calling ``render()``. If omitted, Datasette chooses either an ``<input>`` or ``<textarea>`` based on the column type and current value.
``render(field)`` - function
Called once to render the custom field UI. ``field`` is a helper object described below.
The recommended pattern is to return a DOM node from ``render()``. Datasette appends that node to ``field.root``, a ``<div>`` inside the control area for that field in the row insert/edit dialog. A plugin can alternatively manipulate ``field.root`` directly and return nothing.
``focus(field)`` - function, optional
Called when Datasette wants to focus this field, for example when focusing the first editable field in the dialog. Use this to focus the most useful interactive element inside the custom UI.
``destroy(field)`` - function, optional
Called when Datasette tears down the insert/edit form. Use this to remove event listeners, close nested pickers, revoke object URLs, clear timers, or release other resources.
The field helper object
^^^^^^^^^^^^^^^^^^^^^^^
The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy(field)`` provides stable IDs, DOM elements and value helpers for integrating with the row insert/edit dialog:
``context`` - object
The original context object passed to ``makeColumnField()``.
``id`` - string
The ID Datasette assigned to ``field.input``, the backing ``<input>`` or ``<textarea>`` element.
``labelId`` - string
The ID of the visible field label.
``descriptionId`` - string
The ID of the field metadata/help text. This metadata can include details such as ``Primary key``, ``Required``, ``Current value: NULL`` or ``Custom type: file``.
``root`` - ``HTMLElement``
The empty ``<div>`` container created by Datasette for this custom field. It is inside the control area for the field in the row insert/edit dialog, next to the field label and above the field metadata. Datasette appends the DOM node returned by ``render(field)`` to this element. Plugins can alternatively manipulate this element directly and return nothing from ``render(field)``.
``input`` - ``HTMLInputElement`` or ``HTMLTextAreaElement``
The core-owned backing form control. Plugins can keep this visible, wrap it or hide it, but should use the value helper methods below rather than mutating ``input.value`` directly.
``control``
An alias for ``input``.
``meta`` - ``HTMLElement`` or null
The field metadata/help text element.
``form`` - ``HTMLFormElement`` or null
The containing row insert/edit form.
``dialog`` - ``HTMLDialogElement`` or null
The containing modal dialog.
``getValue()`` - function
Returns the current value for this field.
Datasette uses string values by default. Insert fields for ``"INTEGER"`` and ``"REAL"`` SQLite columns return numbers, or ``null`` if left blank. Plugins can use strings, numbers, booleans or ``null``. If a plugin is editing structured data stored in a SQLite ``TEXT`` column, such as JSON, it should serialize that data to a string before calling ``setValue()``.
``setValue(value)`` - function
Sets the current value for this field. ``value`` should be a string, number, boolean or ``null``.
Calling ``setValue()`` also stops using the SQLite default for the field, if it was previously selected.
``getInitialValue()`` - function
Returns the submitted-value representation the field had when the form was rendered. For edit forms this is the raw row value from the database. For insert forms this is the blank starting value.
``hasChanged()`` - function
Returns true if the field has changed since Datasette last considered it unmodified. By default that means the field state when the insert/edit form was rendered.
``clearValue()`` - function
Sets the value to ``null``.
``markClean()`` - function
Tells Datasette to treat the field's current state as unmodified. After calling this method, ``hasChanged()`` returns false until the field value changes again or its SQLite-default state changes.
This is useful when a plugin rewrites the starting value into an equivalent representation while initializing its editor. For example, a rich text editor might normalize empty HTML or reserialize its initial document before the user has made any edits.
``isUsingSqliteDefault()`` - function
Returns true if the insert dialog is currently set to omit this column and use the SQLite default.
``setValidity(message)`` - function
Sets a custom validation message for this field, marks the backing input with ``aria-invalid="true"`` and shows the message in the field metadata area. Pass an empty string to clear the error.
``clearValidity()`` - function
Clears any custom validation message previously set by ``setValidity()``.
Submitted value contract
^^^^^^^^^^^^^^^^^^^^^^^^
The ``field.setValue()`` method accepts the following value types:
* string
* number
* boolean
* ``null``
These values are used as column values in requests to the :ref:`insert rows <TableInsertView>` and :ref:`update row <RowUpdateView>` JSON APIs.
Plugins should not pass objects or arrays to ``field.setValue()``. If a column stores structured data in SQLite, such as JSON in a ``TEXT`` column, the plugin should serialize that data first and submit the serialized string. Client-side parsing can still be useful for validation or editor state, but the submitted value should match the SQLite value Datasette should write.
Value helpers
^^^^^^^^^^^^^
Custom fields should use ``field.getValue()`` and ``field.setValue(value)`` for value handling:
.. code-block:: javascript
const currentValue = field.getValue();
field.setValue("new value");
field.setValue(null);
Plugins can keep the core input visible, wrap it in a custom element, or hide it and provide a richer interface. If the input is hidden, the custom UI must still expose an accessible name, state and keyboard interaction.
``field.setValue()`` updates both ``field.input`` and the value used in the insert/update request.
For example, a file picker that stores a selected file ID can hide the backing input and call ``field.setValue()`` when the selection changes:
.. code-block:: javascript
field.input.type = "hidden";
field.setValue(fileId);
For insert forms with a SQLite default, ``field.isUsingSqliteDefault()`` indicates whether Datasette will omit that column from the insert payload. Calling ``field.setValue(value)`` automatically stops using the SQLite default.
Lazy loading large controls
^^^^^^^^^^^^^^^^^^^^^^^^^^^
The JavaScript file that registers ``makeColumnField()`` should be small. If the actual control is large, load it from inside ``render()`` using dynamic ``import()``. That way the heavier code is only downloaded after a user opens an insert/edit dialog containing a matching column type.
.. code-block:: javascript
const editorUrl = new URL("./editor.js", import.meta.url).href;
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("my-editor", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "my-editor") {
return;
}
return {
useTextarea: true,
render(field) {
import(editorUrl).then(function () {
// Enhance field.input here.
});
return field.input;
}
};
}
});
});
Example: textarea-backed custom element
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This example handles a ``markdown-editor`` column type by asking Datasette for a textarea and wrapping that textarea in a custom ``<my-markdown-editor>`` Web Component element:
.. code-block:: javascript
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("markdown-editor", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "markdown-editor") {
return;
}
return {
useTextarea: true,
render(field) {
const editor = document.createElement("my-markdown-editor");
editor.appendChild(field.input);
if (field.labelId) {
field.input.setAttribute("aria-labelledby", field.labelId);
}
if (field.descriptionId) {
field.input.setAttribute("aria-describedby", field.descriptionId);
}
return editor;
},
focus(field) {
const editor = field.root.querySelector("my-markdown-editor");
if (editor && editor.focus) {
editor.focus();
} else {
field.input.focus();
}
}
};
}
});
});
Accessibility
^^^^^^^^^^^^^
Custom fields are responsible for preserving the accessibility of the form:
- The visible field label should name the control. Use ``field.labelId`` with ``aria-labelledby`` when wrapping or replacing the visible input.
- Field metadata should remain available to assistive technology. Use ``field.descriptionId`` with ``aria-describedby``.
- Keyboard users must be able to operate every part of the custom field.
- If the field opens an inline picker or other nested UI, ``Escape`` should close that nested UI first and return focus to a sensible element.
- If a control performs asynchronous loading, expose loading and error states in the UI. Use appropriate ARIA live regions where the state change is important to understand the field.
- If a plugin hides ``field.input``, the replacement UI must still make the current value and available actions clear.
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
.. _javascript_datasette_manager_selectors: .. _javascript_datasette_manager_selectors:
Selectors Selectors

File diff suppressed because it is too large Load diff

View file

@ -1,148 +0,0 @@
import asyncio
import json
import pathlib
import tempfile
import textwrap
def table_extras(cog):
from datasette.extras import ExtraScope
from datasette.views.table_extras import table_extra_registry
scopes = [
(
ExtraScope.TABLE,
"Table JSON responses",
"The available table extras are listed below.",
),
(
ExtraScope.ROW,
"Row JSON responses",
"The following extras are available for row JSON responses.",
),
(
ExtraScope.QUERY,
"Query JSON responses",
(
"The following extras are available for arbitrary SQL query "
"responses and stored, named query responses."
),
),
]
classes_by_scope = [
(scope, heading, intro, table_extra_registry.public_classes_for_scope(scope))
for scope, heading, intro in scopes
]
live_examples = asyncio.run(
_fetch_live_examples(
[
(scope, cls)
for scope, _, _, classes in classes_by_scope
for cls in classes
]
)
)
cog.out("\n")
for scope, heading, intro, classes in classes_by_scope:
cog.out("{}\n{}\n\n".format(heading, "~" * len(heading)))
cog.out("{}\n\n".format(intro))
for cls in classes:
examples = _examples_for_scope(cls, scope)
description = cls.description or ""
notes = []
if cls.expensive:
notes.append("May execute additional queries.")
if cls.docs_note:
notes.append(cls.docs_note)
if notes:
description = "{} ({})".format(description, " ".join(notes)).strip()
cog.out("``{}``\n".format(cls.key()))
cog.out(" {}\n\n".format(description))
for example in examples:
if example.path:
value = live_examples[(example.path, example.key or cls.key())]
cog.out(" ``GET {}``\n\n".format(example.path))
else:
value = example.value
if example.note:
cog.out(" {}\n\n".format(example.note))
cog.out(" .. code-block:: json\n\n")
cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
cog.out("\n\n")
def _examples_for_scope(cls, scope):
examples = cls.example_for_scope(scope)
if examples is None:
return []
if isinstance(examples, list):
return examples
return [examples]
async def _fetch_live_examples(scoped_classes):
from datasette.app import Datasette
from datasette.fixtures import write_fixture_database
examples = {}
with tempfile.TemporaryDirectory() as tmpdir:
db_path = pathlib.Path(tmpdir) / "fixtures.db"
write_fixture_database(db_path)
datasette = Datasette(
[str(db_path)],
settings={"num_sql_threads": 1},
metadata={
"databases": {
"fixtures": {
"tables": {
"facetable": {
"description": "A demo table of places, used to demonstrate facets",
"columns": {"state": "Two letter US state code"},
}
}
}
}
},
config={
"databases": {
"fixtures": {
"tables": {
"facetable": {
"column_types": {"tags": "json"},
}
},
"queries": {
"neighborhood_search": {
"sql": textwrap.dedent("""
select _neighborhood, facet_cities.name, state
from facetable
join facet_cities
on facetable._city_id = facet_cities.id
where _neighborhood like '%' || :text || '%'
order by _neighborhood;
"""),
"title": "Search neighborhoods",
}
},
}
}
},
)
try:
for scope, cls in scoped_classes:
for example in _examples_for_scope(cls, scope):
if not example.path:
continue
key = example.key or cls.key()
response = await datasette.client.get(example.path)
assert response.status_code == 200, example.path
data = response.json()
assert key in data, "{} missing from {}".format(key, example.path)
examples[(example.path, key)] = data[key]
finally:
for db in datasette.databases.values():
if not db.is_memory:
db.close()
return examples

View file

@ -28,7 +28,7 @@ The index page can also be accessed at ``/-/``, useful for if the default index
Database Database
======== ========
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. 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.
Examples: Examples:
@ -62,43 +62,16 @@ The following tables are hidden by default:
Queries Queries
======= =======
.. _pages_custom_sql_queries:
Custom SQL queries
------------------
The ``/database-name/-/query`` page can be used to execute an arbitrary SQL query against that database, if the :ref:`actions_execute_sql` permission is enabled. This query is passed as the ``?sql=`` query string parameter. The ``/database-name/-/query`` page can be used to execute an arbitrary SQL query against that database, if the :ref:`actions_execute_sql` permission is enabled. This query is passed as the ``?sql=`` query string parameter.
This means you can link directly to a query by constructing the following URL: This means you can link directly to a query by constructing the following URL:
``/database-name/-/query?sql=SELECT+*+FROM+table_name`` ``/database-name/-/query?sql=SELECT+*+FROM+table_name``
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. 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.
In both cases adding a ``.json`` extension to the URL will return the results as JSON. In both cases adding a ``.json`` extension to the URL will return the results as JSON.
.. _pages_execute_write:
Write SQL queries
-----------------
The ``/database-name/-/execute-write`` page can be used to execute SQL statements that write to a mutable database, if the :ref:`actions_execute_write_sql` permission is enabled.
This page extracts named parameters from the SQL, shows the tables that will be affected and lists the permissions required before the query can be executed. It also includes templates for common ``INSERT``, ``UPDATE`` and ``DELETE`` statements.
Datasette checks additional permissions based on the operations in the SQL. Row changes require the relevant table-level permissions such as :ref:`actions_insert_row`, :ref:`actions_update_row` and :ref:`actions_delete_row`; reads from source tables require :ref:`actions_view_table`; and schema changes require permissions such as :ref:`actions_create_table`, :ref:`actions_alter_table` or :ref:`actions_drop_table`.
Use the :ref:`ExecuteWriteView` JSON API to execute writable SQL programmatically.
.. _pages_stored_query_browser:
Stored query browsers
---------------------
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database.
These pages support search, pagination and filters for read-only or writable queries and private or public queries. Adding a ``.json`` extension to either URL returns the same list as JSON.
.. _TableView: .. _TableView:
Table Table
@ -118,16 +91,6 @@ Some examples:
* `../antiquities-act%2Factions_under_antiquities_act <https://fivethirtyeight.datasettes.com/fivethirtyeight/antiquities-act%2Factions_under_antiquities_act>`_ is an interface for exploring the "actions under the antiquities act" data table published by FiveThirtyEight. * `../antiquities-act%2Factions_under_antiquities_act <https://fivethirtyeight.datasettes.com/fivethirtyeight/antiquities-act%2Factions_under_antiquities_act>`_ is an interface for exploring the "actions under the antiquities act" data table published by FiveThirtyEight.
* `../global-power-plants?country_long=United+Kingdom&primary_fuel=Gas <https://datasette.io/global-power-plants/global-power-plants?_facet=primary_fuel&_facet=owner&_facet=country_long&country_long__exact=United+Kingdom&primary_fuel=Gas>`_ is a filtered table page showing every Gas power plant in the United Kingdom. It includes some default facets (configured using `its metadata.json <https://datasette.io/-/metadata>`_) and uses the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`_ plugin to show a map of the results. * `../global-power-plants?country_long=United+Kingdom&primary_fuel=Gas <https://datasette.io/global-power-plants/global-power-plants?_facet=primary_fuel&_facet=owner&_facet=country_long&country_long__exact=United+Kingdom&primary_fuel=Gas>`_ is a filtered table page showing every Gas power plant in the United Kingdom. It includes some default facets (configured using `its metadata.json <https://datasette.io/-/metadata>`_) and uses the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`_ plugin to show a map of the results.
.. _TableFragmentView:
Table fragment
--------------
The ``/<database>/<table>/-/fragment`` endpoint returns the rendered table HTML
for rows matching the provided filters. It is used by Datasette's row editing
interface to refresh rows after changes while still respecting custom table
templates and ``render_cell`` plugin hooks.
.. _RowView: .. _RowView:
Row Row

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. The SQL query that was executed.
``query_name`` - string or None ``query_name`` - string or None
If this was the execution of a :ref:`stored query <stored_queries>`, the name of that query. If this was the execution of a :ref:`canned query <canned_queries>`, the name of that query.
``database`` - string ``database`` - string
The name of the database. The name of the database.
@ -1092,7 +1092,7 @@ Column types are assigned to columns via the :ref:`column_types <table_configura
config: config:
format: rgb format: rgb
Datasette includes four built-in column types: ``url``, ``email``, ``json``, and ``textarea``. The ``textarea`` type is an editing hint that causes Datasette's insert/edit forms to use a multiline ``<textarea>`` control for that column. Datasette includes three built-in column types: ``url``, ``email``, and ``json``.
.. _plugin_asgi_wrapper: .. _plugin_asgi_wrapper:
@ -1207,6 +1207,85 @@ Potential use-cases:
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__ 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: .. _plugin_hook_actor_from_request:
actor_from_request(datasette, request) actor_from_request(datasette, request)
@ -1625,7 +1704,7 @@ register_magic_parameters(datasette)
``datasette`` - :ref:`internals_datasette` ``datasette`` - :ref:`internals_datasette`
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.
: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. :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.
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. 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.
@ -1818,7 +1897,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. 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 stored 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 canned 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. ``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.
@ -1863,7 +1942,7 @@ This example returns a SQL fragment that searches rows from a ``dashboards`` tab
SELECT SELECT
'dashboard' AS type, 'dashboard' AS type,
slug AS label, slug AS label,
description, description AS description,
json_object( json_object(
'method', 'row', 'method', 'row',
'database', 'content', 'database', 'content',
@ -1909,80 +1988,7 @@ Action hooks
Action hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() <plugin_hook_menu_links>`, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing. Action hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() <plugin_hook_menu_links>`, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing.
Each of these hooks should return a list of menu items, with optional ``"description": "..."`` keys describing each action in more detail. Each of these hooks should return return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail.
The most common action item is a link to another page:
.. code-block:: python
{
"href": datasette.urls.path("/-/custom-action"),
"label": "Custom action",
"description": "Run this action on a separate page.",
}
Plugins can also return button actions for JavaScript-backed interactions:
.. code-block:: python
{
"type": "button",
"label": "Open custom dialog",
"description": "Show a dialog without leaving this page.",
"attrs": {
"aria-label": "Open custom dialog",
"data-plugin-action": "open-custom-dialog",
},
}
These are rendered as ``<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1">``. The optional ``attrs`` dictionary is added to the button, and is useful for ``data-*`` attributes that your plugin's JavaScript can use to attach event handlers.
Here is a minimal plugin example that adds a button to a table page and loads JavaScript to handle clicks on that button:
.. code-block:: python
from datasette import hookimpl
@hookimpl
def table_actions(datasette, database, table):
return [
{
"type": "button",
"label": "Show table name",
"description": "Open a JavaScript-powered plugin action.",
"attrs": {
"aria-label": "Show table name",
"data-plugin-action": "show-table-name",
"data-database": database,
"data-table": table,
},
}
]
@hookimpl
def extra_js_urls(datasette):
return [
datasette.urls.static_plugins(
"datasette_show_table",
"show-table.js",
)
]
The ``static/show-table.js`` file in that plugin could look like this:
.. code-block:: javascript
document.addEventListener("click", (event) => {
const button = event.target.closest(
"button[data-plugin-action='show-table-name']"
);
if (!button) {
return;
}
alert(`${button.dataset.database}.${button.dataset.table}`);
});
They can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items. They can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items.
@ -2067,7 +2073,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params)
The name of the database. The name of the database.
``query_name`` - string or None ``query_name`` - string or None
The name of the stored query, or ``None`` if this is an arbitrary SQL query. The name of the canned query, or ``None`` if this is an arbitrary SQL query.
``request`` - :ref:`internals_request` ``request`` - :ref:`internals_request`
The current HTTP request. The current HTTP request.
@ -2078,7 +2084,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params)
``params`` - dictionary ``params`` - dictionary
The parameters passed to the SQL query, if any. The parameters passed to the SQL query, if any.
Populates a "Query actions" menu on the stored query and arbitrary SQL query pages. Populates a "Query actions" menu on the canned query and arbitrary SQL query pages.
This example adds a new query action linking to a page for explaining a query: This example adds a new query action linking to a page for explaining a query:
@ -2342,9 +2348,9 @@ top_query(datasette, request, database, sql)
Returns HTML to be displayed at the top of the query results page. Returns HTML to be displayed at the top of the query results page.
.. _plugin_hook_top_stored_query: .. _plugin_hook_top_canned_query:
top_stored_query(datasette, request, database, query_name) top_canned_query(datasette, request, database, query_name)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``datasette`` - :ref:`internals_datasette` ``datasette`` - :ref:`internals_datasette`
@ -2357,9 +2363,9 @@ top_stored_query(datasette, request, database, query_name)
The name of the database. The name of the database.
``query_name`` - string ``query_name`` - string
The name of the stored query. The name of the canned query.
Returns HTML to be displayed at the top of the stored query page. Returns HTML to be displayed at the top of the canned query page.
.. _plugin_event_tracking: .. _plugin_event_tracking:

View file

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

View file

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

View file

@ -7,8 +7,6 @@ Datasette treats SQLite database files as read-only and immutable. This means it
The easiest way to execute custom SQL against Datasette is through the web UI. The database index page includes a SQL editor that lets you run any SELECT query you like. You can also construct queries using the filter interface on the tables page, then click "View and edit SQL" to open that query in the custom SQL editor. The easiest way to execute custom SQL against Datasette is through the web UI. The database index page includes a SQL editor that lets you run any SELECT query you like. You can also construct queries using the filter interface on the tables page, then click "View and edit SQL" to open that query in the custom SQL editor.
For mutable databases, actors with the appropriate permissions can use the :ref:`write SQL page <pages_execute_write>` to execute SQL statements that insert, update or delete rows.
Note that this interface is only available if the :ref:`actions_execute_sql` permission is allowed. See :ref:`authentication_permissions_execute_sql`. Note that this interface is only available if the :ref:`actions_execute_sql` permission is allowed. See :ref:`authentication_permissions_execute_sql`.
Any Datasette SQL query is reflected in the URL of the page, allowing you to bookmark them, share them with others and navigate through previous queries using your browser back button. Any Datasette SQL query is reflected in the URL of the page, allowing you to bookmark them, share them with others and navigate through previous queries using your browser back button.
@ -68,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" sqlite-utils create-view sf-trees.db demo_view "select qSpecies from Street_Tree_List"
.. _queries: .. _canned_queries:
Queries Canned queries
------- --------------
As an alternative to adding views to your database, you can define named queries inside your ``datasette.yaml`` file. Here's an example: As an alternative to adding views to your database, you can define canned queries inside your ``datasette.yaml`` file. Here's an example:
.. [[[cog .. [[[cog
from metadata_doc import config_example, config_example from metadata_doc import config_example, config_example
@ -122,76 +120,24 @@ Then run Datasette like this::
datasette sf-trees.db -m metadata.json datasette sf-trees.db -m metadata.json
Each configured query will be listed on the database index page, and will also get its own URL at:: Each canned query will be listed on the database index page, and will also get its own URL at::
/database-name/query-name /database-name/canned-query-name
For the above example, that URL would be:: For the above example, that URL would be::
/sf-trees/just_species /sf-trees/just_species
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). 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).
.. _stored_queries: .. _canned_queries_named_parameters:
.. _saved_queries:
Stored queries Canned query parameters
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~
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. 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.
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. Here's an example of a canned query with a named parameter:
Editing and deleting stored queries
+++++++++++++++++++++++++++++++++++
The page for a stored query includes a "Query actions" menu with **Edit this query** and **Delete this query** links for actors who have permission to use them.
The owner of a stored query can always edit and delete it. For queries that are not private, any actor granted the ``update-query`` or ``delete-query`` permission can edit or delete the query, even if they did not create it. Private queries can only be edited or deleted by their owner, regardless of any broad permission grants.
Editing a query lets you change its title, description, SQL and whether it is private. Changing the SQL also requires the ``execute-sql`` permission (and the relevant write permissions for writable queries). The same operations are available through the JSON API by sending a ``POST`` to ``/<database>/<query>/-/update`` or ``/<database>/<query>/-/delete``. Trusted stored queries cannot be edited or deleted through the web interface or the JSON API.
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. SQL functions are allowed and are not separately restricted by Datasette 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 .. code-block:: sql
@ -201,7 +147,7 @@ Here's an example of a configured query with a named parameter:
where neighborhood like '%' || :text || '%' where neighborhood like '%' || :text || '%'
order by neighborhood; order by neighborhood;
The query configuration looks like this: In the canned query configuration looks like this:
.. [[[cog .. [[[cog
@ -258,7 +204,7 @@ The 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. 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 query out here: You can try this canned query out here:
https://latest.datasette.io/fixtures/neighborhood_search?text=town 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. In this example the ``:text`` named parameter is automatically extracted from the query using a regular expression.
@ -324,17 +270,17 @@ You can alternatively provide an explicit list of named parameters using the ``"
} }
.. [[[end]]] .. [[[end]]]
.. _queries_options: .. _canned_queries_options:
Additional query options Additional canned query options
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Additional options can be specified for configured queries in the YAML or JSON configuration. Additional options can be specified for canned queries in the YAML or JSON configuration.
hide_sql hide_sql
++++++++ ++++++++
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. 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.
Add the ``"hide_sql": true`` option to hide the SQL query by default. Add the ``"hide_sql": true`` option to hide the SQL query by default.
@ -343,7 +289,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. 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 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 canned query from the database index page using the ``"fragment"`` key.
This example demonstrates both ``fragment`` and ``hide_sql``: This example demonstrates both ``fragment`` and ``hide_sql``:
@ -400,14 +346,14 @@ This example demonstrates both ``fragment`` and ``hide_sql``:
`See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action. `See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action.
.. _queries_writable: .. _canned_queries_writable:
Writable queries Writable canned queries
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~
Configured queries are read-only by default. You can use the ``"write": true`` key to indicate that a query can write to the database. 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.
See :ref:`authentication_permissions_query` for details on how to add permission checks to queries, using the ``"allow"`` key. See :ref:`authentication_permissions_query` for details on how to add permission checks to canned queries, using the ``"allow"`` key.
.. [[[cog .. [[[cog
config_example(cog, { config_example(cog, {
@ -535,14 +481,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. 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.
.. _queries_magic_parameters: .. _canned_queries_magic_parameters:
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. 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 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. 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.
Available magic parameters are: Available magic parameters are:
@ -632,14 +578,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. Additional custom magic parameters can be added by plugins using the :ref:`plugin_hook_register_magic_parameters` hook.
.. _queries_json_api: .. _canned_queries_json_api:
JSON API for writable queries JSON API for writable canned queries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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. 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.
To submit JSON to a writable query, encode key/value parameters as a JSON document:: To submit JSON to a writable canned query, encode key/value parameters as a JSON document::
POST /mydatabase/add_message POST /mydatabase/add_message

View file

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

View file

@ -36,7 +36,7 @@ dependencies = [
"mergedeep>=1.1.1", "mergedeep>=1.1.1",
"itsdangerous>=1.1", "itsdangerous>=1.1",
"sqlite-utils>=3.30", "sqlite-utils>=3.30",
"asyncinject>=0.7", "asyncinject>=0.6.1",
"setuptools", "setuptools",
"pip", "pip",
] ]
@ -81,9 +81,6 @@ dev = [
"ruamel.yaml", "ruamel.yaml",
"psutil>=5.9", "psutil>=5.9",
] ]
playwright = [
"pytest-playwright>=0.8.0",
]
[project.optional-dependencies] [project.optional-dependencies]
rich = ["rich"] rich = ["rich"]

View file

@ -6,5 +6,4 @@ filterwarnings=
ignore:Using or importing the ABCs::bs4.element ignore:Using or importing the ABCs::bs4.element
markers = markers =
serial: tests to avoid using with pytest-xdist serial: tests to avoid using with pytest-xdist
playwright: browser automation tests, skipped unless --playwright is passed asyncio_mode = strict
asyncio_mode = strict

View file

@ -93,26 +93,7 @@ def pytest_report_header(config):
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
version = conn.execute("select sqlite_version()").fetchone()[0] version = conn.execute("select sqlite_version()").fetchone()[0]
conn.close() conn.close()
headers = ["SQLite: {}".format(version)] return "SQLite: {}".format(version)
if config.getoption("--playwright"):
try:
browsers = config.getoption("--browser")
except ValueError:
browsers = None
if isinstance(browsers, str):
browsers = [browsers]
if browsers:
headers.append("Playwright browsers: {}".format(", ".join(browsers)))
return headers
def pytest_addoption(parser):
parser.addoption(
"--playwright",
action="store_true",
default=False,
help="run Playwright browser automation tests",
)
def pytest_configure(config): def pytest_configure(config):
@ -127,13 +108,7 @@ def pytest_unconfigure(config):
del sys._called_from_test del sys._called_from_test
def pytest_collection_modifyitems(config, items): def pytest_collection_modifyitems(items):
if not config.getoption("--playwright"):
skip_playwright = pytest.mark.skip(reason="need --playwright option to run")
for item in items:
if "playwright" in item.keywords:
item.add_marker(skip_playwright)
# Ensure test_cli.py and test_black.py and test_inspect.py run first before any asyncio code kicks in # Ensure test_cli.py and test_black.py and test_inspect.py run first before any asyncio code kicks in
move_to_front(items, "test_cli") move_to_front(items, "test_cli")
move_to_front(items, "test_black") move_to_front(items, "test_black")
@ -171,7 +146,6 @@ def restore_working_directory(tmpdir, request):
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def check_actions_are_documented(): def check_actions_are_documented():
from datasette.plugins import pm from datasette.plugins import pm
from datasette.default_actions import register_actions as default_register_actions
content = ( content = (
pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst" pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst"
@ -180,9 +154,6 @@ def check_actions_are_documented():
documented_actions = set(permissions_re.findall(content)).union( documented_actions = set(permissions_re.findall(content)).union(
UNDOCUMENTED_PERMISSIONS UNDOCUMENTED_PERMISSIONS
) )
# Only Datasette core actions need to be documented - actions registered
# by (test) plugins are checked for registration but not documentation
core_actions = {action.name for action in default_register_actions()}
def before(hook_name, hook_impls, kwargs): def before(hook_name, hook_impls, kwargs):
if hook_name == "permission_resources_sql": if hook_name == "permission_resources_sql":
@ -194,10 +165,9 @@ def check_actions_are_documented():
+ " (or maybe a test forgot to do await ds.invoke_startup())" + " (or maybe a test forgot to do await ds.invoke_startup())"
) )
action = kwargs.get("action").replace("-", "_") action = kwargs.get("action").replace("-", "_")
if kwargs["action"] in core_actions: assert (
assert ( action in documented_actions
action in documented_actions ), "Undocumented permission action: {}".format(action)
), "Undocumented permission action: {}".format(action)
pm.add_hookcall_monitoring( pm.add_hookcall_monitoring(
before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None

View file

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

View file

@ -314,6 +314,11 @@ def startup(datasette):
_ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow) _ = (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 @hookimpl
def register_magic_parameters(): def register_magic_parameters():
from uuid import uuid4 from uuid import uuid4
@ -357,30 +362,15 @@ def menu_links(datasette, actor, request):
@hookimpl @hookimpl
def table_actions(datasette, database, table, actor, request): def table_actions(datasette, database, table, actor):
if actor: if actor:
actions = [ return [
{ {
"href": datasette.urls.instance(), "href": datasette.urls.instance(),
"label": f"Database: {database}", "label": f"Database: {database}",
}, },
{"href": datasette.urls.instance(), "label": f"Table: {table}"}, {"href": datasette.urls.instance(), "label": f"Table: {table}"},
] ]
if request.args.get("_button"):
actions.append(
{
"type": "button",
"label": "Plugin button",
"description": "Runs JavaScript from a plugin",
"attrs": {
"aria-label": "Plugin button for {}".format(table),
"data-plugin-action": "plugin-button",
"data-database": database,
"data-table": table,
},
}
)
return actions
@hookimpl @hookimpl
@ -397,8 +387,8 @@ def view_actions(datasette, database, view, actor):
@hookimpl @hookimpl
def query_actions(datasette, database, query_name, sql): def query_actions(datasette, database, query_name, sql):
# Don't explain an explain (or a missing query) # Don't explain an explain
if not sql or sql.lower().startswith("explain"): if sql.lower().startswith("explain"):
return return
return [ return [
{ {

View file

@ -143,6 +143,20 @@ def startup(datasette):
return inner 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) @hookimpl(trylast=True)
def menu_links(datasette, actor): def menu_links(datasette, actor):
async def inner(): async def inner():

View file

@ -12,22 +12,10 @@ import pytest
import pytest_asyncio import pytest_asyncio
from datasette.app import Datasette from datasette.app import Datasette
from datasette.permissions import PermissionSQL from datasette.permissions import PermissionSQL
from datasette.resources import DatabaseResource, QueryResource, TableResource from datasette.resources import TableResource
from datasette import hookimpl from datasette import hookimpl
def test_resource_string_representations():
assert str(DatabaseResource("content")) == "content"
assert repr(DatabaseResource("content")) == (
"DatabaseResource(parent='content', child=None)"
)
assert str(TableResource("content", "dogs")) == "content/dogs"
assert repr(TableResource("content", "dogs")) == (
"TableResource(parent='content', child='dogs')"
)
assert str(QueryResource("content", "insert-a-dog")) == "content/insert-a-dog"
# Test plugin that provides permission rules # Test plugin that provides permission rules
class PermissionRulesPlugin: class PermissionRulesPlugin:
def __init__(self, rules_callback): def __init__(self, rules_callback):

View file

@ -1,707 +0,0 @@
"""
Tests for request-scoped permission check memoization and the
datasette.allowed_many() batch permission API.
Layer 1: per-request cache consulted by datasette.allowed()
Layer 2: allowed_many() resolves multiple actions in one internal-DB query
Layer 3: table/database views precompute all registered actions before
invoking table_actions/database_actions plugin hooks
"""
import pytest
import pytest_asyncio
from datasette.app import Datasette
from datasette.permissions import (
Action,
PermissionSQL,
SkipPermissions,
_permission_check_cache,
)
from datasette.resources import DatabaseResource, TableResource
from datasette import hookimpl
class CountingRulesPlugin:
"""Counts permission_resources_sql gathers and grants rules for alice."""
def __init__(self):
self.calls = []
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
actor_id = actor.get("id") if actor else None
self.calls.append((actor_id, action))
if actor_id == "alice":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'alice allowed' AS reason"
)
return None
def count(self, actor_id=None, action=None):
return len(
[
(a, c)
for a, c in self.calls
if (actor_id is None or a == actor_id)
and (action is None or c == action)
]
)
@pytest_asyncio.fixture
async def ds():
ds = Datasette()
await ds.invoke_startup()
db = ds.add_memory_database("analytics")
await db.execute_write("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY)")
await db.execute_write("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY)")
await ds._refresh_schemas()
return ds
@pytest_asyncio.fixture
async def counting_ds(ds):
plugin = CountingRulesPlugin()
ds.pm.register(plugin, name="counting")
try:
yield ds, plugin
finally:
ds.pm.unregister(name="counting")
# ----------------------------------------------------------------------
# Layer 1: request-scoped memoization
# ----------------------------------------------------------------------
@pytest.mark.asyncio
async def test_allowed_memoized_when_cache_active(counting_ds):
ds, plugin = counting_ds
resource = TableResource("analytics", "users")
token = _permission_check_cache.set({})
try:
first = await ds.allowed(
action="view-table", resource=resource, actor={"id": "alice"}
)
gathers_after_first = plugin.count(actor_id="alice", action="view-table")
assert gathers_after_first > 0
second = await ds.allowed(
action="view-table", resource=resource, actor={"id": "alice"}
)
assert first is True
assert second is True
# The second identical check must not gather hooks again
assert plugin.count(actor_id="alice", action="view-table") == (
gathers_after_first
)
finally:
_permission_check_cache.reset(token)
@pytest.mark.asyncio
async def test_allowed_not_memoized_without_cache(counting_ds):
ds, plugin = counting_ds
resource = TableResource("analytics", "users")
assert _permission_check_cache.get() is None
await ds.allowed(action="view-table", resource=resource, actor={"id": "alice"})
first_count = plugin.count(actor_id="alice", action="view-table")
await ds.allowed(action="view-table", resource=resource, actor={"id": "alice"})
# No request cache active - hooks gathered again
assert plugin.count(actor_id="alice", action="view-table") == first_count * 2
@pytest.mark.asyncio
async def test_cache_keyed_on_full_actor_identity(counting_ds):
"""Interleaved checks for different actors never share cache entries."""
# Uses drop-table because default permissions deny it to non-root actors
ds, plugin = counting_ds
resource = TableResource("analytics", "users")
token = _permission_check_cache.set({})
try:
assert (
await ds.allowed(
action="drop-table", resource=resource, actor={"id": "alice"}
)
is True
)
assert (
await ds.allowed(
action="drop-table", resource=resource, actor={"id": "bob"}
)
is False
)
# Repeat interleaved - cached results must stay correct per actor
assert (
await ds.allowed(
action="drop-table", resource=resource, actor={"id": "alice"}
)
is True
)
assert (
await ds.allowed(
action="drop-table", resource=resource, actor={"id": "bob"}
)
is False
)
# Actors differing in fields beyond id must not collide either
assert (
await ds.allowed(
action="drop-table",
resource=resource,
actor={"id": "alice", "_r": {"a": []}},
)
is False
)
finally:
_permission_check_cache.reset(token)
@pytest.mark.asyncio
async def test_cache_keyed_on_resource(counting_ds):
ds, plugin = counting_ds
token = _permission_check_cache.set({})
try:
await ds.allowed(
action="view-table",
resource=TableResource("analytics", "users"),
actor={"id": "alice"},
)
count = plugin.count(actor_id="alice", action="view-table")
# Different resource - must not be served from cache
await ds.allowed(
action="view-table",
resource=TableResource("analytics", "events"),
actor={"id": "alice"},
)
assert plugin.count(actor_id="alice", action="view-table") == count * 2
finally:
_permission_check_cache.reset(token)
@pytest.mark.asyncio
async def test_skip_permission_checks_bypasses_cache(counting_ds):
ds, plugin = counting_ds
resource = TableResource("analytics", "users")
token = _permission_check_cache.set({})
try:
with SkipPermissions():
assert (
await ds.allowed(
action="drop-table", resource=resource, actor={"id": "bob"}
)
is True
)
# The skip-mode True must not have been cached
assert (
await ds.allowed(
action="drop-table", resource=resource, actor={"id": "bob"}
)
is False
)
finally:
_permission_check_cache.reset(token)
# ----------------------------------------------------------------------
# Layer 2: allowed_many()
# ----------------------------------------------------------------------
class MatrixRulesPlugin:
"""Different rules per action for actor carol, to exercise resolution."""
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if not actor or actor.get("id") != "carol":
return None
if action == "view-table":
return PermissionSQL(sql="""
SELECT NULL AS parent, NULL AS child, 1 AS allow, 'global allow' AS reason
UNION ALL
SELECT 'analytics' AS parent, 'sensitive' AS child, 0 AS allow, 'deny sensitive' AS reason
""")
if action == "insert-row":
return PermissionSQL(
sql="SELECT 'analytics' AS parent, NULL AS child, 1 AS allow, 'analytics writes' AS reason"
)
# Everything else: no opinion (implicit deny unless defaults allow)
return None
@pytest.mark.asyncio
async def test_allowed_many_basic(ds):
plugin = MatrixRulesPlugin()
ds.pm.register(plugin, name="matrix")
try:
results = await ds.allowed_many(
actions=["view-table", "insert-row", "drop-table"],
resource=TableResource("analytics", "users"),
actor={"id": "carol"},
)
assert results == {
"view-table": True,
"insert-row": True,
"drop-table": False,
}
# Child-level deny beats global allow
sensitive = await ds.allowed_many(
actions=["view-table"],
resource=TableResource("analytics", "sensitive"),
actor={"id": "carol"},
)
assert sensitive == {"view-table": False}
finally:
ds.pm.unregister(name="matrix")
@pytest.mark.asyncio
async def test_allowed_many_matches_allowed(ds):
"""Every action resolved by allowed_many() must match allowed()."""
plugin = MatrixRulesPlugin()
ds.pm.register(plugin, name="matrix")
try:
all_actions = list(ds.actions)
for resource in (
TableResource("analytics", "users"),
TableResource("analytics", "sensitive"),
DatabaseResource("analytics"),
):
batched = await ds.allowed_many(
actions=all_actions, resource=resource, actor={"id": "carol"}
)
assert set(batched) == set(all_actions)
for action in all_actions:
individual = await ds.allowed(
action=action, resource=resource, actor={"id": "carol"}
)
assert (
batched[action] == individual
), f"Mismatch for {action} on {resource}"
finally:
ds.pm.unregister(name="matrix")
@pytest.mark.asyncio
async def test_allowed_many_unknown_action_raises(ds):
with pytest.raises(ValueError, match="Unknown action"):
await ds.allowed_many(
actions=["view-table", "no-such-action"],
resource=TableResource("analytics", "users"),
actor=None,
)
@pytest.mark.asyncio
async def test_allowed_many_empty_actions(ds):
assert (
await ds.allowed_many(
actions=[], resource=TableResource("analytics", "users"), actor=None
)
== {}
)
class AlsoRequiresRulesPlugin:
"""dave: store-query allowed but execute-sql explicitly denied.
erin: store-query allowed (execute-sql stays default-allowed)."""
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
actor_id = actor.get("id") if actor else None
if actor_id == "dave":
if action == "store-query":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'dave can store' AS reason"
)
if action == "execute-sql":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 0 AS allow, 'dave no sql' AS reason"
)
if actor_id == "erin" and action == "store-query":
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'erin can store' AS reason"
)
return None
@pytest.mark.asyncio
async def test_allowed_many_also_requires(ds):
# store-query also_requires execute-sql, which also_requires view-database
plugin = AlsoRequiresRulesPlugin()
ds.pm.register(plugin, name="also_requires")
try:
resource = DatabaseResource("analytics")
dave = await ds.allowed_many(
actions=["store-query", "execute-sql", "view-database"],
resource=resource,
actor={"id": "dave"},
)
# execute-sql denied, so store-query must be denied too
assert dave == {
"store-query": False,
"execute-sql": False,
"view-database": True,
}
erin = await ds.allowed_many(
actions=["store-query"], resource=resource, actor={"id": "erin"}
)
assert erin == {"store-query": True}
# Must match the single-check path
assert (
await ds.allowed(
action="store-query", resource=resource, actor={"id": "dave"}
)
is False
)
assert (
await ds.allowed(
action="store-query", resource=resource, actor={"id": "erin"}
)
is True
)
finally:
ds.pm.unregister(name="also_requires")
@pytest.mark.asyncio
async def test_allowed_many_respects_restrictions(ds):
"""Token-style _r restrictions are enforced within the batch."""
actor = {"id": "root", "_r": {"d": {"analytics": ["vt"]}}}
results = await ds.allowed_many(
actions=["view-table", "drop-table"],
resource=TableResource("analytics", "users"),
actor=actor,
)
# root could normally do both, but the token only allows view-table
# on the analytics database
assert results == {"view-table": True, "drop-table": False}
other_db = await ds.allowed_many(
actions=["view-table"],
resource=TableResource("production", "stuff"),
actor=actor,
)
assert other_db == {"view-table": False}
# Equivalence with allowed()
assert (
await ds.allowed(
action="view-table",
resource=TableResource("analytics", "users"),
actor=actor,
)
is True
)
assert (
await ds.allowed(
action="drop-table",
resource=TableResource("analytics", "users"),
actor=actor,
)
is False
)
class ParamCollisionPlugin:
"""Same parameter name with a different value for every action."""
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if not actor or actor.get("id") != "paula":
return None
flag = 1 if action in ("drop-table", "insert-row") else 0
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, :flag AS allow, 'flagged' AS reason",
params={"flag": flag},
)
@pytest.mark.asyncio
async def test_allowed_many_namespaces_params_across_actions(ds):
"""40+ actions whose rules use identical param names must not collide."""
plugin = ParamCollisionPlugin()
ds.pm.register(plugin, name="collision")
try:
all_actions = list(ds.actions)
assert len(all_actions) >= 15
resource = TableResource("analytics", "users")
results = await ds.allowed_many(
actions=all_actions, resource=resource, actor={"id": "paula"}
)
# Spot-check: only the flagged actions resolve True
assert results["drop-table"] is True
assert results["create-table"] is False
# Full equivalence against single checks
for action in all_actions:
assert results[action] == await ds.allowed(
action=action, resource=resource, actor={"id": "paula"}
), f"Mismatch for {action}"
finally:
ds.pm.unregister(name="collision")
@pytest.mark.asyncio
async def test_allowed_many_single_internal_db_query(ds):
internal_db = ds.get_internal_database()
calls = []
original_execute = internal_db.execute
async def counting_execute(sql, params=None, **kwargs):
calls.append(sql)
return await original_execute(sql, params, **kwargs)
internal_db.execute = counting_execute
try:
results = await ds.allowed_many(
actions=["view-table", "insert-row", "delete-row", "drop-table"],
resource=TableResource("analytics", "users"),
actor={"id": "root", "_r": {"d": {"analytics": ["vt"]}}},
)
assert len(results) == 4
assert len(calls) == 1
finally:
internal_db.execute = original_execute
@pytest.mark.asyncio
async def test_allowed_many_no_query_when_no_rules(ds):
"""Actions with no rules from any plugin are denied without SQL.
Restrictions can only restrict, never grant, so an action with no
rule rows is always False - it should not contribute to the query,
and if no action has rules there should be no query at all."""
internal_db = ds.get_internal_database()
calls = []
original_execute = internal_db.execute
async def counting_execute(sql, params=None, **kwargs):
calls.append(sql)
return await original_execute(sql, params, **kwargs)
internal_db.execute = counting_execute
try:
# bob gets no rules at all for these write actions
results = await ds.allowed_many(
actions=["drop-table", "delete-row"],
resource=TableResource("analytics", "users"),
actor={"id": "bob"},
)
assert results == {"drop-table": False, "delete-row": False}
assert len(calls) == 0
# A mixed batch still needs exactly one query
calls.clear()
results = await ds.allowed_many(
actions=["view-table", "drop-table"],
resource=TableResource("analytics", "users"),
actor={"id": "bob"},
)
assert results == {"view-table": True, "drop-table": False}
assert len(calls) == 1
finally:
internal_db.execute = original_execute
@pytest.mark.asyncio
async def test_allowed_many_global_actions_without_resource(ds):
results = await ds.allowed_many(
actions=["view-instance", "permissions-debug"],
actor={"id": "root"},
)
assert results["view-instance"] is True
# Equivalence with single checks for global actions
for action in ("view-instance", "permissions-debug"):
assert results[action] == await ds.allowed(action=action, actor={"id": "root"})
anon = await ds.allowed_many(actions=["permissions-debug"], actor=None)
assert anon == {"permissions-debug": False}
@pytest.mark.asyncio
async def test_allowed_many_seeds_request_cache(counting_ds):
ds, plugin = counting_ds
resource = TableResource("analytics", "users")
actions = ["view-table", "insert-row", "drop-table"]
token = _permission_check_cache.set({})
try:
await ds.allowed_many(actions=actions, resource=resource, actor={"id": "alice"})
gathers = plugin.count(actor_id="alice")
assert gathers > 0
for action in actions:
await ds.allowed(action=action, resource=resource, actor={"id": "alice"})
# Every allowed() call must have been served from the seeded cache
assert plugin.count(actor_id="alice") == gathers
finally:
_permission_check_cache.reset(token)
@pytest.mark.asyncio
async def test_allowed_many_skip_permission_checks(ds):
with SkipPermissions():
results = await ds.allowed_many(
actions=["view-table", "drop-table"],
resource=TableResource("analytics", "users"),
actor=None,
)
assert results == {"view-table": True, "drop-table": True}
class ManyActionsPlugin:
"""Registers enough actions to exceed SQLite's compound SELECT limit."""
def __init__(self, count):
self.action_names = [f"bulk-action-{i}" for i in range(count)]
self.action_names_set = set(self.action_names)
@hookimpl
def register_actions(self, datasette):
return [
Action(name=name, abbr=None, description="Bulk test action")
for name in self.action_names
]
@hookimpl
def permission_resources_sql(self, datasette, actor, action):
if action in self.action_names_set:
return PermissionSQL(
sql="SELECT NULL AS parent, NULL AS child, 1 AS allow, 'bulk allow' AS reason",
params={},
)
@pytest.mark.asyncio
async def test_allowed_many_more_than_sqlite_compound_select_limit():
plugin = ManyActionsPlugin(600)
ds = Datasette()
ds.pm.register(plugin, name="many_actions")
try:
await ds.invoke_startup()
results = await ds.allowed_many(actions=plugin.action_names, actor=None)
assert len(results) == 600
assert all(results.values())
finally:
ds.pm.unregister(name="many_actions")
# ----------------------------------------------------------------------
# Layer 3: precompute before table_actions / database_actions hooks
# ----------------------------------------------------------------------
class ActionHooksPlugin:
"""Plugin hooks that make allowed() checks, like real action plugins do."""
@hookimpl
def table_actions(self, datasette, actor, database, table):
async def inner():
links = []
if await datasette.allowed(
action="drop-table",
resource=TableResource(database, table),
actor=actor,
):
links.append(
{"href": "/drop", "label": "Drop this table (test-plugin)"}
)
if await datasette.allowed(
action="create-table",
resource=DatabaseResource(database),
actor=actor,
):
links.append(
{"href": "/create", "label": "Create a table (test-plugin)"}
)
return links
return inner
@hookimpl
def database_actions(self, datasette, actor, database):
async def inner():
if await datasette.allowed(
action="create-table",
resource=DatabaseResource(database),
actor=actor,
):
return [{"href": "/create", "label": "Create a table (test-plugin)"}]
return []
return inner
@pytest_asyncio.fixture
async def spying_ds(ds, monkeypatch):
"""ds with the ActionHooksPlugin plus a spy recording every batch of
actions sent to check_permissions_for_actions."""
from datasette.utils import actions_sql
plugin = ActionHooksPlugin()
ds.pm.register(plugin, name="action_hooks")
ds.root_enabled = True
recorded = []
original = actions_sql.check_permissions_for_actions
async def spy(**kwargs):
recorded.append(kwargs["actions"])
return await original(**kwargs)
monkeypatch.setattr(actions_sql, "check_permissions_for_actions", spy)
try:
yield ds, recorded
finally:
ds.pm.unregister(name="action_hooks")
@pytest.mark.asyncio
async def test_table_page_precomputes_action_permissions(spying_ds):
ds, recorded = spying_ds
cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})}
response = await ds.client.get("/analytics/users", cookies=cookies)
assert response.status_code == 200
# The plugin's permission checks were served from the precomputed batch
assert "Drop this table (test-plugin)" in response.text
assert "Create a table (test-plugin)" in response.text
# One batch covered the table-level actions for the table resource,
# and one covered the database-level actions for the database resource
batches = [batch for batch in recorded if len(batch) > 1]
assert any("drop-table" in batch for batch in batches)
assert any("create-table" in batch for batch in batches)
# The precompute is scoped to actions relevant to each resource:
# no global or query-level actions in any batch, and no mixing of
# table-level and database-level actions
for batch in batches:
assert "view-instance" not in batch
assert "view-query" not in batch
assert not ("drop-table" in batch and "create-table" in batch)
# The hook's own allowed() calls hit the cache - no single-action
# fallback queries for the actions it checked
assert ["drop-table"] not in recorded
assert ["create-table"] not in recorded
@pytest.mark.asyncio
async def test_database_page_precomputes_action_permissions(spying_ds):
ds, recorded = spying_ds
cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})}
response = await ds.client.get("/analytics", cookies=cookies)
assert response.status_code == 200
assert "Create a table (test-plugin)" in response.text
batches = [batch for batch in recorded if len(batch) > 1]
assert any("create-table" in batch for batch in batches)
# Scoped to database-level actions only
for batch in batches:
assert "view-instance" not in batch
assert "drop-table" not in batch
assert ["create-table"] not in recorded
@pytest.mark.asyncio
async def test_cache_does_not_leak_across_requests(counting_ds):
ds, plugin = counting_ds
cookies = {"ds_actor": ds.client.actor_cookie({"id": "alice"})}
response = await ds.client.get("/analytics/users.json", cookies=cookies)
assert response.status_code == 200
first_request_gathers = plugin.count(actor_id="alice", action="view-table")
assert first_request_gathers > 0
response = await ds.client.get("/analytics/users.json", cookies=cookies)
assert response.status_code == 200
# Second request must re-gather (fresh cache), not reuse the first one
assert (
plugin.count(actor_id="alice", action="view-table") == first_request_gathers * 2
)

View file

@ -383,7 +383,7 @@ async def test_row_strange_table_name(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_row_foreign_key_tables(ds_client): async def test_row_foreign_key_tables(ds_client):
response = await ds_client.get( response = await ds_client.get(
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables" "/fixtures/simple_primary_key/1.json?_extras=foreign_key_tables"
) )
assert response.status_code == 200 assert response.status_code == 200
# Foreign keys are sorted by (other_table, column, other_column) # Foreign keys are sorted by (other_table, column, other_column)
@ -426,28 +426,6 @@ async def test_row_foreign_key_tables(ds_client):
] ]
@pytest.mark.asyncio
async def test_row_extras(ds_client):
response = await ds_client.get(
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables"
)
assert response.status_code == 200
data = response.json()
assert data["database"] == "fixtures"
assert data["table"] == "simple_primary_key"
assert data["primary_keys"] == ["id"]
assert data["query"]["sql"] == 'select * from simple_primary_key where "id"=:p0'
assert data["query"]["params"] == {"p0": "1"}
assert data["request"]["path"] == "/fixtures/simple_primary_key/1.json"
assert data["debug"]["url_vars"] == {
"database": "fixtures",
"table": "simple_primary_key",
"pks": "1",
"format": "json",
}
assert len(data["foreign_key_tables"]) == 5
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_row_extra_render_cell(): async def test_row_extra_render_cell():
"""Test that _extra=render_cell returns rendered HTML from render_cell plugin hook on row pages""" """Test that _extra=render_cell returns rendered HTML from render_cell plugin hook on row pages"""

View file

@ -794,44 +794,6 @@ async def test_update_row_alter(ds_write):
assert response.json() == {"ok": True} assert response.json() == {"ok": True}
@pytest.mark.asyncio
async def test_execute_write_form_parameter_called_sql():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_parameter_sql", name="data")
await db.execute_write("create table docs (id integer primary key, title text)")
await db.execute_write("insert into docs (id, title) values (1, 'Initial')")
await ds.invoke_startup()
form_response = await ds.client.get(
"/data/-/execute-write",
actor={"id": "root"},
params={"sql": "update docs set title = :sql where id = :id"},
)
assert form_response.status_code == 200
assert 'data-parameter-name-prefix="_sql_param_"' in form_response.text
assert '<label for="qp1">sql</label>' in form_response.text
assert 'name="_sql_param_sql"' in form_response.text
assert 'data-parameter-name="sql"' in form_response.text
assert 'name="_sql_param_id"' in form_response.text
response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
data={
"sql": "update docs set title = :sql where id = :id",
"_sql_param_sql": "Updated",
"_sql_param_id": "1",
},
)
assert response.status_code == 200
assert "Query executed, 1 row affected" in response.text
assert (await db.execute("select title from docs where id = 1")).first()[
0
] == "Updated"
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected_errors", "input,expected_errors",

View file

@ -1,253 +0,0 @@
import pytest
from datasette.app import Datasette
from datasette.database import QueryInterrupted
@pytest.mark.asyncio
async def test_autocomplete_single_pk_exact_match_and_label_order():
ds = Datasette(memory=True)
db = ds.add_memory_database("autocomplete_single")
await db.execute_write_script("""
create table people (
id integer primary key,
name text
);
insert into people (id, name) values
(2, 'Longer non-label pk match'),
(20, '2'),
(21, '22'),
(200, 'A'),
(3, 'A label containing 2');
""")
response = await ds.client.get("/autocomplete_single/people/-/autocomplete?q=2")
assert response.status_code == 200
assert response.json() == {
"rows": [
{"pks": {"id": 2}, "label": "Longer non-label pk match"},
{"pks": {"id": 20}, "label": "2"},
{"pks": {"id": 21}, "label": "22"},
{"pks": {"id": 3}, "label": "A label containing 2"},
{"pks": {"id": 200}, "label": "A"},
]
}
@pytest.mark.asyncio
async def test_autocomplete_blank_q_returns_no_results():
ds = Datasette(memory=True)
db = ds.add_memory_database("autocomplete_blank")
await db.execute_write_script("""
create table people (
id integer primary key,
name text
);
insert into people (id, name) values
(1, 'Alice'),
(2, 'Bob');
""")
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete?q=")
assert response.status_code == 200
assert response.json() == {"rows": []}
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete")
assert response.status_code == 200
assert response.json() == {"rows": []}
@pytest.mark.asyncio
async def test_autocomplete_initial_returns_latest_rows():
ds = Datasette(memory=True)
db = ds.add_memory_database("autocomplete_initial")
await db.execute_write_script("""
create table people (
id integer primary key,
name text
);
insert into people (id, name) values
(1, 'Alice'),
(2, 'Bob'),
(3, 'Cleo');
""")
response = await ds.client.get(
"/autocomplete_initial/people/-/autocomplete?_initial=1"
)
assert response.status_code == 200
assert response.json() == {
"rows": [
{"pks": {"id": 3}, "label": "Cleo"},
{"pks": {"id": 2}, "label": "Bob"},
{"pks": {"id": 1}, "label": "Alice"},
]
}
response = await ds.client.get(
"/autocomplete_initial/people/-/autocomplete?q=&_initial=1"
)
assert response.status_code == 200
assert response.json() == {
"rows": [
{"pks": {"id": 3}, "label": "Cleo"},
{"pks": {"id": 2}, "label": "Bob"},
{"pks": {"id": 1}, "label": "Alice"},
]
}
@pytest.mark.asyncio
async def test_autocomplete_escapes_like_characters():
ds = Datasette(memory=True)
db = ds.add_memory_database("autocomplete_escape")
await db.execute_write_script("""
create table tags (
id integer primary key,
name text
);
insert into tags (id, name) values
(1, '100% real'),
(2, '100X real'),
(3, '100 percent real');
""")
response = await ds.client.get("/autocomplete_escape/tags/-/autocomplete?q=100%25")
assert response.status_code == 200
assert response.json() == {
"rows": [
{"pks": {"id": 1}, "label": "100% real"},
]
}
@pytest.mark.asyncio
async def test_autocomplete_compound_pk_searches_all_pk_columns():
ds = Datasette(memory=True)
db = ds.add_memory_database("autocomplete_compound")
await db.execute_write_script("""
create table places (
country text,
code text,
name text,
primary key (country, code)
);
insert into places (country, code, name) values
('us', 'ca', 'California'),
('ca', 'bc', 'British Columbia'),
('mx', 'ca', 'Campeche'),
('zz', 'zz', 'Nothing');
""")
response = await ds.client.get("/autocomplete_compound/places/-/autocomplete?q=ca")
assert response.status_code == 200
assert response.json() == {
"rows": [
{"pks": {"country": "mx", "code": "ca"}, "label": "Campeche"},
{"pks": {"country": "us", "code": "ca"}, "label": "California"},
{"pks": {"country": "ca", "code": "bc"}, "label": "British Columbia"},
]
}
@pytest.mark.asyncio
async def test_autocomplete_primary_key_called_label():
ds = Datasette(
memory=True,
config={
"databases": {
"autocomplete_label_pk": {
"tables": {"things": {"label_column": "name"}}
}
}
},
)
db = ds.add_memory_database("autocomplete_label_pk")
await db.execute_write_script("""
create table things (
label text primary key,
name text
);
insert into things (label, name) values
('abc', 'Display value'),
('def', 'Other value');
""")
response = await ds.client.get("/autocomplete_label_pk/things/-/autocomplete?q=abc")
assert response.status_code == 200
assert response.json() == {
"rows": [
{"pks": {"label": "abc"}, "label": "Display value"},
]
}
@pytest.mark.asyncio
async def test_autocomplete_timeout_uses_prefix_fallback(monkeypatch):
ds = Datasette(
memory=True,
config={
"databases": {
"autocomplete_timeout": {"tables": {"things": {"label_column": "name"}}}
}
},
settings={
"num_sql_threads": 1,
},
)
db = ds.add_memory_database("autocomplete_timeout")
await db.execute_write_script("""
create table things (
id text primary key,
name text
);
insert into things (id, name) values
('other-000001', 'item-1999 label-only match');
""")
def insert_rows(conn):
conn.executemany(
"insert into things (id, name) values (?, ?)",
((f"item-1999{i:02d}", f"name 1999{i:02d}") for i in range(12)),
)
await db.execute_write_fn(insert_rows)
original_execute = db.execute
timeout_was_simulated = False
async def execute_with_simulated_timeout(sql, params=None, *args, **kwargs):
nonlocal timeout_was_simulated
if (
not timeout_was_simulated
and isinstance(params, dict)
and params.get("q") == "item-1999"
and "prefix_end" not in params
):
timeout_was_simulated = True
raise QueryInterrupted(Exception("interrupted"), sql, params)
return await original_execute(sql, params, *args, **kwargs)
monkeypatch.setattr(db, "execute", execute_with_simulated_timeout)
response = await ds.client.get(
"/autocomplete_timeout/things/-/autocomplete?q=item-1999"
)
assert response.status_code == 200
assert timeout_was_simulated
data = response.json()
assert data == {
"rows": [
{"pks": {"id": f"item-1999{i:02d}"}, "label": f"name 1999{i:02d}"}
for i in range(10)
]
}

View file

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

View file

@ -35,28 +35,12 @@ def test_inspect_cli(app_client):
assert expected_count == database["tables"][table_name]["count"] assert expected_count == database["tables"][table_name]["count"]
def test_inspect_cli_counts_all_rows(tmp_path):
db_path = tmp_path / "big.db"
conn = sqlite3.connect(db_path)
with conn:
conn.execute("create table t (id integer primary key)")
conn.executemany("insert into t (id) values (?)", ((i,) for i in range(10002)))
conn.close()
runner = CliRunner()
result = runner.invoke(cli, ["inspect", str(db_path)])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["big"]["tables"]["t"]["count"] == 10002
def test_inspect_cli_writes_to_file(app_client): def test_inspect_cli_writes_to_file(app_client):
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
cli, ["inspect", "fixtures.db", "--inspect-file", "foo.json"] cli, ["inspect", "fixtures.db", "--inspect-file", "foo.json"]
) )
assert result.exit_code == 0, result.output assert 0 == result.exit_code, result.output
with open("foo.json") as fp: with open("foo.json") as fp:
data = json.load(fp) data = json.load(fp)
assert ["fixtures"] == list(data.keys()) assert ["fixtures"] == list(data.keys())

Some files were not shown because too many files have changed in this diff Show more