Merge remote-tracking branch 'origin/main' into template-context-docs

# Conflicts:
#	datasette/views/row.py
This commit is contained in:
Simon Willison 2026-06-22 18:44:37 -07:00
commit 49b1adba7b
71 changed files with 16280 additions and 1146 deletions

48
.github/workflows/playwright.yml vendored Normal file
View file

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

View file

@ -9,6 +9,7 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:

4
.gitignore vendored
View file

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

View file

@ -11,6 +11,22 @@ export DATASETTE_SECRET := "not_a_secret"
@test *options: init
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:
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
import contextvars
from typing import TYPE_CHECKING, Any, Dict, Iterable, List
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
if TYPE_CHECKING:
from datasette.permissions import Resource
@ -47,9 +47,14 @@ from .views import Context
from .views.database import (
database_download,
DatabaseView,
TableCreateView,
QueryView,
)
from .views.table_create_alter import (
DatabaseForeignKeyTargetsView,
TableAlterView,
TableCreateView,
TableForeignKeySuggestionsView,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.stored_queries import (
QueryCreateAnalyzeView,
@ -66,6 +71,7 @@ from .views.index import IndexView
from .views.special import (
JsonDataView,
PatternPortfolioView,
AutocompleteDebugView,
AuthTokenView,
ApiExplorerView,
CreateTokenView,
@ -82,10 +88,12 @@ from .views.special import (
TableSchemaView,
)
from .views.table import (
TableAutocompleteView,
TableInsertView,
TableUpsertView,
TableSetColumnTypeView,
TableDropView,
TableFragmentView,
table_view,
)
from .views.row import RowView, RowDeleteView, RowUpdateView
@ -291,6 +299,15 @@ DEFAULT_NOT_SET = object()
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):
await asgi_send_file(
send,
@ -327,6 +344,8 @@ TEMPLATE_BASE_CONTEXT = {
"display_actor": "Function returning a display string for an actor dictionary",
"show_logout": "True if the logout link should be shown in the navigation menu",
"app_css_hash": "Hash of Datasette's app.css contents, used for cache busting",
"edit_tools_js_hash": "Hash of Datasette's edit-tools.js contents, used for cache busting",
"table_js_hash": "Hash of Datasette's table.js contents, used for cache busting",
"zip": "Python's zip() builtin, made available to template logic",
"body_scripts": "List of script blocks for the page body contributed by plugins",
"format_bytes": "Function that formats a number of bytes as a human-readable size",
@ -639,9 +658,12 @@ class Datasette:
return action
return None
async def refresh_schemas(self):
async def refresh_schemas(self, *, force=False):
# Throttle schema refreshes to at most once per second
if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0:
if (
not force
and time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0
):
return
self._last_schema_refresh = time.monotonic()
if self._refresh_schemas_lock.locked():
@ -1843,46 +1865,124 @@ class Datasette:
# For global actions, resource can be omitted:
can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
"""
from datasette.utils.actions_sql import check_permission_for_resource
results = await self.allowed_many(
actions=[action], resource=resource, actor=actor
)
return results[action]
# For global actions, resource remains None
async def allowed_many(
self,
*,
actions: Sequence[str],
resource: "Resource" = None,
actor: dict | None = None,
) -> dict[str, bool]:
"""
Check several actions against one resource for one actor.
# Check if this action has also_requires - if so, check that action first
action_obj = self.actions.get(action)
if action_obj and action_obj.also_requires:
# Must have the required action first
if not await self.allowed(
action=action_obj.also_requires,
resource=resource,
Resolves every action (plus any also_requires dependencies) with a
single internal database query, instead of one or two queries per
action. Results are stored in the request-scoped permission cache,
so subsequent datasette.allowed() calls for the same checks within
the same request are served from the cache.
Example:
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,
):
return False
)
# {"edit-schema": True, "drop-table": True, "insert-row": 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
parent = resource.parent if resource else None
child = resource.child if resource else None
result = await check_permission_for_resource(
datasette=self,
actor=actor,
action=action,
parent=parent,
child=child,
)
# Expand also_requires dependencies (transitively) so that each
# dependency is resolved within the same batch
expanded = []
# Log the permission check for debugging
self._permission_checks.append(
PermissionCheck(
when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
def add_action(name):
if name in expanded:
return
action_obj = self.actions.get(name)
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,
action=action,
actions=to_check,
parent=parent,
child=child,
result=result,
)
)
return result
def resolve(name):
# 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(
self,
@ -1955,6 +2055,11 @@ class Datasette:
other_table = fk["other_table"]
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(
actor,
action="view-table",
@ -2257,6 +2362,8 @@ class Datasette:
and "ds_actor" in request.cookies
and request.actor,
"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,
"body_scripts": body_scripts,
"format_bytes": format_bytes,
@ -2481,6 +2588,10 @@ class Datasette:
wrap_view(PatternPortfolioView, self),
r"/-/patterns$",
)
add_route(
AutocompleteDebugView.as_view(self),
r"/-/debug/autocomplete$",
)
add_route(
wrap_view(database_download, self),
r"/(?P<database>[^\/\.]+)\.db$",
@ -2490,6 +2601,10 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
)
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
add_route(
DatabaseForeignKeyTargetsView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/foreign-key-targets$",
)
add_route(
QueryListView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
@ -2554,10 +2669,26 @@ class Datasette:
TableUpsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
)
add_route(
TableAlterView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/alter$",
)
add_route(
TableForeignKeySuggestionsView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/foreign-key-suggestions$",
)
add_route(
TableSetColumnTypeView.as_view(self),
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(
TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
@ -2644,7 +2775,16 @@ class DatasetteRouter:
if raw_path:
path = raw_path.decode("ascii")
path = path.partition("?")[0]
return await self.route_path(scope, receive, send, path)
# Give each request a fresh permission check cache, so repeated
# 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):
# Strip off base_url if present before routing

View file

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

View file

@ -829,10 +829,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
# Execute the actual write
try:
result = fn(conn)
except Exception:
except Exception as e:
# Throw exception into generator so it can handle it
try:
gen.throw(*sys.exc_info())
gen.throw(e)
except StopIteration:
pass
# Re-raise the original exception

View file

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

View file

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

View file

@ -0,0 +1,29 @@
from datasette import hookimpl
from datasette.resources import TableResource
@hookimpl
def table_actions(datasette, actor, database, table, request):
async def inner():
db = datasette.get_database(database)
if not db.is_mutable:
return []
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database, table=table),
actor=actor,
):
return []
return [
{
"type": "button",
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": "Alter table {}".format(table),
"data-table-action": "alter-table",
},
}
]
return inner

View file

@ -159,32 +159,32 @@ def jump_items_sql(datasette, actor, request):
@hookspec
def row_actions(datasette, actor, request, database, table, row):
"""Links for the row actions menu"""
"""Items for the row actions menu"""
@hookspec
def table_actions(datasette, actor, database, table, request):
"""Links for the table actions menu"""
"""Items for the table actions menu"""
@hookspec
def view_actions(datasette, actor, database, view, request):
"""Links for the view actions menu"""
"""Items for the view actions menu"""
@hookspec
def query_actions(datasette, actor, database, query_name, request, sql, params):
"""Links for the query and stored query actions menu"""
"""Items for the query and stored query actions menu"""
@hookspec
def database_actions(datasette, actor, database, request):
"""Links for the database actions menu"""
"""Items for the database actions menu"""
@hookspec
def homepage_actions(datasette, actor, request):
"""Links for the homepage actions menu"""
"""Items for the homepage actions menu"""
@hookspec

View file

@ -8,6 +8,14 @@ _skip_permission_checks = contextvars.ContextVar(
"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:
"""Context manager to temporarily skip permission checks.

View file

@ -31,6 +31,7 @@ DEFAULT_PLUGINS = (
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.default_table_actions",
"datasette.default_query_actions",
"datasette.handle_exception",
"datasette.forbidden",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,344 @@
(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>
:host {
--ink: #0f0f0f;
--paper: #f5f3ef;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #e2dfd8;
--rule: #d8e6f5;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;

View file

@ -82,6 +82,35 @@ const datasetteManager = {
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) => {
let jumpSections = [];

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,6 @@ 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 setColumnTypeDialogState = null;
function getParams() {
return new URLSearchParams(location.search);
}
@ -634,32 +633,151 @@ const initDatasetteTable = function (manager) {
});
};
/* Add x buttons to the filter rows */
function addButtonsToFilterRows(manager) {
var x = "✖";
var rows = Array.from(
document.querySelectorAll(manager.selectors.filterRow),
function filterRowSelector(manager) {
return manager.selectors.filterRows || manager.selectors.filterRow;
}
function filterRowsWithControls(manager) {
return Array.from(
document.querySelectorAll(filterRowSelector(manager)),
).filter((el) => el.querySelector(".filter-op"));
rows.forEach((row) => {
var a = document.createElement("a");
a.setAttribute("href", "#");
a.setAttribute("aria-label", "Remove this filter");
a.style.textDecoration = "none";
a.innerText = x;
a.addEventListener("click", (ev) => {
ev.preventDefault();
let row = ev.target.closest("div");
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
ev.target.closest("a").style.display = "none";
});
row.appendChild(a);
}
function filterRowNumberFromName(name) {
var match = name && name.match(/^_filter_column_(\d+)$/);
return match ? parseInt(match[1], 10) : 0;
}
function nextFilterRowNumber(manager) {
return filterRowsWithControls(manager).reduce((max, row) => {
var column = row.querySelector("select");
if (!column.value) {
a.style.display = "none";
return Math.max(max, filterRowNumberFromName(column && column.name));
}, 0) + 1;
}
function setFilterRowNumber(row, number) {
row.querySelector("select").name = `_filter_column_${number}`;
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
}
function resetFilterRow(row) {
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
}
function updateFilterRowButtons(manager) {
var rows = filterRowsWithControls(manager);
rows.forEach((row, index) => {
var removeButton = row.querySelector(".filter-row-remove");
var addButton = row.querySelector(".filter-row-add");
var column = row.querySelector("select");
if (removeButton) {
removeButton.hidden = index === 0;
}
if (addButton) {
addButton.hidden = index !== rows.length - 1 || !column.value;
}
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
return button && !button.hidden;
}).length;
row.classList.toggle(
"filter-controls-row-has-buttons",
visibleButtonCount > 0,
);
row.classList.toggle(
"filter-controls-row-one-button",
visibleButtonCount === 1,
);
row.classList.toggle(
"filter-controls-row-two-buttons",
visibleButtonCount === 2,
);
});
}
function cloneFilterRow(row) {
var clone = row.cloneNode(true);
clone.querySelector("select").name = "_filter_column";
clone.querySelector(".filter-op select").name = "_filter_op";
clone.querySelector("input.filter-value").name = "_filter_value";
resetFilterRow(clone);
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
return clone;
}
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-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.1" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>`;
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>`;
function addFilterRowButtons(row, manager) {
var removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "filter-row-icon filter-row-remove";
removeButton.setAttribute("aria-label", "Remove this filter");
removeButton.title = "Remove this filter";
removeButton.tabIndex = 0;
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
removeButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
var rows = filterRowsWithControls(manager);
var rowIndex = rows.indexOf(row);
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
row.remove();
updateFilterRowButtons(manager);
if (focusRow) {
var focusTarget =
focusRow.querySelector(".filter-row-add:not([hidden])") ||
focusRow.querySelector("select");
if (focusTarget) {
focusTarget.focus();
}
}
});
row.appendChild(removeButton);
var addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "filter-row-icon filter-row-add";
addButton.setAttribute("aria-label", "Add another filter");
addButton.title = "Add another filter";
addButton.tabIndex = 0;
addButton.innerHTML = FILTER_ADD_ICON_SVG;
addButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
if (row.querySelector("select").name === "_filter_column") {
setFilterRowNumber(row, nextFilterRowNumber(manager));
}
var clone = cloneFilterRow(row);
addFilterRowButtons(clone, manager);
row.parentNode.insertBefore(clone, row.nextSibling);
updateFilterRowButtons(manager);
clone.querySelector("select").focus();
});
row.appendChild(addButton);
row.querySelector("select").addEventListener("change", () => {
updateFilterRowButtons(manager);
});
}
/* Add buttons to the filter rows */
function addButtonsToFilterRows(manager) {
var rows = filterRowsWithControls(manager);
rows.forEach((row) => {
addFilterRowButtons(row, manager);
});
updateFilterRowButtons(manager);
}
/* Set up datalist autocomplete for filter values */
@ -688,11 +806,11 @@ function initAutocompleteForFilterValues(manager) {
});
}
createDataLists();
// When any select with name=_filter_column changes, update the datalist
// When any filter column select changes, update the datalist
document.body.addEventListener("change", function (event) {
if (event.target.name === "_filter_column") {
if (event.target.name && event.target.name.startsWith("_filter_column")) {
event.target
.closest(manager.selectors.filterRow)
.closest(filterRowSelector(manager))
.querySelector(".filter-value")
.setAttribute("list", "datalist-" + event.target.value);
}

View file

@ -15,14 +15,22 @@
<div class="hook"></div>
<ul role="menu">
{% for link in action_links %}
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
{% if link.description %}
<p class="dropdown-description">{{ link.description }}</p>
{% endif %}</a>
<li role="none">
{% if link.get("type") == "button" %}
<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 }}
{% if link.description %}
<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>
{% endfor %}
</ul>
</div>
</details>
</div>
{% endif %}
{% endif %}

View file

@ -22,7 +22,7 @@
</thead>
<tbody>
{% for row in display_rows %}
<tr>
<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 %}>
{% for cell in row %}
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
{% endfor %}

View file

@ -6,6 +6,10 @@
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% if database_page_data.createTable %}
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
{% endif %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}

View file

@ -0,0 +1,78 @@
{% 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

@ -56,6 +56,11 @@ form.sql.core input[data-execute-write-submit]:disabled {
cursor: not-allowed;
opacity: 1;
}
.execute-write form.sql .sql-editor-min-lines .cm-content,
.execute-write form.sql .sql-editor-min-lines .cm-gutter {
/* Four visible editor lines without adding blank lines to the SQL value. */
min-height: calc(5.6em + 8px);
}
.execute-write-disabled-reason {
color: #4f5b6d;
font-size: 0.85rem;
@ -93,20 +98,25 @@ form.sql.core input[data-execute-write-submit]:disabled {
{% 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 %}
{% if write_create_table_template_sql or 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>
{% if write_create_table_template_sql %}
<button type="button" data-sql-template="create" data-template-sql="{{ write_create_table_template_sql }}">Create table</button>
{% endif %}
{% if write_template_tables %}
<label for="execute-write-template-table">{% if write_create_table_template_sql %}or table:{% else %}Table{% endif %}</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 %}
</select>
{% for operation in write_template_operations %}
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
{% endfor %}
{% endif %}
</p>
</details>
</div>
@ -114,7 +124,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
<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>
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><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 %}
@ -159,19 +169,13 @@ form.sql.core input[data-execute-write-submit]:disabled {
</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 executeWriteSqlInput = document.querySelector("textarea#sql-editor");
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#execute-write-analysis-section");
const submitButton = form
@ -252,11 +256,12 @@ window.addEventListener("DOMContentLoaded", () => {
});
</script>
{% if write_template_tables %}
{% if write_create_table_template_sql or write_template_tables %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const tableSelect = document.querySelector("#execute-write-template-table");
const templateButtons = document.querySelectorAll("[data-sql-template]");
const sqlInput = document.querySelector("textarea#sql-editor");
function dataKey(operation) {
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
@ -266,26 +271,59 @@ window.addEventListener("DOMContentLoaded", () => {
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
}
function templateSql(operation) {
function templateSql(button) {
if (button.dataset.templateSql) {
return button.dataset.templateSql;
}
const operation = button.dataset.sqlTemplate;
const option = selectedOption();
return option ? option.dataset[dataKey(operation)] || "" : "";
}
function updateTemplateButtons() {
templateButtons.forEach((button) => {
button.hidden = !templateSql(button.dataset.sqlTemplate);
button.hidden = !templateSql(button);
});
}
function updateSqlUrl(sql) {
if (!window.history || !window.history.replaceState) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.history.replaceState(null, "", url.toString());
}
function setEditorSql(sql) {
if (window.editor) {
window.editor.dispatch({
changes: {
from: 0,
to: window.editor.state.doc.length,
insert: sql,
},
selection: { anchor: sql.length },
});
window.editor.focus();
if (sqlInput) {
sqlInput.value = sql;
}
} else if (sqlInput) {
sqlInput.value = sql;
sqlInput.dispatchEvent(new Event("input", { bubbles: true }));
sqlInput.focus();
}
updateSqlUrl(sql);
}
templateButtons.forEach((button) => {
button.addEventListener("click", () => {
const sql = templateSql(button.dataset.sqlTemplate);
const sql = templateSql(button);
if (!sql) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.location.href = url.toString();
setEditorSql(sql);
});
});
if (tableSelect) {

View file

@ -202,9 +202,9 @@
<h3>3 rows
where characteristic_id = 2
</h3>
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
<form class="core filters" action="{{ base_url }}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="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column_1">
<option value="">- remove filter -</option>
@ -238,7 +238,7 @@
</select>
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
</div>
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
@ -272,8 +272,8 @@
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
<div class="select-wrapper small-screen-only">
<div class="filter-row filter-actions-row">
<div class="select-wrapper">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
<option value="rowid" selected>Sort by rowid</option>
@ -281,8 +281,8 @@
<option value="characteristic_id">Sort by characteristic_id</option>
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply">
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply filters">
</div>
</form>

View file

@ -4,6 +4,13 @@
{% block extra_head %}
{{- 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>
@media only screen and (max-width: 576px) {
{% for column in columns %}

View file

@ -4,8 +4,13 @@
{% block extra_head %}
{{- super() -}}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
<script src="{{ urls.static('table.js') }}" defer></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>
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style>
@ -50,12 +55,12 @@
</h3>
{% endif %}
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
{% if supports_search %}
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %}
{% for column, lookup, value in filters.selections() %}
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column_{{ loop.index }}">
<option value="">- remove filter -</option>
@ -72,7 +77,7 @@
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
</div>
{% endfor %}
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
@ -88,9 +93,9 @@
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
<div class="filter-row filter-actions-row">
{% if is_sortable %}
<div class="select-wrapper small-screen-only">
<div class="select-wrapper">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
{% for column in display_columns %}
@ -100,12 +105,12 @@
{% endfor %}
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
{% endif %}
{% for key, value in form_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<input type="submit" value="Apply">
<input type="submit" value="Apply filters" tabindex="0">
</div>
</form>
@ -158,6 +163,19 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
</script>
{% 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 %}
{% if next_url %}

View file

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

View file

@ -21,6 +21,8 @@ The core pattern is:
- Across levels, child beats parent beats global
"""
import asyncio
import re
from typing import TYPE_CHECKING
from datasette.utils.permissions import gather_permission_sql_from_hooks
@ -495,6 +497,153 @@ async def build_permission_rules_sql(
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(
*,
datasette: "Datasette",
@ -515,77 +664,12 @@ async def check_permission_for_resource(
Returns:
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.
"""
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
datasette, actor, action
results = await check_permissions_for_actions(
datasette=datasette,
actor=actor,
actions=[action],
parent=parent,
child=child,
)
# 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
return results[action]

View file

@ -155,6 +155,10 @@ class Request:
body = await self.post_body()
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(
self,
files: bool = False,

View file

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

View file

@ -6,11 +6,8 @@ import itertools
import json
import markupsafe
import os
import re
import sqlite_utils
import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
@ -37,13 +34,14 @@ from datasette.utils import (
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm
from .base import BaseView, DatasetteError, View, _error, stream_csv
from .base import DatasetteError, View, 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 .table_create_alter import _create_table_ui_context
from . import Context
@ -117,8 +115,36 @@ class DatabaseView(View):
else len(stored_queries)
)
# Resolve the registered database-level actions for this database in
# one batched query, seeding the request permission cache so allowed()
# calls made inside plugin hooks below are served from the cache.
database_action_permissions = 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,
)
create_table_ui = await _create_table_ui_context(
datasette, request, db, database, database_action_permissions
)
async def database_actions():
links = []
if create_table_ui:
links.append(
{
"type": "button",
"label": "Create table",
"description": "Create a new table in this database.",
"attrs": {
"aria-label": "Create table in {}".format(database),
"data-database-action": "create-table",
},
}
)
for hook in pm.hook.database_actions(
datasette=datasette,
database=database,
@ -198,6 +224,9 @@ class DatabaseView(View):
),
metadata=metadata,
database_color=db.color,
database_page_data=(
{"createTable": create_table_ui} if create_table_ui else {}
),
database_actions=database_actions,
show_hidden=request.args.get("_show_hidden"),
editable=True,
@ -254,6 +283,9 @@ class DatabaseContext(Context):
)
metadata: dict = field(metadata={"help": "Metadata for the database"})
database_color: str = field(metadata={"help": "The color assigned to the database"})
database_page_data: dict = field(
metadata={"help": "JSON data used by JavaScript on the database page"}
)
database_actions: callable = field(
metadata={
"help": "Callable returning list of action links for the database menu"
@ -1050,261 +1082,6 @@ class MagicParameters(dict):
return super().__getitem__(key)
class TableCreateView(BaseView):
name = "table-create"
_valid_keys = {
"table",
"rows",
"row",
"columns",
"pk",
"pks",
"ignore",
"replace",
"alter",
}
_supported_column_types = {
"text",
"integer",
"float",
"blob",
}
# Any string that does not contain a newline or start with sqlite_
_table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$")
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
db = await self.ds.resolve_database(request)
database_name = db.name
# Must have create-table permission
if not await self.ds.allowed(
action="create-table",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
body = await request.post_body()
try:
data = json.loads(body)
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict):
return _error(["JSON must be an object"])
invalid_keys = set(data.keys()) - self._valid_keys
if invalid_keys:
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
# ignore and replace are mutually exclusive
if data.get("ignore") and data.get("replace"):
return _error(["ignore and replace are mutually exclusive"])
# ignore and replace only allowed with row or rows
if "ignore" in data or "replace" in data:
if not data.get("row") and not data.get("rows"):
return _error(["ignore and replace require row or rows"])
# ignore and replace require pk or pks
if "ignore" in data or "replace" in data:
if not data.get("pk") and not data.get("pks"):
return _error(["ignore and replace require pk or pks"])
ignore = data.get("ignore")
replace = data.get("replace")
if replace:
# Must have update-row permission
if not await self.ds.allowed(
action="update-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need update-row"], 403)
table_name = data.get("table")
if not table_name:
return _error(["Table is required"])
if not self._table_name_re.match(table_name):
return _error(["Invalid table name"])
table_exists = await db.table_exists(data["table"])
columns = data.get("columns")
rows = data.get("rows")
row = data.get("row")
if not columns and not rows and not row:
return _error(["columns, rows or row is required"])
if rows and row:
return _error(["Cannot specify both rows and row"])
if rows or row:
# Must have insert-row permission
if not await self.ds.allowed(
action="insert-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need insert-row"], 403)
alter = False
if rows or row:
if not table_exists:
# if table is being created for the first time, alter=True
alter = True
else:
# alter=True only if they request it AND they have permission
if data.get("alter"):
if not await self.ds.allowed(
action="alter-table",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need alter-table"], 403)
alter = True
if columns:
if rows or row:
return _error(["Cannot specify columns with rows or row"])
if not isinstance(columns, list):
return _error(["columns must be a list"])
for column in columns:
if not isinstance(column, dict):
return _error(["columns must be a list of objects"])
if not column.get("name") or not isinstance(column.get("name"), str):
return _error(["Column name is required"])
if not column.get("type"):
column["type"] = "text"
if column["type"] not in self._supported_column_types:
return _error(
["Unsupported column type: {}".format(column["type"])]
)
# No duplicate column names
dupes = {c["name"] for c in columns if columns.count(c) > 1}
if dupes:
return _error(["Duplicate column name: {}".format(", ".join(dupes))])
if row:
rows = [row]
if rows:
if not isinstance(rows, list):
return _error(["rows must be a list"])
for row in rows:
if not isinstance(row, dict):
return _error(["rows must be a list of objects"])
pk = data.get("pk")
pks = data.get("pks")
if pk and pks:
return _error(["Cannot specify both pk and pks"])
if pk:
if not isinstance(pk, str):
return _error(["pk must be a string"])
if pks:
if not isinstance(pks, list):
return _error(["pks must be a list"])
for pk in pks:
if not isinstance(pk, str):
return _error(["pks must be a list of strings"])
# If table exists already, read pks from that instead
if table_exists:
actual_pks = await db.primary_keys(table_name)
# if pk passed and table already exists check it does not change
bad_pks = False
if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]:
bad_pks = True
elif (
len(actual_pks) > 1
and data.get("pks")
and set(data["pks"]) != set(actual_pks)
):
bad_pks = True
if bad_pks:
return _error(["pk cannot be changed for existing table"])
pks = actual_pks
initial_schema = None
if table_exists:
initial_schema = await db.execute_fn(
lambda conn: sqlite_utils.Database(conn)[table_name].schema
)
def create_table(conn):
table = sqlite_utils.Database(conn)[table_name]
if rows:
table.insert_all(
rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter
)
else:
table.create(
{c["name"]: c["type"] for c in columns},
pk=pks or pk,
)
return table.schema
try:
schema = await db.execute_write_fn(create_table, request=request)
except Exception as e:
return _error([str(e)])
if initial_schema is not None and initial_schema != schema:
await self.ds.track_event(
AlterTableEvent(
request.actor,
database=database_name,
table=table_name,
before_schema=initial_schema,
after_schema=schema,
)
)
table_url = self.ds.absolute_url(
request, self.ds.urls.table(db.name, table_name)
)
table_api_url = self.ds.absolute_url(
request, self.ds.urls.table(db.name, table_name, format="json")
)
details = {
"ok": True,
"database": db.name,
"table": table_name,
"table_url": table_url,
"table_api_url": table_api_url,
"schema": schema,
}
if rows:
details["row_count"] = len(rows)
if not table_exists:
# Only log creation if we created a table
await self.ds.track_event(
CreateTableEvent(
request.actor, database=db.name, table=table_name, schema=schema
)
)
if rows:
await self.ds.track_event(
InsertRowsEvent(
request.actor,
database=db.name,
table=table_name,
num_rows=len(rows),
ignore=ignore,
replace=replace,
)
)
return Response.json(details, status=201)
async def display_rows(datasette, database, request, rows, columns):
display_rows = []
truncate_cells = datasette.setting("truncate_cells_html")

View file

@ -31,6 +31,15 @@ WRITE_TEMPLATE_LABELS = {
"delete": "Delete rows",
}
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
(
"create table new_table (",
" id integer primary key,",
" name text",
" -- created text default (datetime('now'))",
")",
)
)
def _parameter_names(columns):
@ -207,6 +216,23 @@ def _write_template_operations(write_template_tables):
return operations
async def _create_table_template_sql(datasette, db, actor):
if await datasette.allowed(
action="create-table",
resource=DatabaseResource(db.name),
actor=actor,
):
return CREATE_TABLE_TEMPLATE_SQL
return None
def _analysis_changes_schema(analysis):
return any(
operation.operation in {"create", "alter", "drop"}
for operation in analysis.operations
)
class ExecuteWriteView(BaseView):
name = "execute-write"
has_json_alternate = False
@ -241,6 +267,9 @@ class ExecuteWriteView(BaseView):
self.ds, db, table_columns, hidden_table_names, request.actor
)
write_template_operations = _write_template_operations(write_template_tables)
write_create_table_template_sql = await _create_table_template_sql(
self.ds, db, request.actor
)
if sql and analysis_error is None:
try:
parameter_names = _derived_query_parameters(sql)
@ -302,6 +331,7 @@ class ExecuteWriteView(BaseView):
"table_columns": table_columns,
"write_template_tables": write_template_tables,
"write_template_operations": write_template_operations,
"write_create_table_template_sql": write_create_table_template_sql,
"save_query_url": save_query_url,
"save_query_base_url": save_query_base_url,
},
@ -387,6 +417,9 @@ class ExecuteWriteView(BaseView):
status=400,
)
if _analysis_changes_schema(analysis):
await self.ds.refresh_schemas(force=True)
if cursor.rowcount == -1:
message = "Query executed"
else:

View file

@ -7,6 +7,7 @@ from datasette.utils import (
await_me_maybe,
CustomRow,
make_slot_function,
path_from_row_pks,
to_css_class,
escape_sqlite,
)
@ -17,7 +18,11 @@ import markupsafe
import sqlite_utils
from datasette.extras import extra_names_from_request, ExtraScope
from . import Context, extra_field
from .table import display_columns_and_rows
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
@ -70,6 +75,14 @@ class RowContext(Context):
row_actions: list = field(
metadata={"help": "Row actions made available by plugin hooks"}
)
row_mutation_ui: bool = field(
metadata={
"help": "True if the row edit/delete JavaScript UI should be enabled"
}
)
table_page_data: dict = field(
metadata={"help": "JSON data used by JavaScript on the row page"}
)
top_row: callable = field(
metadata={"help": "Async function rendering the top_row plugin slot"}
)
@ -129,6 +142,7 @@ class RowView(DataView):
pks = resolved.pks
async def template_data():
is_table = await db.table_exists(table)
# Reorder columns so primary keys come first
pk_set = set(pks)
pk_cols = [d for d in results.description if d[0] in pk_set]
@ -197,7 +211,60 @@ class RowView(DataView):
"<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 = []
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(
datasette=self.ds,
actor=request.actor,
@ -224,6 +291,17 @@ class RowView(DataView):
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
"_table.html",
],
"row_mutation_ui": any(row_action_permissions.values()),
"table_page_data": await _table_page_data(
datasette=self.ds,
request=request,
db=db,
database_name=database,
table_name=table,
is_view=not is_table,
table_insert_ui=None,
table_alter_ui=None,
),
"row_actions": row_actions,
"top_row": make_slot_function(
"top_row",
@ -329,6 +407,27 @@ class RowError(Exception):
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):
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
@ -383,6 +482,15 @@ 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)
@ -399,9 +507,8 @@ class RowUpdateView(BaseView):
if not ok:
return resolved
body = await request.post_body()
try:
data = json.loads(body)
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
@ -444,11 +551,13 @@ class RowUpdateView(BaseView):
return _error([str(e)], 400)
result = {"ok": True}
returned_row = None
if data.get("return"):
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
result["row"] = results.dicts()[0]
returned_row = results.dicts()[0]
result["row"] = returned_row
await self.ds.track_event(
UpdateRowEvent(
@ -459,4 +568,19 @@ 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)

View file

@ -91,6 +91,110 @@ 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):
name = "auth_token"
has_json_alternate = False

View file

@ -2,9 +2,11 @@ import asyncio
import itertools
import json
import urllib
import urllib.parse
import markupsafe
from datasette.column_types import SQLiteType
from datasette.extras import extra_names_from_request
from datasette.plugins import pm
from datasette.events import (
@ -13,6 +15,7 @@ from datasette.events import (
InsertRowsEvent,
UpsertRowsEvent,
)
from datasette.database import QueryInterrupted
from datasette import tracer
from datasette.resources import DatabaseResource, TableResource
from datasette.utils import (
@ -40,7 +43,7 @@ from datasette.utils import (
InvalidSql,
sqlite3,
)
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
from datasette.filters import Filters
import sqlite_utils
from dataclasses import dataclass, field, fields
@ -49,9 +52,18 @@ from datasette.extras import ExtraScope
from . import Context, extra_field
from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView
from .table_create_alter import (
ALTER_TABLE_COLUMN_TYPES,
ALTER_TABLE_TYPE_FOR_SQLITE_TYPE,
_custom_column_type_options_for_create_table,
default_expr_for_sql,
default_expression_options,
)
from .table_extras import (
TABLE_EXTRA_BUNDLES,
TableExtraContext,
precompute_database_action_permissions,
precompute_table_action_permissions,
resolve_table_extras,
table_extra_registry,
)
@ -178,6 +190,9 @@ class TableContext(Context):
"help": "The maximum number of rows Datasette will count before showing an approximation"
}
)
table_page_data: dict = field(
metadata={"help": "JSON data used by JavaScript on the table page"}
)
LINK_WITH_LABEL = (
@ -187,8 +202,17 @@ LINK_WITH_VALUE = '<a href="{base_url}{database}/{table}/{link_id}">{id}</a>'
class Row:
def __init__(self, cells):
def __init__(
self,
cells,
pk_path=None,
row_path=None,
row_label=None,
):
self.cells = cells
self.pk_path = pk_path
self.row_path = row_path
self.row_label = row_label
def __iter__(self):
return iter(self.cells)
@ -215,6 +239,20 @@ class Row:
return json.dumps(d, default=repr, indent=2)
def row_label_from_label_column(row, label_column):
if not label_column:
return None
try:
value = row[label_column]
except (KeyError, IndexError):
return None
if isinstance(value, dict):
value = value.get("label")
if value is None or value == "":
return None
return str(value)
async def run_sequential(*args):
# This used to be swappable for asyncio.gather() to run things in
# parallel, but this lead to hard-to-debug locking issues with
@ -225,6 +263,66 @@ async def run_sequential(*args):
return results
def _exact_filter_key(column):
if column.startswith("_"):
return f"{column}__exact"
return column
def _request_with_query_string(request, query_string):
scope = dict(request.scope)
scope["query_string"] = query_string.encode("latin-1")
return Request(scope, request.receive)
async def _fragment_request_for_row(request, resolved):
row_path = request.args.get("_row")
if not row_path:
return request
if resolved.is_view:
raise BadRequest("_row is not supported for views")
pks = await resolved.db.primary_keys(resolved.table)
row_pks = pks or ["rowid"]
pk_values = urlsafe_components(row_path)
if len(pk_values) != len(row_pks):
raise BadRequest("_row does not match the primary key for this table")
row_pk_filter_keys = {
key
for pk in row_pks
for key in {
_exact_filter_key(pk),
f"{pk}__exact",
}
}
args = [
(key, value)
for key, value in urllib.parse.parse_qsl(
request.query_string, keep_blank_values=True
)
if key
not in {
"_row",
"_next",
"_nocount",
"_nofacet",
"_nosuggest",
}.union(row_pk_filter_keys)
]
args.extend(
[(_exact_filter_key(pk), value) for pk, value in zip(row_pks, pk_values)]
)
args.extend(
[
("_nocount", "1"),
("_nofacet", "1"),
("_nosuggest", "1"),
]
)
return _request_with_query_string(request, urllib.parse.urlencode(args))
def _redirect(datasette, request, path, forward_querystring=True, remove_args=None):
if request.query_string and "?" not in path and forward_querystring:
path = f"{path}?{request.query_string}"
@ -283,6 +381,211 @@ async def _validate_column_types(datasette, database_name, table_name, rows):
return errors
def _column_value_kind_for_insert_form(column_detail):
sqlite_type = SQLiteType.from_declared_type(column_detail.type)
if sqlite_type in (SQLiteType.INTEGER, SQLiteType.REAL):
return "number"
return "string"
def _column_sqlite_type_for_insert_form(column_detail):
sqlite_type = SQLiteType.from_declared_type(column_detail.type)
return sqlite_type.value if sqlite_type is not None else None
async def _foreign_key_autocomplete_urls(
datasette, request, db, database_name, table_name
):
autocomplete_urls = {}
for fk in await db.foreign_keys_for_table(table_name):
if not await db.table_exists(fk["other_table"]):
continue
other_pks = await db.primary_keys(fk["other_table"])
other_column = fk["other_column"]
if other_column is None and len(other_pks) == 1:
other_column = other_pks[0]
if len(other_pks) != 1 or other_column != other_pks[0]:
continue
visible, _ = await datasette.check_visibility(
request.actor,
action="view-table",
resource=TableResource(database=database_name, table=fk["other_table"]),
)
if not visible:
continue
autocomplete_urls[fk["column"]] = "{}/-/autocomplete".format(
datasette.urls.table(database_name, fk["other_table"])
)
return autocomplete_urls
async def _table_page_data(
datasette,
request,
db,
database_name,
table_name,
is_view,
table_insert_ui,
table_alter_ui,
):
data = {
"database": database_name,
"table": table_name,
"tableUrl": datasette.urls.table(database_name, table_name),
}
if table_insert_ui:
data["insertRow"] = table_insert_ui
if table_alter_ui:
data["alterTable"] = table_alter_ui
if not is_view:
foreign_keys = await _foreign_key_autocomplete_urls(
datasette, request, db, database_name, table_name
)
if foreign_keys:
data["foreignKeys"] = foreign_keys
return data
async def _table_insert_ui(
datasette, request, db, database_name, table_name, is_view, pks
):
if is_view or not db.is_mutable:
return None
if not await datasette.allowed(
action="insert-row",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return None
column_types_map = await datasette.get_column_types(database_name, table_name)
columns = []
column_details = await db.table_column_details(table_name)
for column in column_details:
if column.hidden:
continue
is_pk = column.name in pks
is_auto_pk = (
is_pk
and len(pks) == 1
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
)
if is_auto_pk:
continue
column_type = column_types_map.get(column.name)
columns.append(
{
"name": column.name,
"sqlite_type": _column_sqlite_type_for_insert_form(column),
"notnull": column.notnull,
"default": column.default_value,
"has_default": column.default_value is not None,
"is_pk": is_pk,
"value_kind": _column_value_kind_for_insert_form(column),
"column_type": (
{"type": column_type.name, "config": column_type.config}
if column_type is not None
else None
),
}
)
return {
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"primaryKeys": pks,
}
async def _table_alter_ui(
datasette, request, db, database_name, table_name, is_view, pks
):
if is_view or not db.is_mutable:
return None
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return None
column_types_map = await datasette.get_column_types(database_name, table_name)
foreign_keys_by_column = {}
for fk in await db.foreign_keys_for_table(table_name):
other_column = fk["other_column"]
if other_column is None and await db.table_exists(fk["other_table"]):
other_pks = await db.primary_keys(fk["other_table"])
if len(other_pks) == 1:
other_column = other_pks[0]
if other_column is None:
continue
foreign_keys_by_column[fk["column"]] = {
"fk_table": fk["other_table"],
"fk_column": other_column,
}
columns = []
for column in await db.table_column_details(table_name):
if column.hidden:
continue
sqlite_type = SQLiteType.from_declared_type(column.type)
column_type = column_types_map.get(column.name)
default_expr = default_expr_for_sql(column.default_value)
column_data = {
"name": column.name,
"type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"),
"sqlite_type": sqlite_type.value,
"notnull": column.notnull,
"default": None if default_expr else column.default_value,
"has_default": column.default_value is not None,
"is_pk": column.name in pks,
"foreign_key": foreign_keys_by_column.get(column.name),
"column_type": (
{"type": column_type.name, "config": column_type.config}
if column_type is not None
else None
),
}
if default_expr:
column_data["default_expr"] = default_expr
columns.append(column_data)
data = {
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"primaryKeys": pks,
"columnTypes": ALTER_TABLE_COLUMN_TYPES,
"defaultExpressions": default_expression_options(),
"foreignKeyTargetsPath": "{}/-/foreign-key-targets?table={}".format(
datasette.urls.database(database_name),
urllib.parse.quote(table_name, safe=""),
),
}
can_set_column_type = await datasette.allowed(
action="set-column-type",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
if can_set_column_type:
data["customColumnTypes"] = _custom_column_type_options_for_create_table(
datasette
)
can_drop_table = await datasette.allowed(
action="drop-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
if can_drop_table:
data["dropPath"] = "{}/-/drop".format(
datasette.urls.table(database_name, table_name)
)
return data
async def display_columns_and_rows(
datasette,
database_name,
@ -322,6 +625,16 @@ async def display_columns_and_rows(
pks_for_display = pks
if not pks_for_display:
pks_for_display = ["rowid"]
label_column = None
if link_column:
label_column = await db.label_column_for_table(table_name)
row_action_permissions = {}
if link_column and request is not None and db.is_mutable:
row_action_permissions = await datasette.allowed_many(
actions=["update-row", "delete-row"],
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
columns = []
for r in description:
@ -361,19 +674,72 @@ async def display_columns_and_rows(
if link_column:
is_special_link_column = len(pks) != 1
pk_path = path_from_row_pks(row, pks, not pks, False)
row_path = path_from_row_pks(row, pks, not pks)
row_label = row_label_from_label_column(row, label_column)
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = "{} {}".format(pk_path, row_label)
table_path = datasette.urls.table(database_name, table_name)
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
table_path=table_path,
flat_pks=str(markupsafe.escape(pk_path)),
flat_pks_quoted=row_path,
)
edit_icon = (
'<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">'
'<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>'
'<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>'
"</svg>"
)
delete_icon = (
'<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">'
'<path d="M3 6h18"></path>'
'<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>'
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>'
'<path d="M10 11v6"></path>'
'<path d="M14 11v6"></path>'
"</svg>"
)
row_actions = []
if row_action_permissions.get("update-row"):
row_actions.append(
'<button type="button" class="row-inline-action row-inline-action-edit" '
'aria-label="Edit row {row_label}" title="Edit row" '
'data-row-action="edit">'
"{edit_icon}</button>".format(
edit_icon=edit_icon,
row_label=markupsafe.escape(row_action_label),
)
)
if row_action_permissions.get("delete-row"):
row_actions.append(
'<button type="button" class="row-inline-action row-inline-action-delete" '
'aria-label="Delete row {row_label}" title="Delete row" '
'data-row-action="delete">'
"{delete_icon}</button>".format(
delete_icon=delete_icon,
row_label=markupsafe.escape(row_action_label),
)
)
if row_actions:
row_link = (
'<span class="row-link-with-actions">{row_link}'
'<span class="row-inline-actions" aria-label="Row actions">'
"{row_actions}</span></span>"
).format(row_link=row_link, row_actions="".join(row_actions))
cells.append(
{
"column": pks[0] if len(pks) == 1 else "Link",
"value_type": "pk",
"is_special_link_column": is_special_link_column,
"raw": pk_path,
"value": markupsafe.Markup(
'<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
table_path=datasette.urls.table(database_name, table_name),
flat_pks=str(markupsafe.escape(pk_path)),
flat_pks_quoted=path_from_row_pks(row, pks, not pks),
)
),
"value": markupsafe.Markup(row_link),
}
)
@ -479,7 +845,17 @@ async def display_columns_and_rows(
),
}
)
cell_rows.append(Row(cells))
if link_column:
cell_rows.append(
Row(
cells,
pk_path=pk_path,
row_path=row_path,
row_label=row_label,
)
)
else:
cell_rows.append(Row(cells))
if link_column:
# Add the link column header.
@ -532,9 +908,8 @@ class TableInsertView(BaseView):
if not request.headers.get("content-type").startswith("application/json"):
# TODO: handle form-encoded data
return _errors(["Invalid content-type, must be application/json"])
body = await request.post_body()
try:
data = json.loads(body)
data = await request.json()
except json.JSONDecodeError as e:
return _errors(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict):
@ -833,7 +1208,7 @@ class TableSetColumnTypeView(BaseView):
return _error(["Invalid content-type, must be application/json"], 400)
try:
data = json.loads(await request.post_body())
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
@ -950,7 +1325,7 @@ class TableDropView(BaseView):
return _error(["Database is immutable"], 403)
confirm = False
try:
data = json.loads(await request.post_body())
data = await request.json()
confirm = data.get("confirm")
except json.JSONDecodeError:
pass
@ -979,9 +1354,228 @@ class TableDropView(BaseView):
actor=request.actor, database=database_name, table=table_name
)
)
self.ds.add_message(
request,
"Table {} dropped".format(table_name),
self.ds.WARNING,
)
return Response.json({"ok": True}, status=200)
class TableFragmentView(BaseView):
name = "table-fragment"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
resolved = await self.ds.resolve_table(request)
request = await _fragment_request_for_row(request, resolved)
view_data = await table_view_data(
self.ds,
request,
resolved,
extra_extras={"_html"},
context_for_html_hack=True,
default_labels=True,
)
if isinstance(view_data, Response):
return view_data
data, _rows, _columns, _expanded_columns, _sql, _next_url = view_data
templates = data["custom_table_templates"]
html = await self.ds.render_template(
templates,
dict(
data,
append_querystring=append_querystring,
path_with_replaced_args=path_with_replaced_args,
fix_path=self.ds.urls.path,
settings=self.ds.settings_dict(),
count_limit=resolved.db.count_limit,
),
request=request,
view_name="table",
)
return Response.html(html)
def _escape_like(value):
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
# Returns the exclusive upper bound for an indexed prefix search:
# For example, values beginning with "abc" fall below the next prefix boundary.
# The LIKE clause is still applied separately for exact escaped-LIKE semantics.
def _prefix_range_end(value):
if not value:
return None
characters = list(value)
for i in range(len(characters) - 1, -1, -1):
if ord(characters[i]) < 0x10FFFF:
return "{}{}".format("".join(characters[:i]), chr(ord(characters[i]) + 1))
return None
def _autocomplete_like(column):
return "{} like :like escape char(92)".format(escape_sqlite(column))
def _autocomplete_prefix_like(column):
return "{} like :prefix escape char(92)".format(escape_sqlite(column))
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
clauses = []
if exact_pk:
clauses.append(
"case when cast({} as text) = :q then 0 else 1 end".format(
escape_sqlite(pks[0])
)
)
if label_column:
label_like = _autocomplete_like(label_column)
if label_matches_first:
clauses.append("case when {} then 0 else 1 end".format(label_like))
clauses.append(
"case when {} then length(cast({} as text)) end".format(
label_like, escape_sqlite(label_column)
)
)
else:
clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0])))
clauses.extend(escape_sqlite(pk) for pk in pks)
return ", ".join(clauses)
def _autocomplete_pk_order_by(pks):
return ", ".join(escape_sqlite(pk) for pk in pks)
def _autocomplete_initial_order_by(pks):
order_by = [f"{escape_sqlite(pks[0])} desc"]
order_by.extend(escape_sqlite(pk) for pk in pks[1:])
return ", ".join(order_by)
def _autocomplete_response_rows(rows, pks, label_column):
response_rows = []
for row in rows:
item = {"pks": {pk: row[pk] for pk in pks}}
if label_column:
item["label"] = row[label_column]
response_rows.append(item)
return response_rows
AUTOCOMPLETE_TIME_LIMIT_MS = 500
class TableAutocompleteView(BaseView):
name = "table-autocomplete"
async def get(self, request):
resolved = await self.ds.resolve_table(request)
if resolved.is_view:
raise BadRequest("Autocomplete is only available for tables")
db = resolved.db
database_name = db.name
table_name = resolved.table
visible, _ = await self.ds.check_visibility(
request.actor,
action="view-table",
resource=TableResource(database=database_name, table=table_name),
)
if not visible:
raise Forbidden("You do not have permission to view this table")
pks = await db.primary_keys(table_name)
if not pks:
pks = ["rowid"]
label_column = await db.label_column_for_table(table_name)
select_columns = list(
dict.fromkeys(pks + ([label_column] if label_column else []))
)
select_sql = ", ".join(escape_sqlite(column) for column in select_columns)
q = request.args.get("q") or ""
initial_arg = request.args.get("_initial")
initial = (
not q
and initial_arg is not None
and initial_arg != ""
and value_as_boolean(initial_arg)
)
if not q and not initial:
return Response.json({"rows": []})
params = {
"q": q,
"like": "%{}%".format(_escape_like(q)),
"prefix": "{}%".format(_escape_like(q)),
}
like_columns = pks[:]
if label_column and label_column not in like_columns:
like_columns.append(label_column)
where_sql = " or ".join(_autocomplete_like(column) for column in like_columns)
exact_pk = len(pks) == 1
order_by = _autocomplete_order_by(pks, label_column, exact_pk)
if initial:
where_sql = "1 = 1"
order_by = _autocomplete_initial_order_by(pks)
sql = """
select {select_sql}
from {table}
where {where}
order by {order_by}
limit 10
""".format(
select_sql=select_sql,
table=escape_sqlite(table_name),
where=where_sql,
order_by=order_by,
)
try:
results = await db.execute(
sql, params, custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS
)
except QueryInterrupted:
fallback_where = _autocomplete_prefix_like(pks[0])
prefix_end = _prefix_range_end(q)
if prefix_end:
params["prefix_end"] = prefix_end
first_pk = escape_sqlite(pks[0])
fallback_where = (
"{first_pk} >= :q and {first_pk} < :prefix_end and {like}"
).format(first_pk=first_pk, like=fallback_where)
fallback_sql = """
select {select_sql}
from {table}
where {where}
order by {order_by}
limit 10
""".format(
select_sql=select_sql,
table=escape_sqlite(table_name),
where=fallback_where,
order_by=_autocomplete_pk_order_by(pks),
)
try:
results = await db.execute(
fallback_sql,
params,
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
)
except QueryInterrupted:
return Response.json({"rows": []})
return Response.json(
{"rows": _autocomplete_response_rows(results.rows, pks, label_column)}
)
async def _columns_to_select(table_columns, pks, request):
columns = list(table_columns)
if "_col" in request.args:
@ -1293,6 +1887,15 @@ async def table_view_data(
if redirect_response:
return redirect_response
if context_for_html_hack:
await precompute_database_action_permissions(
datasette, request.actor, database_name
)
if not is_view:
await precompute_table_action_permissions(
datasette, request.actor, database_name, table_name
)
# Introspect columns and primary keys for table
pks = await db.primary_keys(table_name)
table_columns = await db.table_columns(table_name)
@ -1716,6 +2319,24 @@ async def table_view_data(
sort = "rowid"
data["sort"] = sort
data["sort_desc"] = sort_desc
table_insert_ui = await _table_insert_ui(
datasette, request, db, database_name, table_name, is_view, pks
)
table_alter_ui = await _table_alter_ui(
datasette, request, db, database_name, table_name, is_view, pks
)
data["table_insert_ui"] = table_insert_ui
data["table_alter_ui"] = table_alter_ui
data["table_page_data"] = await _table_page_data(
datasette=datasette,
request=request,
db=db,
database_name=database_name,
table_name=table_name,
is_view=is_view,
table_insert_ui=table_insert_ui,
table_alter_ui=table_alter_ui,
)
return data, rows[:page_size], columns, expanded_columns, sql, next_url

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ from dataclasses import dataclass
from datasette.database import QueryInterrupted
from datasette.extras import Extra, ExtraExample, ExtraRegistry, ExtraScope, Provider
from datasette.plugins import pm
from datasette.resources import TableResource
from datasette.resources import DatabaseResource, TableResource
from datasette.utils import (
await_me_maybe,
call_with_supported_arguments,
@ -184,6 +184,7 @@ class FacetResultsExtra(Extra):
)
scopes = {ExtraScope.TABLE}
expensive = True
docs_note = "See :ref:`facets` for details of how facets work."
async def resolve(self, context, facet_instances):
facet_results = {}
@ -215,7 +216,12 @@ class FacetResultsExtra(Extra):
class FacetsTimedOutExtra(Extra):
description = "Facet calculations that timed out"
example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_extra=facets_timed_out"
"/fixtures/facetable.json?_facet=state&_extra=facets_timed_out",
note=(
"A list of the names of any facets that exceeded the "
":ref:`setting_facet_time_limit_ms` time limit - an empty list "
"if every facet calculation completed."
),
)
scopes = {ExtraScope.TABLE}
@ -236,6 +242,9 @@ class SuggestedFacetsExtra(Extra):
)
scopes = {ExtraScope.TABLE}
expensive = True
docs_note = (
"Suggestions are controlled by the :ref:`setting_suggest_facets` setting."
)
async def resolve(self, context, facet_instances):
suggested_facets = []
@ -278,7 +287,13 @@ class HumanDescriptionEnExtra(Extra):
class NextUrlExtra(Extra):
description = "Full URL for the next page of results"
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=next_url")
example = ExtraExample(
"/fixtures/facetable.json?_size=1&_extra=next_url",
note=(
"``null`` if there are no more pages of results. "
"See :ref:`json_api_pagination`."
),
)
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
@ -346,6 +361,21 @@ class ActionsExtra(Extra):
else:
kwargs["table"] = context.table_name
method = pm.hook.table_actions
# Resolve the registered table-level actions for this table
# and the database-level actions for its database in two
# batched queries, seeding the request permission cache so
# that allowed() calls made inside the plugin hooks below
# are served from the cache
datasette = context.datasette
await precompute_table_action_permissions(
datasette,
context.request.actor,
context.database_name,
context.table_name,
)
await precompute_database_action_permissions(
datasette, context.request.actor, context.database_name
)
for hook in method(**kwargs):
extra_links = await await_me_maybe(hook)
if extra_links:
@ -355,6 +385,32 @@ class ActionsExtra(Extra):
return actions
async def precompute_table_action_permissions(
datasette, actor, database_name, table_name
):
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is TableResource
],
resource=TableResource(database_name, table_name),
actor=actor,
)
async def precompute_database_action_permissions(datasette, actor, database_name):
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database_name),
actor=actor,
)
class IsViewExtra(Extra):
description = "Whether this resource is a view instead of a table"
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
@ -366,6 +422,10 @@ class IsViewExtra(Extra):
class DebugExtra(Extra):
description = "Extra debug information"
docs_note = (
"The contents of this block are not a stable part of the Datasette "
"API and may change without warning."
)
example = ExtraExample("/fixtures/facetable.json?_extra=debug")
examples = {
ExtraScope.ROW: ExtraExample(
@ -482,6 +542,10 @@ class DisplayRowsExtra(Extra):
class RenderCellExtra(Extra):
description = "Rendered HTML for each cell using the render_cell plugin hook"
docs_note = (
"See the :ref:`render_cell() plugin hook <plugin_hook_render_cell>` "
"documentation."
)
example = ExtraExample(
value={
"rows": [
@ -598,7 +662,28 @@ class QueryExtra(Extra):
class ColumnTypesExtra(Extra):
description = "Column type assignments for this table"
example = ExtraExample(value={})
docs_note = (
"An empty object if no column types have been assigned. Column types "
"can be assigned in :ref:`configuration "
"<table_configuration_column_types>` or using the :ref:`set column "
"type API <TableSetColumnTypeView>`."
)
example = ExtraExample(
"/fixtures/facetable.json?_size=0&_extra=column_types",
note=(
"This example is from an instance where the ``tags`` column has "
"been assigned the ``json`` column type."
),
)
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/facetable/1.json?_extra=column_types",
note=(
"This example is from an instance where the ``tags`` column "
"has been assigned the ``json`` column type."
),
)
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context):
@ -615,7 +700,40 @@ class ColumnTypesExtra(Extra):
class SetColumnTypeUiExtra(Extra):
description = "Column type UI metadata for this table"
description = "Information needed to build an interface for assigning column types"
docs_note = (
"``null`` unless the current actor is allowed to use the :ref:`set "
"column type API <TableSetColumnTypeView>` for this table."
)
example = ExtraExample(
value={
"path": "/fixtures/facetable/-/set-column-type",
"columns": {
"created": {
"current": None,
"options": [
{"name": "email", "description": "Email address"},
{"name": "json", "description": "JSON data"},
{"name": "url", "description": "URL"},
],
},
"tags": {
"current": {"type": "json", "config": None},
"options": [
{"name": "email", "description": "Email address"},
{"name": "json", "description": "JSON data"},
{"name": "url", "description": "URL"},
],
},
},
},
note=(
"Shape abbreviated to two columns, as seen by an actor with "
"``set-column-type`` permission. ``current`` is the column type "
"currently assigned to each column and ``options`` lists the "
"types that could be assigned to it."
),
)
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
@ -667,13 +785,33 @@ class SetColumnTypeUiExtra(Extra):
class MetadataExtra(Extra):
description = "Metadata about the table, database or stored query"
example = ExtraExample("/fixtures/facetable.json?_extra=metadata")
docs_note = "See :ref:`metadata` for how to attach metadata to tables."
example = ExtraExample(
"/fixtures/facetable.json?_extra=metadata",
note=(
"This example is from an instance where the ``facetable`` table "
"has a metadata ``description`` and a :ref:`column description "
"<metadata_column_descriptions>` for its ``state`` column. The "
"``columns`` object is empty for tables with no column "
"descriptions."
),
)
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=metadata"
"/fixtures/simple_primary_key/1.json?_extra=metadata",
note=(
"This table has no metadata, so only an empty ``columns`` "
"object is returned."
),
),
ExtraScope.QUERY: ExtraExample(
"/fixtures/neighborhood_search.json?text=town&_extra=metadata"
"/fixtures/neighborhood_search.json?text=town&_extra=metadata",
note=(
"For stored queries this returns the full configuration of "
"the query, including the :ref:`stored query options "
"<queries_options>`. For ``?sql=`` queries it returns an "
"empty object."
),
),
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
@ -733,6 +871,10 @@ class TableExtra(Extra):
class DatabaseColorExtra(Extra):
description = "Color assigned to the database"
docs_note = (
"A six character hex color, without the leading ``#``, derived from "
"a hash of the database name and used in the Datasette interface."
)
example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
examples = {
ExtraScope.ROW: ExtraExample(
@ -780,6 +922,11 @@ class FiltersExtra(Extra):
class CustomTableTemplatesExtra(Extra):
description = "Custom template names considered for this table"
docs_note = (
"The first template in this list that exists will be used to render "
"the table on the HTML version of this page. See "
":ref:`customization_custom_templates`."
)
example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates")
scopes = {ExtraScope.TABLE}
@ -793,6 +940,12 @@ class CustomTableTemplatesExtra(Extra):
class SortedFacetResultsExtra(Extra):
description = "Facet results sorted for display"
docs_note = (
"The same data as ``facet_results``, as a list in the order used by "
"the HTML interface: facets from :ref:`facet configuration "
"<facets_metadata>` first, then other facets ordered by their number "
"of results."
)
example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results"
)
@ -849,7 +1002,15 @@ class ViewDefinitionExtra(Extra):
class RenderersExtra(Extra):
description = "Alternative output renderers available for this table"
example = ExtraExample("/fixtures/facetable.json?_extra=renderers")
example = ExtraExample(
"/fixtures/facetable.json?_extra=renderers",
note=(
"Each key is the name of an output format, each value the URL "
"for this data in that format. Plugins can add additional "
"formats using the :ref:`register_output_renderer() plugin hook "
"<plugin_register_output_renderer>`."
),
)
scopes = {ExtraScope.TABLE}
async def resolve(self, context, expandable_columns, query):
@ -887,6 +1048,10 @@ class RenderersExtra(Extra):
class PrivateExtra(Extra):
description = "Whether this resource is private to the current actor"
docs_note = (
"``true`` if the current actor can see this resource but an "
"anonymous user could not. See :ref:`authentication_permissions`."
)
example = ExtraExample("/fixtures/facetable.json?_extra=private")
examples = {
ExtraScope.ROW: ExtraExample(
@ -904,7 +1069,15 @@ class PrivateExtra(Extra):
class ExpandableColumnsExtra(Extra):
description = "Foreign key columns that can be expanded with labels"
example = ExtraExample("/fixtures/facetable.json?_extra=expandable_columns")
docs_note = "See :ref:`expand_foreign_keys` for how to expand these labels."
example = ExtraExample(
"/fixtures/facetable.json?_extra=expandable_columns",
note=(
"Each item is a ``[foreign_key, label_column]`` pair: the "
"foreign key relationship, then the column in the other table "
"that would be used as the label for each expanded value."
),
)
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
@ -919,9 +1092,14 @@ class ExpandableColumnsExtra(Extra):
class ForeignKeyTablesExtra(Extra):
description = "Tables that link to this row using foreign keys"
example = ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables"
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables",
note=(
"``count`` is the number of rows in the other table that "
"reference this row, and ``link`` is a URL to browse those rows."
),
)
scopes = {ExtraScope.ROW}
expensive = True
async def resolve(self, context):
return await context.foreign_key_tables(
@ -930,7 +1108,30 @@ class ForeignKeyTablesExtra(Extra):
class ExtrasExtra(Extra):
description = "Available ?_extra= blocks"
description = "List of ?_extra= blocks that can be used on this page"
example = ExtraExample(
value=[
{
"name": "count",
"description": "Total count of rows matching these filters",
"toggle_url": "http://localhost/fixtures/facetable.json?_extra=extras&_extra=count",
"selected": False,
},
{
"name": "extras",
"description": "List of ?_extra= blocks that can be used on this page",
"toggle_url": "http://localhost/fixtures/facetable.json",
"selected": True,
},
],
note=(
"Shape abbreviated from /fixtures/facetable.json?_extra=extras - "
"the full response lists every extra described on this page. "
"``toggle_url`` is the current URL with that extra added or "
"removed, and ``selected`` is ``true`` for extras included in "
"the current request."
),
)
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
async def resolve(self, context):

View file

@ -4,20 +4,80 @@
Changelog
=========
.. _v1_0_a33:
.. _unreleased:
1.0a33 (unreleased)
Unreleased
----------
- New "Create table" interface in the database actions menu, backed by the ``/<database>/-/create`` :ref:`JSON API <TableCreateView>`. It can define columns, primary keys, custom column types, ``NOT NULL`` constraints, literal defaults, expression defaults and single-column foreign keys. (:issue:`2787`)
- New "Alter table" table action and ``/<database>/<table>/-/alter`` :ref:`JSON API <TableAlterView>` for changing existing tables: add, rename, reorder and drop columns; change column types, defaults, ``NOT NULL`` constraints, primary keys and foreign keys; and rename the table. The alter table dialog also includes a "Drop table" button. (:issue:`2788`)
- New ``/<database>/-/foreign-key-targets`` and ``/<database>/<table>/-/foreign-key-suggestions`` JSON APIs for discovering valid single-column foreign key targets and suggested relationships.
- Create and alter table dialogs share their column-editing controls, including literal and expression defaults, custom column types, foreign keys and column ordering.
.. _v1_0_a34:
1.0a34 (2026-06-16)
-------------------
- Stored queries can now be edited and deleted from the web interface. 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`)
- 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. See :ref:`json_api_extra` for the full list.
- New generated reference documentation for 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`.
- ``?_extra=`` values can be separated by commas as well as repeated, e.g. ``?_extra=count,next_url``. Previously a comma-separated value that included ``columns`` failed to include the ``columns`` key in the response.
- The ``?_extra=private`` extra on arbitrary SQL query pages now correctly reflects whether the SQL execution permission is private to the current actor - it previously always returned ``false``.
- The ``?_extra=query`` extra on query pages now reports the named parameters that were actually bound when the query executed, including parameters declared in a stored query's ``params`` list. Magic ``_``-prefixed parameters are no longer echoed back with unbound values taken from the querystring.
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)

View file

@ -1102,9 +1102,9 @@ These configure :ref:`full-text search <full_text_search>` for a table or view.
``column_types``
^^^^^^^^^^^^^^^^
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.
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.
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.
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.
The simplest form maps column names to type name strings:
@ -1119,6 +1119,7 @@ The simplest form maps column names to type name strings:
website: url
contact: email
extra_data: json
notes: textarea
""").strip()
)
.. ]]]
@ -1135,6 +1136,7 @@ The simplest form maps column names to type name strings:
website: url
contact: email
extra_data: json
notes: textarea
.. tab:: datasette.json
@ -1148,7 +1150,8 @@ The simplest form maps column names to type name strings:
"column_types": {
"website": "url",
"contact": "email",
"extra_data": "json"
"extra_data": "json",
"notes": "textarea"
}
}
}

View file

@ -62,6 +62,76 @@ You can run the tests faster using multiple CPU cores with `pytest-xdist <https:
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
You can add the ``--headed`` option to have Playwright open a browser window that you can see while it runs the tests. This only works if you specify a browser, for example:
.. code-block:: bash
just playwright firefox --headed
Combine this with ``-k`` to watch a specific test:
.. code-block:: bash
just playwright chromium --headed -k test_insert_row
If you are not using ``just``, the equivalent ``uv run`` 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:
Using fixtures

View file

@ -279,13 +279,28 @@ Here is an example of a custom ``_table.html`` template:
.. code-block:: jinja
{% for row in display_rows %}
<div>
<div data-row="{{ row.row_path }}">
<h2>{{ row["title"] }}</h2>
<p>{{ row["description"] }}<lp>
<p>Category: {{ row.display("category_id") }}</p>
</div>
{% 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

View file

@ -106,6 +106,9 @@ The object also has the following awaitable methods:
``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.
``await request.json()`` - Any
Returns the parsed JSON body of a request submitted by ``POST``.
``await request.post_body()`` - bytes
Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.
@ -512,6 +515,43 @@ Example usage:
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:
await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None)

View file

@ -201,6 +201,15 @@ 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.
.. _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:
/-/threads

View file

@ -46,6 +46,9 @@ The ``datasetteManager`` object
``registerPlugin(name, 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
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
@ -60,22 +63,26 @@ The ``implementation`` object passed to this method should include a ``version``
.. _javascript_plugins_makeJumpSections:
makeJumpSections()
~~~~~~~~~~~~~~~~~~
makeJumpSections(context)
~~~~~~~~~~~~~~~~~~~~~~~~~
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.
Each object should have the following:
It should return an array of objects, each with the following:
``id`` - string
A unique string ID for the section, for example ``agent-chat``
``render(node, context)`` - function
A function that will be called with a DOM node to render the section into
The ``context`` object has the following keys:
Datasette passes a ``context`` object to both ``makeJumpSections(context)`` and ``render(node, context)``. It has the following keys:
``navigationSearch``
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:
@ -84,11 +91,11 @@ This example shows how a plugin might add a button for starting a new chat:
document.addEventListener('datasette_init', function(ev) {
ev.detail.registerPlugin('agent-plugin', {
version: 0.1,
makeJumpSections: () => {
makeJumpSections: (context) => {
return [
{
id: 'agent-chat',
render: node => {
render: (node, context) => {
node.innerHTML = '<button type="button">Start a new chat</button>';
node.querySelector('button').addEventListener('click', () => {
location.href = '/-/agent/new';
@ -188,6 +195,285 @@ 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:
Selectors

View file

@ -44,7 +44,7 @@ looks like this:
``"ok"`` is always ``true`` if an error did not occur.
The ``"rows"`` key is a list of objects, each one representing a row.
The ``"rows"`` key is a list of objects, each one representing a row.
The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting).
@ -276,7 +276,7 @@ The available table extras are listed below.
"select count(*) from facetable "
``facet_results``
Results of facets calculated against this data (May execute additional queries.)
Results of facets calculated against this data (May execute additional queries. See :ref:`facets` for details of how facets work.)
Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.
@ -309,12 +309,14 @@ The available table extras are listed below.
``GET /fixtures/facetable.json?_facet=state&_extra=facets_timed_out``
A list of the names of any facets that exceeded the :ref:`setting_facet_time_limit_ms` time limit - an empty list if every facet calculation completed.
.. code-block:: json
[]
``suggested_facets``
Suggestions for facets that might return interesting results (May execute additional queries.)
Suggestions for facets that might return interesting results (May execute additional queries. Suggestions are controlled by the :ref:`setting_suggest_facets` setting.)
Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.
@ -341,6 +343,8 @@ The available table extras are listed below.
``GET /fixtures/facetable.json?_size=1&_extra=next_url``
``null`` if there are no more pages of results. See :ref:`json_api_pagination`.
.. code-block:: json
"http://localhost/fixtures/facetable.json?_size=1&_extra=next_url&_next=1"
@ -426,7 +430,7 @@ The available table extras are listed below.
]
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook
Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook <plugin_hook_render_cell>` documentation.)
The ``render_cell`` array has one item per row, in the same order as the ``rows`` array. Each object is keyed by column name. Only columns whose rendered value differs from the default are included.
@ -452,7 +456,7 @@ The available table extras are listed below.
}
``debug``
Extra debug information
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
``GET /fixtures/facetable.json?_extra=debug``
@ -501,28 +505,108 @@ The available table extras are listed below.
}
``column_types``
Column type assignments for this table
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
.. code-block:: json
``GET /fixtures/facetable.json?_size=0&_extra=column_types``
{}
``set_column_type_ui``
Column type UI metadata for this table
``metadata``
Metadata about the table, database or stored query
``GET /fixtures/facetable.json?_extra=metadata``
This example is from an instance where the ``tags`` column has been assigned the ``json`` column type.
.. code-block:: json
{
"columns": {}
"tags": {
"type": "json",
"config": null
}
}
``set_column_type_ui``
Information needed to build an interface for assigning column types (``null`` unless the current actor is allowed to use the :ref:`set column type API <TableSetColumnTypeView>` for this table.)
Shape abbreviated to two columns, as seen by an actor with ``set-column-type`` permission. ``current`` is the column type currently assigned to each column and ``options`` lists the types that could be assigned to it.
.. code-block:: json
{
"path": "/fixtures/facetable/-/set-column-type",
"columns": {
"created": {
"current": null,
"options": [
{
"name": "email",
"description": "Email address"
},
{
"name": "json",
"description": "JSON data"
},
{
"name": "url",
"description": "URL"
}
]
},
"tags": {
"current": {
"type": "json",
"config": null
},
"options": [
{
"name": "email",
"description": "Email address"
},
{
"name": "json",
"description": "JSON data"
},
{
"name": "url",
"description": "URL"
}
]
}
}
}
``metadata``
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
``GET /fixtures/facetable.json?_extra=metadata``
This example is from an instance where the ``facetable`` table has a metadata ``description`` and a :ref:`column description <metadata_column_descriptions>` for its ``state`` column. The ``columns`` object is empty for tables with no column descriptions.
.. code-block:: json
{
"description": "A demo table of places, used to demonstrate facets",
"columns": {
"state": "Two letter US state code"
}
}
``extras``
Available ?_extra= blocks
List of ?_extra= blocks that can be used on this page
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
.. code-block:: json
[
{
"name": "count",
"description": "Total count of rows matching these filters",
"toggle_url": "http://localhost/fixtures/facetable.json?_extra=extras&_extra=count",
"selected": false
},
{
"name": "extras",
"description": "List of ?_extra= blocks that can be used on this page",
"toggle_url": "http://localhost/fixtures/facetable.json",
"selected": true
}
]
``database``
Database name
@ -543,7 +627,7 @@ The available table extras are listed below.
"facetable"
``database_color``
Color assigned to the database
Color assigned to the database (A six character hex color, without the leading ``#``, derived from a hash of the database name and used in the Datasette interface.)
``GET /fixtures/facetable.json?_extra=database_color``
@ -556,6 +640,8 @@ The available table extras are listed below.
``GET /fixtures/facetable.json?_extra=renderers``
Each key is the name of an output format, each value the URL for this data in that format. Plugins can add additional formats using the :ref:`register_output_renderer() plugin hook <plugin_register_output_renderer>`.
.. code-block:: json
{
@ -563,7 +649,7 @@ The available table extras are listed below.
}
``custom_table_templates``
Custom template names considered for this table
Custom template names considered for this table (The first template in this list that exists will be used to render the table on the HTML version of this page. See :ref:`customization_custom_templates`.)
``GET /fixtures/facetable.json?_extra=custom_table_templates``
@ -576,7 +662,7 @@ The available table extras are listed below.
]
``sorted_facet_results``
Facet results sorted for display
Facet results sorted for display (The same data as ``facet_results``, as a list in the order used by the HTML interface: facets from :ref:`facet configuration <facets_metadata>` first, then other facets ordered by their number of results.)
``GET /fixtures/facetable.json?_facet=state&_extra=sorted_facet_results``
@ -643,7 +729,7 @@ The available table extras are listed below.
true
``private``
Whether this resource is private to the current actor
Whether this resource is private to the current actor (``true`` if the current actor can see this resource but an anonymous user could not. See :ref:`authentication_permissions`.)
``GET /fixtures/facetable.json?_extra=private``
@ -652,10 +738,12 @@ The available table extras are listed below.
false
``expandable_columns``
Foreign key columns that can be expanded with labels
Foreign key columns that can be expanded with labels (See :ref:`expand_foreign_keys` for how to expand these labels.)
``GET /fixtures/facetable.json?_extra=expandable_columns``
Each item is a ``[foreign_key, label_column]`` pair: the foreign key relationship, then the column in the other table that would be used as the label for each expanded value.
.. code-block:: json
[
@ -720,7 +808,7 @@ The following extras are available for row JSON responses.
]
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook
Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook <plugin_hook_render_cell>` documentation.)
The ``render_cell`` array has one item for the requested row. The object is keyed by column name. Only columns whose rendered value differs from the default are included.
@ -741,7 +829,7 @@ The following extras are available for row JSON responses.
}
``debug``
Extra debug information
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
``GET /fixtures/simple_primary_key/1.json?_extra=debug``
@ -803,17 +891,28 @@ The following extras are available for row JSON responses.
}
``column_types``
Column type assignments for this table
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
``GET /fixtures/facetable/1.json?_extra=column_types``
This example is from an instance where the ``tags`` column has been assigned the ``json`` column type.
.. code-block:: json
{}
{
"tags": {
"type": "json",
"config": null
}
}
``metadata``
Metadata about the table, database or stored query
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
``GET /fixtures/simple_primary_key/1.json?_extra=metadata``
This table has no metadata, so only an empty ``columns`` object is returned.
.. code-block:: json
{
@ -821,7 +920,26 @@ The following extras are available for row JSON responses.
}
``extras``
Available ?_extra= blocks
List of ?_extra= blocks that can be used on this page
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
.. code-block:: json
[
{
"name": "count",
"description": "Total count of rows matching these filters",
"toggle_url": "http://localhost/fixtures/facetable.json?_extra=extras&_extra=count",
"selected": false
},
{
"name": "extras",
"description": "List of ?_extra= blocks that can be used on this page",
"toggle_url": "http://localhost/fixtures/facetable.json",
"selected": true
}
]
``database``
Database name
@ -842,7 +960,7 @@ The following extras are available for row JSON responses.
"simple_primary_key"
``database_color``
Color assigned to the database
Color assigned to the database (A six character hex color, without the leading ``#``, derived from a hash of the database name and used in the Datasette interface.)
``GET /fixtures/simple_primary_key/1.json?_extra=database_color``
@ -851,7 +969,7 @@ The following extras are available for row JSON responses.
"9403e5"
``private``
Whether this resource is private to the current actor
Whether this resource is private to the current actor (``true`` if the current actor can see this resource but an anonymous user could not. See :ref:`authentication_permissions`.)
``GET /fixtures/simple_primary_key/1.json?_extra=private``
@ -860,10 +978,12 @@ The following extras are available for row JSON responses.
false
``foreign_key_tables``
Tables that link to this row using foreign keys
Tables that link to this row using foreign keys (May execute additional queries.)
``GET /fixtures/simple_primary_key/1.json?_extra=foreign_key_tables``
``count`` is the number of rows in the other table that reference this row, and ``link`` is a URL to browse those rows.
.. code-block:: json
[
@ -921,7 +1041,7 @@ The following extras are available for arbitrary SQL query responses and stored,
]
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook
Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook <plugin_hook_render_cell>` documentation.)
The ``render_cell`` array has one item per query result row, in the same order as the ``rows`` array. Each object is keyed by column name. Only columns whose rendered value differs from the default are included.
@ -941,7 +1061,7 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``debug``
Extra debug information
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=debug``
@ -1000,10 +1120,12 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``metadata``
Metadata about the table, database or stored query
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
``GET /fixtures/neighborhood_search.json?text=town&_extra=metadata``
For stored queries this returns the full configuration of the query, including the :ref:`stored query options <queries_options>`. For ``?sql=`` queries it returns an empty object.
.. code-block:: json
{
@ -1029,7 +1151,26 @@ The following extras are available for arbitrary SQL query responses and stored,
}
``extras``
Available ?_extra= blocks
List of ?_extra= blocks that can be used on this page
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
.. code-block:: json
[
{
"name": "count",
"description": "Total count of rows matching these filters",
"toggle_url": "http://localhost/fixtures/facetable.json?_extra=extras&_extra=count",
"selected": false
},
{
"name": "extras",
"description": "List of ?_extra= blocks that can be used on this page",
"toggle_url": "http://localhost/fixtures/facetable.json",
"selected": true
}
]
``database``
Database name
@ -1041,7 +1182,7 @@ The following extras are available for arbitrary SQL query responses and stored,
"fixtures"
``database_color``
Color assigned to the database
Color assigned to the database (A six character hex color, without the leading ``#``, derived from a hash of the database name and used in the Datasette interface.)
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=database_color``
@ -1050,7 +1191,7 @@ The following extras are available for arbitrary SQL query responses and stored,
"9403e5"
``private``
Whether this resource is private to the current actor
Whether this resource is private to the current actor (``true`` if the current actor can see this resource but an anonymous user could not. See :ref:`authentication_permissions`.)
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=private``
@ -1060,6 +1201,48 @@ The following extras are available for arbitrary SQL query responses and stored,
.. [[[end]]]
.. _TableAutocompleteView:
Table autocomplete
------------------
The ``/<database>/<table>/-/autocomplete`` endpoint returns up to 10 primary key
matches for a table, intended for building autocomplete interfaces such as
foreign key pickers.
::
GET /<database>/<table>/-/autocomplete?q=search
The ``q`` parameter is required. If it is omitted or blank, the endpoint returns
an empty ``"rows"`` list.
The response includes a ``"pks"`` object containing the primary key value or
values for each row. If Datasette can detect a label column, or one has been
configured using ``label_column``, each row will also include ``"label"``:
.. code-block:: json
{
"rows": [
{
"pks": {
"id": 1
},
"label": "Example row"
}
]
}
The endpoint searches the primary key column or columns and the label column
using escaped SQL ``LIKE`` queries. A single-column primary key exact match is
returned first. Other matches are ordered by the shortest matching label value
where a label column is available.
The initial search runs with a 500ms time limit. If that query times out,
Datasette falls back to a prefix match against the first primary key column so
SQLite can use the primary key index.
.. _table_arguments:
Table arguments
@ -1785,7 +1968,14 @@ To create a table, make a ``POST`` to ``/<database>/-/create``. This requires th
},
{
"name": "title",
"type": "text"
"type": "text",
"not_null": true,
"default": "Untitled"
},
{
"name": "created",
"type": "text",
"default_expr": "current_timestamp"
}
],
"pk": "id"
@ -1798,6 +1988,10 @@ The JSON here describes the table that will be created:
- ``name`` is the name of the column. This is required.
- ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``.
- ``not_null`` can be set to ``true`` to create this column with a ``NOT NULL`` constraint.
- ``default`` can be used to set a literal default value for this column.
- ``default_expr`` can be used instead of ``default`` to set a SQLite default expression. See :ref:`default_expr values <json_api_default_expr_values>`.
- ``fk_table`` can be used to create a single-column foreign key constraint referencing another table. ``fk_column`` is optional and can be used to specify the referenced column - if omitted, Datasette will use the single primary key of ``fk_table``.
* ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column.
@ -1810,6 +2004,56 @@ The JSON here describes the table that will be created:
* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`actions_update_row` permission.
* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.
.. _json_api_default_expr_values:
``default_expr`` accepts these values:
.. list-table::
:header-rows: 1
* - Value
- Recommended column type
- Example inserted value
* - ``current_timestamp``
- ``text``
- ``2026-05-01 13:34:00``
* - ``current_date``
- ``text``
- ``2026-05-01``
* - ``current_time``
- ``text``
- ``13:34:00``
* - ``current_unixtime``
- ``integer``
- ``1777642440``
* - ``current_unixtime_ms``
- ``integer``
- ``1777642440000``
This example creates a foreign key from ``projects.owner_id`` to the single primary key of ``owners``:
.. code-block:: json
{
"table": "projects",
"columns": [
{
"name": "id",
"type": "integer"
},
{
"name": "owner_id",
"type": "integer",
"fk_table": "owners"
},
{
"name": "title",
"type": "text"
}
],
"pk": "id"
}
If the table is successfully created this will return a ``201`` status code and the following response:
.. code-block:: json
@ -1820,7 +2064,7 @@ If the table is successfully created this will return a ``201`` status code and
"table": "name_of_new_table",
"table_url": "http://127.0.0.1:8001/data/name_of_new_table",
"table_api_url": "http://127.0.0.1:8001/data/name_of_new_table.json",
"schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT\n)"
"schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT NOT NULL DEFAULT 'Untitled',\n [created] TEXT DEFAULT CURRENT_TIMESTAMP\n)"
}
.. _TableCreateView_example:
@ -1889,6 +2133,235 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat
Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission.
.. _DatabaseForeignKeyTargetsView:
Database foreign key targets
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``/<database>/-/foreign-key-targets`` endpoint returns the list of tables in a database that can be referenced by a single-column foreign key. This requires the :ref:`actions_create_table` permission.
::
GET /<database>/-/foreign-key-targets
The response includes only tables with exactly one primary key column. Hidden tables, tables with compound primary keys and tables with no explicit primary key are omitted.
Each target includes the normalized SQLite type affinity for the primary key column in ``type``. The type is calculated using SQLite's documented affinity rules: ``INT`` maps to ``integer``; ``CHAR``, ``CLOB`` or ``TEXT`` maps to ``text``; ``BLOB`` or no type maps to ``blob``; ``REAL`` and floating-point declared types map to ``real``; everything else maps to ``numeric``.
.. code-block:: json
{
"ok": true,
"database": "data",
"targets": [
{
"fk_table": "owners",
"fk_column": "id",
"type": "integer"
},
{
"fk_table": "categories",
"fk_column": "slug",
"type": "text"
}
]
}
.. _TableForeignKeySuggestionsView:
Table foreign key suggestions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``/<database>/<table>/-/foreign-key-suggestions`` endpoint suggests possible single-column foreign key relationships for a table. This requires the :ref:`actions_alter_table` permission.
::
GET /<database>/<table>/-/foreign-key-suggestions
The response includes every type-compatible single-column primary key target for each column in ``options``. Datasette also performs a bounded data check against up to 500 rows in the table: if the sampled non-null values for a column all exist in a target primary key, that target is included in ``suggestions``.
If the bounded check takes too long, the endpoint fails open. It still returns the type-compatible ``options`` for each column, but ``row_check.status`` will be ``"timed_out"`` and there may be no ``suggestions``.
.. code-block:: json
{
"ok": true,
"database": "data",
"table": "projects",
"row_check": {
"attempted": true,
"status": "completed",
"row_limit": 500,
"sampled_rows": 3,
"checked_options": 4
},
"columns": [
{
"column": "owner_id",
"type": "INTEGER",
"affinity": "integer",
"current": null,
"suggestions": [
{
"fk_table": "owners",
"fk_column": "id",
"confidence": "sampled",
"sampled_values": 3,
"reasons": [
"type_match",
"sample_values_exist",
"name_match"
]
}
],
"options": [
{
"fk_table": "owners",
"fk_column": "id",
"type": "INTEGER"
}
]
}
]
}
.. _TableAlterView:
Altering tables
~~~~~~~~~~~~~~~
To alter an existing table, make a ``POST`` to ``/<database>/<table>/-/alter``. This requires the :ref:`actions_alter_table` permission.
::
POST /<database>/<table>/-/alter
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>
The request body should include an ``operations`` array. Each operation has the same top-level shape: an ``op`` string and an ``args`` object.
.. code-block:: json
{
"operations": [
{
"op": "add_column",
"args": {
"name": "slug",
"type": "text",
"not_null": true,
"default": ""
}
},
{
"op": "add_column",
"args": {
"name": "created",
"type": "text",
"default_expr": "current_timestamp"
}
},
{
"op": "rename_column",
"args": {
"name": "title",
"to": "headline"
}
},
{
"op": "rename_table",
"args": {
"to": "published_posts"
}
},
{
"op": "alter_column",
"args": {
"name": "score",
"type": "float"
}
},
{
"op": "drop_column",
"args": {
"name": "draft_notes"
}
},
{
"op": "set_primary_key",
"args": {
"columns": ["id"]
}
},
{
"op": "add_foreign_key",
"args": {
"column": "owner_id",
"fk_table": "owners"
}
},
{
"op": "drop_foreign_key",
"args": {
"column": "old_owner_id"
}
},
{
"op": "set_foreign_keys",
"args": {
"foreign_keys": [
{
"column": "owner_id",
"fk_table": "owners",
"fk_column": "id"
}
]
}
},
{
"op": "reorder_columns",
"args": {
"columns": ["id", "headline", "slug", "created", "score"]
}
}
]
}
Supported operations:
* ``add_column`` adds a new column. ``args`` accepts ``name``, optional ``type`` of ``text``, ``integer``, ``float`` or ``blob``, optional ``not_null``, optional literal ``default`` and optional ``default_expr``. If ``not_null`` is ``true`` either a non-null ``default`` or ``default_expr`` is required.
* ``rename_column`` renames a column. ``args`` accepts ``name`` and ``to``.
* ``rename_table`` renames the table. ``args`` accepts ``to``, the new table name. If combined with other operations, Datasette applies the column, primary key, foreign key and column order changes before renaming the table.
* ``alter_column`` changes column properties. ``args`` accepts ``name`` and at least one of ``type``, ``not_null``, literal ``default`` or ``default_expr``. Passing ``"default": null`` removes an existing default.
* ``drop_column`` drops a column. ``args`` accepts ``name``.
* ``set_primary_key`` changes the table primary key. ``args`` accepts ``columns``, a list of one or more column names.
* ``add_foreign_key`` adds a single-column foreign key constraint. ``args`` accepts ``column``, ``fk_table`` and optional ``fk_column``. If ``fk_column`` is omitted, Datasette will use the single primary key of ``fk_table``.
* ``drop_foreign_key`` removes the foreign key constraint for a column. ``args`` accepts ``column``.
* ``set_foreign_keys`` replaces all foreign key constraints on the table. ``args`` accepts ``foreign_keys``, a list of objects that each have ``column``, ``fk_table`` and optional ``fk_column``. An empty list removes all foreign key constraints.
* ``reorder_columns`` reorders columns. ``args`` accepts ``columns``, a list of one or more column names. Columns omitted from this list will appear afterwards in their existing order.
``default`` is always treated as a literal value. ``default_expr`` accepts the values shown in :ref:`default_expr values <json_api_default_expr_values>` and is rendered as the corresponding SQLite default expression.
For foreign key operations that omit ``fk_column``, the referenced ``fk_table`` must have a single-column primary key. Datasette will return an error if it cannot identify a single primary key column for that table.
A successful response returns the new schema and the previous schema. If the request used ``rename_table``, ``table``, ``table_url`` and ``table_api_url`` will use the new table name. Renaming a table through this endpoint triggers the :class:`~datasette.events.RenameTableEvent` event.
.. code-block:: json
{
"ok": true,
"database": "data",
"table": "published_posts",
"table_url": "http://127.0.0.1:8001/data/published_posts",
"table_api_url": "http://127.0.0.1:8001/data/published_posts.json",
"altered": true,
"schema": "CREATE TABLE ...",
"before_schema": "CREATE TABLE ...",
"operations_applied": 11
}
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableSetColumnTypeView:
Setting a column type

View file

@ -93,9 +93,26 @@ async def _fetch_live_examples(scoped_classes):
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("""
@ -108,7 +125,7 @@ async def _fetch_live_examples(scoped_classes):
"""),
"title": "Search neighborhoods",
}
}
},
}
}
},

View file

@ -118,6 +118,16 @@ 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.
* `../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:
Row

View file

@ -1092,7 +1092,7 @@ Column types are assigned to columns via the :ref:`column_types <table_configura
config:
format: rgb
Datasette includes three built-in column types: ``url``, ``email``, and ``json``.
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.
.. _plugin_asgi_wrapper:
@ -1909,7 +1909,80 @@ 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.
Each of these hooks should return return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail.
Each of these hooks should return a list of 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.

View file

@ -280,6 +280,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"query_actions"
]
},
{
"name": "datasette.default_table_actions",
"static": false,
"templates": false,
"version": null,
"hooks": [
"table_actions"
]
},
{
"name": "datasette.events",
"static": false,

View file

@ -50,6 +50,12 @@ These variables are available on every page rendered by Datasette, including pag
``app_css_hash``
Hash of Datasette's app.css contents, used for cache busting
``edit_tools_js_hash``
Hash of Datasette's edit-tools.js contents, used for cache busting
``table_js_hash``
Hash of Datasette's table.js contents, used for cache busting
``zip``
Python's zip() builtin, made available to template logic
@ -106,6 +112,9 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re
``database_color`` - ``str``
The color assigned to the database
``database_page_data`` - ``dict``
JSON data used by JavaScript on the database page
``editable`` - ``bool``
Boolean indicating if the database is editable
@ -365,7 +374,7 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
List of template names that were considered for this page, the one used marked with an asterisk
``set_column_type_ui`` - ``dict``
Column type UI metadata for this table
Information needed to build an interface for assigning column types
``settings`` - ``dict``
Dictionary of Datasette's current settings
@ -391,6 +400,9 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
``table_definition`` - ``str``
SQL definition for this table
``table_page_data`` - ``dict``
JSON data used by JavaScript on the table page
``top_table`` - ``callable``
Async function rendering the top_table plugin slot
@ -461,6 +473,9 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
``row_actions`` - ``list``
Row actions made available by plugin hooks
``row_mutation_ui`` - ``bool``
True if the row edit/delete JavaScript UI should be enabled
``rows`` - ``list``
The rows for this page, as a list of dictionaries mapping column name to value
@ -473,6 +488,9 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
``table`` - ``str``
Table name
``table_page_data`` - ``dict``
JSON data used by JavaScript on the row page
``top_row`` - ``callable``
Async function rendering the top_row plugin slot

View file

@ -35,10 +35,11 @@ dependencies = [
"PyYAML>=5.3",
"mergedeep>=1.1.1",
"itsdangerous>=1.1",
"sqlite-utils>=3.30",
"sqlite-utils>=3.30,<4.0",
"asyncinject>=0.7",
"setuptools",
"pip",
"pydantic>=2",
]
[project.urls]
@ -81,6 +82,9 @@ dev = [
"ruamel.yaml",
"psutil>=5.9",
]
playwright = [
"pytest-playwright>=0.8.0",
]
[project.optional-dependencies]
rich = ["rich"]

View file

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

View file

@ -1,40 +1,37 @@
#!/bin/bash
set -e
# So the script fails if there are any errors
set -euo pipefail
read -r -a PYTHON_CMD <<< "${PYTHON:-python3}"
read -r -a SHOT_SCRAPER_CMD <<< "${SHOT_SCRAPER:-shot-scraper}"
# Build the wheel
python3 -m build
"${PYTHON_CMD[@]}" -m build
# Find name of wheel, strip off the dist/
wheel=$(basename $(ls dist/*.whl) | head -n 1)
# Find name of most recently built wheel, strip off the dist/
wheel=$(basename "$(ls -t dist/*.whl | head -n 1)")
# Create a blank index page
echo '
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
<script src="https://cdn.jsdelivr.net/pyodide/v314.0.0/full/pyodide.js"></script>
' > dist/index.html
# Run a server for that dist/ folder
cd dist
python3 -m http.server 8529 &
cd ..
"${PYTHON_CMD[@]}" -m http.server 8529 --directory dist &
server_pid=$!
# Register the kill_server function to be called on script exit
kill_server() {
pkill -f 'http.server 8529'
kill "$server_pid" 2>/dev/null || true
}
trap kill_server EXIT
shot-scraper javascript http://localhost:8529/ "
"${SHOT_SCRAPER_CMD[@]}" javascript http://localhost:8529/ "
async () => {
let pyodide = await loadPyodide();
await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']);
await pyodide.loadPackage(['micropip', 'setuptools']);
let output = await pyodide.runPythonAsync(\`
import micropip
await micropip.install('h11==0.12.0')
await micropip.install('httpx==0.23')
# To avoid 'from typing_extensions import deprecated' error:
await micropip.install('typing-extensions>=4.12.2')
await micropip.install('http://localhost:8529/$wheel')
import ssl
import setuptools

View file

@ -1,4 +1,5 @@
import httpx
import importlib.metadata
import os
import pathlib
import pytest
@ -93,7 +94,30 @@ def pytest_report_header(config):
conn = sqlite3.connect(":memory:")
version = conn.execute("select sqlite_version()").fetchone()[0]
conn.close()
return "SQLite: {}".format(version)
sqlite_utils_version = importlib.metadata.version("sqlite-utils")
headers = [
"SQLite: {}".format(version),
"sqlite-utils: {}".format(sqlite_utils_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):
@ -108,7 +132,13 @@ def pytest_unconfigure(config):
del sys._called_from_test
def pytest_collection_modifyitems(items):
def pytest_collection_modifyitems(config, 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
move_to_front(items, "test_cli")
move_to_front(items, "test_black")
@ -146,6 +176,7 @@ def restore_working_directory(tmpdir, request):
@pytest.fixture(scope="session", autouse=True)
def check_actions_are_documented():
from datasette.plugins import pm
from datasette.default_actions import register_actions as default_register_actions
content = (
pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst"
@ -154,6 +185,9 @@ def check_actions_are_documented():
documented_actions = set(permissions_re.findall(content)).union(
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):
if hook_name == "permission_resources_sql":
@ -165,9 +199,10 @@ def check_actions_are_documented():
+ " (or maybe a test forgot to do await ds.invoke_startup())"
)
action = kwargs.get("action").replace("-", "_")
assert (
action in documented_actions
), "Undocumented permission action: {}".format(action)
if kwargs["action"] in core_actions:
assert (
action in documented_actions
), "Undocumented permission action: {}".format(action)
pm.add_hookcall_monitoring(
before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None
@ -225,8 +260,12 @@ def ds_unix_domain_socket_server(tmp_path_factory):
# This used to use tmp_path_factory.mktemp("uds") but that turned out to
# produce paths that were too long to use as UDS on macOS, see
# https://github.com/simonw/datasette/issues/1407 - so I switched to
# using tempfile.gettempdir()
uds = str(pathlib.Path(tempfile.gettempdir()) / "datasette.sock")
# using tempfile.gettempdir() with a per-process filename.
uds = str(pathlib.Path(tempfile.gettempdir()) / f"datasette-{os.getpid()}.sock")
try:
os.unlink(uds)
except FileNotFoundError:
pass
ds_proc = subprocess.Popen(
[sys.executable, "-m", "datasette", "--memory", "--uds", uds],
stdout=subprocess.PIPE,
@ -236,12 +275,26 @@ def ds_unix_domain_socket_server(tmp_path_factory):
# Poll until available
transport = httpx.HTTPTransport(uds=uds)
client = httpx.Client(transport=transport)
wait_until_responds("http://localhost/_memory.json", client=client)
# Check it started successfully
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
yield ds_proc, uds
# Shut it down at the end of the pytest session
ds_proc.terminate()
try:
wait_until_responds(
"http://localhost/_memory.json", timeout=30.0, client=client
)
# Check it started successfully
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
yield ds_proc, uds
finally:
client.close()
# Shut it down at the end of the pytest session
ds_proc.terminate()
try:
ds_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
ds_proc.kill()
ds_proc.wait()
try:
os.unlink(uds)
except FileNotFoundError:
pass
# Import fixtures from fixtures.py to make them available

View file

@ -357,15 +357,30 @@ def menu_links(datasette, actor, request):
@hookimpl
def table_actions(datasette, database, table, actor):
def table_actions(datasette, database, table, actor, request):
if actor:
return [
actions = [
{
"href": datasette.urls.instance(),
"label": f"Database: {database}",
},
{"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

707
tests/test_allowed_many.py Normal file
View file

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

@ -1,5 +1,6 @@
from datasette.app import Datasette
from datasette.utils import sqlite3
from datasette.events import RenameTableEvent
from datasette.utils import escape_sqlite, sqlite3
from .utils import last_event
import pytest
import time
@ -39,6 +40,16 @@ def _headers(token):
}
def _insert_and_fetch_created(conn, table, insert_sql):
cursor = conn.execute(insert_sql)
return conn.execute(
"select created, typeof(created) from {} where rowid = ?".format(
escape_sqlite(table)
),
(cursor.lastrowid,),
).fetchone()
@pytest.mark.asyncio
async def test_api_explorer_upsert_example_json(ds_write):
response = await ds_write.client.get("/-/api", actor={"id": "root"})
@ -794,6 +805,613 @@ async def test_update_row_alter(ds_write):
assert response.json() == {"ok": True}
@pytest.mark.asyncio
async def test_alter_table_operations(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
before_schema = await db.execute_fn(
lambda conn: conn.execute(
"select sql from sqlite_master where type = 'table' and name = 'docs'"
).fetchone()[0]
)
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_column",
"args": {
"name": "slug",
"type": "text",
"not_null": True,
"default": "",
},
},
{
"op": "add_column",
"args": {
"name": "created",
"type": "text",
"default_expr": "current_timestamp",
},
},
{
"op": "add_column",
"args": {
"name": "literal_default",
"type": "text",
"default": "hello)",
},
},
{"op": "rename_column", "args": {"name": "title", "to": "headline"}},
{
"op": "alter_column",
"args": {"name": "age", "type": "text", "default": "0"},
},
{"op": "drop_column", "args": {"name": "score"}},
{
"op": "reorder_columns",
"args": {
"columns": [
"id",
"headline",
"slug",
"created",
"literal_default",
"age",
]
},
},
{"op": "set_primary_key", "args": {"columns": ["id"]}},
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["ok"] is True
assert data["database"] == "data"
assert data["table"] == "docs"
assert data["altered"] is True
assert data["operations_applied"] == 8
assert data["before_schema"] == before_schema
assert "headline" in data["schema"]
assert "score" not in data["schema"]
assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"]
assert "DEFAULT 'hello)'" in data["schema"]
columns = (
await db.execute("select * from pragma_table_info('docs') order by cid")
).dicts()
assert [column["name"] for column in columns] == [
"id",
"headline",
"slug",
"created",
"literal_default",
"age",
]
assert columns[0]["pk"] == 1
assert columns[2]["notnull"] == 1
assert columns[2]["dflt_value"] == "''"
assert columns[3]["dflt_value"] == "CURRENT_TIMESTAMP"
assert columns[4]["dflt_value"] == "'hello)'"
assert columns[5]["type"] == "TEXT"
assert columns[5]["dflt_value"] == "'0'"
event = last_event(ds_write)
assert event.name == "alter-table"
assert event.database == "data"
assert event.table == "docs"
assert event.before_schema == before_schema
assert event.after_schema == data["schema"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"default_expr,minimum_value,expected_schema",
(
(
"current_unixtime",
1_600_000_000,
"strftime('%s', 'now')",
),
(
"current_unixtime_ms",
1_600_000_000_000,
"julianday('now')",
),
),
)
async def test_alter_table_integer_default_expr(
ds_write, default_expr, minimum_value, expected_schema
):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_column",
"args": {
"name": "created",
"type": "integer",
"default_expr": default_expr,
},
}
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert expected_schema in data["schema"]
columns = await db.execute("select * from pragma_table_info('docs')")
created_column = [
column for column in columns.dicts() if column["name"] == "created"
][0]
assert created_column["type"] == "INTEGER"
assert expected_schema in created_column["dflt_value"]
row = await db.execute_write_fn(
lambda conn: _insert_and_fetch_created(
conn, "docs", "insert into docs (title) values ('with default')"
)
)
assert row[0] > minimum_value
assert row[1] == "integer"
@pytest.mark.asyncio
async def test_alter_table_rename_table(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
before_schema = await db.execute_fn(
lambda conn: conn.execute(
"select sql from sqlite_master where type = 'table' and name = 'docs'"
).fetchone()[0]
)
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{"op": "rename_table", "args": {"to": "documents"}},
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["ok"] is True
assert data["database"] == "data"
assert data["table"] == "documents"
assert data["table_url"].endswith("/data/documents")
assert data["table_api_url"].endswith("/data/documents.json")
assert data["altered"] is True
assert data["operations_applied"] == 1
assert data["before_schema"] == before_schema
assert 'CREATE TABLE "documents"' in data["schema"]
tables = (
await db.execute(
"select name from sqlite_master where type = 'table' order by name"
)
).dicts()
table_names = [table["name"] for table in tables]
assert "docs" not in table_names
assert "documents" in table_names
rename_events = [
event
for event in ds_write._tracked_events
if isinstance(event, RenameTableEvent)
]
assert len(rename_events) == 1
assert rename_events[0].database == "data"
assert rename_events[0].old_table == "docs"
assert rename_events[0].new_table == "documents"
@pytest.mark.asyncio
async def test_alter_table_foreign_key_operations(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
await db.execute_write("create table categories (id integer primary key)")
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{"op": "add_column", "args": {"name": "owner_id", "type": "integer"}},
{
"op": "add_foreign_key",
"args": {"column": "owner_id", "fk_table": "owners"},
},
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["operations_applied"] == 2
assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [{"op": "drop_foreign_key", "args": {"column": "owner_id"}}]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "set_foreign_keys",
"args": {
"foreign_keys": [
{
"column": "owner_id",
"fk_table": "categories",
"fk_column": "id",
}
]
},
}
]
},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES [categories]([id])" in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
json={"operations": [{"op": "set_foreign_keys", "args": {"foreign_keys": []}}]},
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
@pytest.mark.asyncio
async def test_alter_table_foreign_key_requires_fk_table_for_fk_column(ds_write):
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_foreign_key",
"args": {"column": "age", "fk_column": "id"},
}
]
},
headers=_headers(write_token(ds_write, permissions=["at"])),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["operations.0.add_foreign_key.args: fk_column requires fk_table"],
}
@pytest.mark.asyncio
async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write(
"create table accounts (tenant_id integer, id integer, primary key (tenant_id, id))"
)
response = await ds_write.client.post(
"/data/docs/-/alter",
json={
"operations": [
{
"op": "add_foreign_key",
"args": {"column": "age", "fk_table": "accounts"},
}
]
},
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
@pytest.mark.asyncio
async def test_foreign_key_suggestions(ds_write):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
await db.execute_write("insert into owners (id) values (1), (2), (3)")
await db.execute_write("create table categories (slug text primary key)")
await db.execute_write("insert into categories (slug) values ('one'), ('two')")
await db.execute_write("create table numbers (id integer primary key)")
await db.execute_write("insert into numbers (id) values (10), (20)")
await db.execute_write("create table weights (id real primary key)")
await db.execute_write("insert into weights (id) values (1.5), (2.5)")
await db.execute_write(
"insert into docs (id, title, score, age) values "
"(1, 'one', 1.5, 1), (2, 'two', 999.5, 2), (3, null, null, null)"
)
response = await ds_write.client.get(
"/data/docs/-/foreign-key-suggestions",
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["ok"] is True
assert data["database"] == "data"
assert data["table"] == "docs"
assert data["row_check"]["attempted"] is True
assert data["row_check"]["status"] == "completed"
assert data["row_check"]["row_limit"] == 500
assert data["row_check"]["sampled_rows"] == 3
columns = {column["column"]: column for column in data["columns"]}
assert columns["age"]["options"] == [
{"fk_table": "numbers", "fk_column": "id", "type": "INTEGER"},
{"fk_table": "owners", "fk_column": "id", "type": "INTEGER"},
]
assert columns["age"]["suggestions"] == [
{
"fk_table": "owners",
"fk_column": "id",
"confidence": "sampled",
"sampled_values": 2,
"reasons": ["type_match", "sample_values_exist"],
}
]
assert columns["title"]["options"] == [
{"fk_table": "categories", "fk_column": "slug", "type": "TEXT"}
]
assert columns["title"]["suggestions"][0]["fk_table"] == "categories"
assert columns["score"]["options"] == [
{"fk_table": "weights", "fk_column": "id", "type": "REAL"}
]
assert columns["score"]["suggestions"] == []
@pytest.mark.asyncio
async def test_foreign_key_suggestions_permission_denied(ds_write):
token = write_token(ds_write, permissions=["ir"])
response = await ds_write.client.get(
"/data/docs/-/foreign-key-suggestions",
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
@pytest.mark.asyncio
async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch):
token = write_token(ds_write, permissions=["at"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
async def raise_timeout(*args, **kwargs):
raise table_create_alter.ForeignKeySuggestionTimedOut
from datasette.views import table_create_alter
monkeypatch.setattr(
table_create_alter,
"_foreign_key_suggestion_samples",
raise_timeout,
)
response = await ds_write.client.get(
"/data/docs/-/foreign-key-suggestions",
headers=_headers(token),
)
assert response.status_code == 200, response.text
data = response.json()
assert data["row_check"]["status"] == "timed_out"
columns = {column["column"]: column for column in data["columns"]}
assert columns["age"]["options"] == [
{"fk_table": "owners", "fk_column": "id", "type": "INTEGER"}
]
assert columns["age"]["suggestions"] == []
@pytest.mark.asyncio
async def test_foreign_key_targets(ds_write):
token = write_token(ds_write, permissions=["ct"])
db = ds_write.get_database("data")
await db.execute_write("create table owners (id integer primary key)")
await db.execute_write("create table categories (slug varchar(30) primary key)")
await db.execute_write("create table blob_things (hash blob primary key)")
await db.execute_write(
"create table numeric_codes (code decimal(10,5) primary key)"
)
await db.execute_write(
'create table floating_point (value "FLOATING POINT" primary key)'
)
await db.execute_write(
"create table compound (a integer, b integer, primary key (a, b))"
)
await db.execute_write("create table no_pk (name text)")
try:
await db.execute_write("create virtual table search_docs using fts5(body)")
except Exception:
pass
response = await ds_write.client.get(
"/data/-/foreign-key-targets",
headers=_headers(token),
)
assert response.status_code == 200, response.text
assert response.json() == {
"ok": True,
"database": "data",
"targets": [
{
"fk_table": "blob_things",
"fk_column": "hash",
"type": "blob",
},
{
"fk_table": "categories",
"fk_column": "slug",
"type": "text",
},
{
"fk_table": "docs",
"fk_column": "id",
"type": "integer",
},
{
"fk_table": "floating_point",
"fk_column": "value",
"type": "integer",
},
{
"fk_table": "numeric_codes",
"fk_column": "code",
"type": "numeric",
},
{
"fk_table": "owners",
"fk_column": "id",
"type": "integer",
},
],
}
assert not any(
target["fk_table"].startswith("search_docs_")
for target in response.json()["targets"]
)
@pytest.mark.asyncio
async def test_foreign_key_targets_permission_denied(ds_write):
token = write_token(ds_write, permissions=["ir"])
response = await ds_write.client.get(
"/data/-/foreign-key-targets",
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need create-table"],
}
@pytest.mark.asyncio
async def test_foreign_key_targets_allowed_for_alter_table(ds_write):
token = write_token(ds_write, permissions=["at"])
response = await ds_write.client.get(
"/data/-/foreign-key-targets?table=docs",
headers=_headers(token),
)
assert response.status_code == 200, response.text
assert response.json()["ok"] is True
@pytest.mark.asyncio
async def test_alter_table_permission_denied(ds_write):
token = write_token(ds_write, permissions=["ir"])
response = await ds_write.client.post(
"/data/docs/-/alter",
json={"operations": [{"op": "add_column", "args": {"name": "slug"}}]},
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body,expected_error",
(
(
{
"dry_run": True,
"operations": [
{"op": "add_column", "args": {"name": "slug", "type": "text"}}
],
},
"dry_run: Extra inputs are not permitted",
),
(
{"operations": [{"op": "add_column", "args": {"type": "text"}}]},
"operations.0.add_column.args.name: Field required",
),
(
{
"operations": [
{"op": "add_column", "args": {"name": "x", "type": "bad"}}
]
},
"operations.0.add_column.args.type: Input should be 'text', 'integer', 'float' or 'blob'",
),
(
{
"operations": [
{
"op": "add_column",
"args": {
"name": "x",
"default_expr": "datetime('now')",
},
}
]
},
"operations.0.add_column.args.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'",
),
(
{
"operations": [
{
"op": "add_column",
"args": {
"name": "x",
"default": "x",
"default_expr": "current_timestamp",
},
}
]
},
"operations.0.add_column.args: Value error, default and default_expr cannot both be provided",
),
),
)
async def test_alter_table_validation_errors(ds_write, body, expected_error):
response = await ds_write.client.post(
"/data/docs/-/alter",
json=body,
headers=_headers(write_token(ds_write, permissions=["at"])),
)
assert response.status_code == 400
assert response.json()["ok"] is False
assert response.json()["errors"] == [expected_error]
@pytest.mark.asyncio
async def test_execute_write_form_parameter_called_sql():
ds = Datasette(memory=True, default_deny=True)
@ -1409,6 +2027,247 @@ async def test_create_table(
assert [e.name for e in events] == expected_events
@pytest.mark.asyncio
async def test_create_table_with_foreign_key(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "owners",
"columns": [
{"name": "id", "type": "integer"},
{"name": "name", "type": "text"},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "projects",
"columns": [
{"name": "id", "type": "integer"},
{
"name": "owner_id",
"type": "integer",
"fk_table": "owners",
},
{"name": "title", "type": "text"},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
data = response.json()
assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
@pytest.mark.asyncio
async def test_create_table_with_column_constraints(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "constrained",
"columns": [
{"name": "id", "type": "integer"},
{
"name": "title",
"type": "text",
"not_null": True,
"default": "Untitled",
},
{
"name": "created",
"type": "text",
"default_expr": "current_timestamp",
},
{"name": "score", "type": "integer", "default": 0},
{"name": "literal_default", "type": "text", "default": "hello)"},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201, response.text
data = response.json()
assert data["ok"] is True
assert "NOT NULL DEFAULT 'Untitled'" in data["schema"]
assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"]
assert "DEFAULT 0" in data["schema"]
assert "DEFAULT 'hello)'" in data["schema"]
db = ds_write.get_database("data")
columns = (
await db.execute("select * from pragma_table_info('constrained') order by cid")
).dicts()
assert [column["name"] for column in columns] == [
"id",
"title",
"created",
"score",
"literal_default",
]
assert columns[0]["pk"] == 1
assert columns[1]["notnull"] == 1
assert columns[1]["dflt_value"] == "'Untitled'"
assert columns[2]["dflt_value"] == "CURRENT_TIMESTAMP"
assert columns[3]["dflt_value"] == "0"
assert columns[4]["dflt_value"] == "'hello)'"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"default_expr,minimum_value,expected_schema",
(
(
"current_unixtime",
1_600_000_000,
"strftime('%s', 'now')",
),
(
"current_unixtime_ms",
1_600_000_000_000,
"julianday('now')",
),
),
)
async def test_create_table_integer_default_expr(
ds_write, default_expr, minimum_value, expected_schema
):
token = write_token(ds_write)
table = "default_{}".format(default_expr)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": table,
"columns": [
{"name": "id", "type": "integer"},
{
"name": "created",
"type": "integer",
"default_expr": default_expr,
},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201, response.text
data = response.json()
assert expected_schema in data["schema"]
db = ds_write.get_database("data")
columns = (await db.execute("select * from pragma_table_info(?)", [table])).dicts()
assert columns[1]["type"] == "INTEGER"
assert expected_schema in columns[1]["dflt_value"]
row = await db.execute_write_fn(
lambda conn: _insert_and_fetch_created(
conn, table, "insert into {} default values".format(escape_sqlite(table))
)
)
assert row[0] > minimum_value
assert row[1] == "integer"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"column,expected_error",
(
(
{"name": "owner_id", "type": "integer", "fk_table": "owners"},
None,
),
(
{"name": "owner_id", "type": "integer", "fk_column": "id"},
"columns.0: fk_column requires fk_table",
),
(
{
"name": "created",
"type": "text",
"default_expr": "datetime('now')",
},
"columns.0.default_expr: Input should be 'current_timestamp', 'current_date', 'current_time', 'current_unixtime' or 'current_unixtime_ms'",
),
(
{
"name": "created",
"type": "text",
"default": "x",
"default_expr": "current_timestamp",
},
"columns.0: Value error, default and default_expr cannot both be provided",
),
),
)
async def test_create_table_column_validation(ds_write, column, expected_error):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "projects",
"columns": [column],
},
headers=_headers(token),
)
if expected_error:
assert response.status_code == 400
assert response.json() == {"ok": False, "errors": [expected_error]}
else:
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'owners'"],
}
@pytest.mark.asyncio
async def test_create_table_foreign_key_without_fk_column_requires_single_pk(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "accounts",
"columns": [
{"name": "tenant_id", "type": "integer"},
{"name": "id", "type": "integer"},
{"name": "name", "type": "text"},
],
"pks": ["tenant_id", "id"],
},
headers=_headers(token),
)
assert response.status_code == 201
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "projects",
"columns": [
{"name": "id", "type": "integer"},
{
"name": "account_id",
"type": "integer",
"fk_table": "accounts",
},
],
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"permissions,body,expected_status,expected_errors",

253
tests/test_autocomplete.py Normal file
View file

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

@ -494,6 +494,7 @@ async def test_builtin_column_types_registered(ds_ct):
assert "url" in ds_ct._column_types
assert "email" in ds_ct._column_types
assert "json" in ds_ct._column_types
assert "textarea" in ds_ct._column_types
assert "nonexistent" not in ds_ct._column_types
@ -510,16 +511,25 @@ async def test_column_type_class_attributes(ds_ct):
assert email_cls.sqlite_types == (SQLiteType.TEXT,)
json_cls = ds_ct._column_types["json"]
assert json_cls.sqlite_types == (SQLiteType.TEXT,)
textarea_cls = ds_ct._column_types["textarea"]
assert textarea_cls.name == "textarea"
assert textarea_cls.description == "Multiline text"
assert textarea_cls.sqlite_types == (SQLiteType.TEXT,)
def test_sqlite_type_from_declared_type():
assert SQLiteType.from_declared_type(None) == SQLiteType.BLOB
assert SQLiteType.from_declared_type("text") == SQLiteType.TEXT
assert SQLiteType.from_declared_type("varchar(255)") == SQLiteType.TEXT
assert SQLiteType.from_declared_type("integer") == SQLiteType.INTEGER
assert SQLiteType.from_declared_type("float") == SQLiteType.REAL
assert SQLiteType.from_declared_type("blob") == SQLiteType.BLOB
assert SQLiteType.from_declared_type("") == SQLiteType.NULL
assert SQLiteType.from_declared_type("numeric") is None
assert SQLiteType.from_declared_type("") == SQLiteType.BLOB
assert SQLiteType.from_declared_type("numeric") == SQLiteType.NUMERIC
assert SQLiteType.from_declared_type("decimal(10,5)") == SQLiteType.NUMERIC
assert SQLiteType.from_declared_type("boolean") == SQLiteType.NUMERIC
assert SQLiteType.from_declared_type("date") == SQLiteType.NUMERIC
assert SQLiteType.from_declared_type("null") == SQLiteType.NUMERIC
# --- JSON API ---
@ -941,6 +951,7 @@ async def test_set_column_type_ui_data_includes_applicable_types(
"options": [
{"name": "email", "description": "Email address"},
{"name": "json", "description": "JSON data"},
{"name": "textarea", "description": "Multiline text"},
{"name": "url", "description": "URL"},
],
}
@ -949,6 +960,7 @@ async def test_set_column_type_ui_data_includes_applicable_types(
"options": [
{"name": "email", "description": "Email address"},
{"name": "json", "description": "JSON data"},
{"name": "textarea", "description": "Multiline text"},
{"name": "url", "description": "URL"},
],
}

View file

@ -40,22 +40,23 @@ curl -f --cacert client.pem $test_url
curl_exit_code=$?
# Shut down the server
kill $server_pid
waiting=0
# show all pids
# | find just the $server_pid
# | | dont match on the previous grep
# | | | we dont need the output
# | | | |
until ( ! ps ax | grep $server_pid | grep -v grep > /dev/null ); do
if [ $waiting -eq 4 ]; then
echo "$server_pid does still exist, server failed to stop"
cleanup
exit 1
kill $server_pid 2>/dev/null || true
(
sleep 5
if kill -0 $server_pid 2>/dev/null; then
kill -9 $server_pid 2>/dev/null || true
fi
let waiting=waiting+1
sleep 1
done
) &
killer_pid=$!
wait_status=0
wait $server_pid 2>/dev/null || wait_status=$?
kill $killer_pid 2>/dev/null || true
wait $killer_pid 2>/dev/null || true
if [ $wait_status -eq 137 ]; then
echo "$server_pid did not stop after SIGTERM, server failed to stop"
cleanup
exit 1
fi
# Clean up the certificates
cleanup

View file

@ -0,0 +1,91 @@
import pytest
from bs4 import BeautifulSoup as Soup
from datasette.app import Datasette
from datasette.database import Database
@pytest.mark.asyncio
async def test_debug_autocomplete_for_table():
ds = Datasette(memory=True)
db = ds.add_database(
Database(ds, memory_name="test_debug_autocomplete_for_table"), name="data"
)
await db.execute_write_script("""
create table authors (
id integer primary key,
name text
);
insert into authors (id, name) values
(1, 'Ada Lovelace'),
(2, 'Grace Hopper');
""")
response = await ds.client.get("/-/debug/autocomplete?database=data&table=authors")
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert soup.select_one("h1").text == "Debug autocomplete"
assert any(
"autocomplete.js" in (script.get("src") or "")
for script in soup.find_all("script")
)
autocomplete = soup.select_one("datasette-autocomplete")
assert autocomplete is not None
assert autocomplete["src"] == "/data/authors/-/autocomplete"
assert soup.select_one("input#debug-autocomplete-input") is not None
assert "Label column:" in response.text
assert "<code>name</code>" in response.text
@pytest.mark.asyncio
async def test_debug_autocomplete_suggests_label_column_tables():
ds = Datasette(memory=True)
db = ds.add_database(
Database(ds, memory_name="test_debug_autocomplete_suggests"), name="data"
)
await db.execute_write_script("""
create table authors (
id integer primary key,
name text
);
create table releases (
id integer primary key,
title text
);
""")
response = await ds.client.get("/-/debug/autocomplete")
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
links = {a.text: a["href"] for a in soup.select("table.rows-and-columns a")}
assert links == {
"authors": "/-/debug/autocomplete?database=data&table=authors",
"releases": "/-/debug/autocomplete?database=data&table=releases",
}
assert [code.text for code in soup.select("table.rows-and-columns code")] == [
"name",
"title",
]
@pytest.mark.asyncio
async def test_debug_autocomplete_scan_limit():
ds = Datasette(memory=True)
db = ds.add_database(
Database(ds, memory_name="test_debug_autocomplete_scan_limit"), name="data"
)
await db.execute_write_script(
"\n".join(
f"create table t{i:03d} (id integer primary key);" for i in range(100)
)
+ "\ncreate table z_has_label (id integer primary key, name text);"
)
response = await ds.client.get("/-/debug/autocomplete")
assert response.status_code == 200
assert "No tables with detected label columns found." in response.text
assert "Scanned 100 tables; stopped at the 100 table scan limit." in response.text
assert "z_has_label" not in response.text

View file

@ -55,6 +55,57 @@ async def test_request_post_body():
assert data == json.loads(body)
@pytest.mark.asyncio
async def test_request_json():
scope = {
"http_version": "1.1",
"method": "POST",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"scheme": "http",
"type": "http",
"headers": [[b"content-type", b"application/json"]],
}
data = {"hello": "world", "items": [1, 2, 3]}
async def receive():
return {
"type": "http.request",
"body": json.dumps(data).encode("utf-8"),
"more_body": False,
}
request = Request(scope, receive)
assert data == await request.json()
@pytest.mark.asyncio
async def test_request_json_invalid():
scope = {
"http_version": "1.1",
"method": "POST",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"scheme": "http",
"type": "http",
"headers": [[b"content-type", b"application/json"]],
}
async def receive():
return {
"type": "http.request",
"body": b"this is not JSON",
"more_body": False,
}
request = Request(scope, receive)
with pytest.raises(json.JSONDecodeError):
await request.json()
def test_request_args():
request = Request.fake("/foo?multi=1&multi=2&single=3")
assert "1" == request.args.get("multi")

View file

@ -9,7 +9,7 @@ def ds():
@pytest.mark.parametrize(
"base_url,path,expected",
"ds_base_url,path,expected",
[
("/", "/", "/"),
("/", "/foo", "/foo"),
@ -20,8 +20,8 @@ def ds():
("/data/", "/data/foo", "/data/data/foo"),
],
)
def test_path(ds, base_url, path, expected):
ds._settings["base_url"] = base_url
def test_path(ds, ds_base_url, path, expected):
ds._settings["base_url"] = ds_base_url
actual = ds.urls.path(path)
assert actual == expected
assert isinstance(actual, PrefixedUrlString)
@ -36,35 +36,35 @@ def test_path_applied_twice_does_not_double_prefix(ds):
@pytest.mark.parametrize(
"base_url,expected",
"ds_base_url,expected",
[
("/", "/"),
("/prefix/", "/prefix/"),
],
)
def test_instance(ds, base_url, expected):
ds._settings["base_url"] = base_url
def test_instance(ds, ds_base_url, expected):
ds._settings["base_url"] = ds_base_url
actual = ds.urls.instance()
assert actual == expected
assert isinstance(actual, PrefixedUrlString)
@pytest.mark.parametrize(
"base_url,file,expected",
"ds_base_url,file,expected",
[
("/", "foo.js", "/-/static/foo.js"),
("/prefix/", "foo.js", "/prefix/-/static/foo.js"),
],
)
def test_static(ds, base_url, file, expected):
ds._settings["base_url"] = base_url
def test_static(ds, ds_base_url, file, expected):
ds._settings["base_url"] = ds_base_url
actual = ds.urls.static(file)
assert actual == expected
assert isinstance(actual, PrefixedUrlString)
@pytest.mark.parametrize(
"base_url,plugin,file,expected",
"ds_base_url,plugin,file,expected",
[
(
"/",
@ -80,44 +80,44 @@ def test_static(ds, base_url, file, expected):
),
],
)
def test_static_plugins(ds, base_url, plugin, file, expected):
ds._settings["base_url"] = base_url
def test_static_plugins(ds, ds_base_url, plugin, file, expected):
ds._settings["base_url"] = ds_base_url
actual = ds.urls.static_plugins(plugin, file)
assert actual == expected
assert isinstance(actual, PrefixedUrlString)
@pytest.mark.parametrize(
"base_url,expected",
"ds_base_url,expected",
[
("/", "/-/logout"),
("/prefix/", "/prefix/-/logout"),
],
)
def test_logout(ds, base_url, expected):
ds._settings["base_url"] = base_url
def test_logout(ds, ds_base_url, expected):
ds._settings["base_url"] = ds_base_url
actual = ds.urls.logout()
assert actual == expected
assert isinstance(actual, PrefixedUrlString)
@pytest.mark.parametrize(
"base_url,format,expected",
"ds_base_url,format,expected",
[
("/", None, "/_memory"),
("/prefix/", None, "/prefix/_memory"),
("/", "json", "/_memory.json"),
],
)
def test_database(ds, base_url, format, expected):
ds._settings["base_url"] = base_url
def test_database(ds, ds_base_url, format, expected):
ds._settings["base_url"] = ds_base_url
actual = ds.urls.database("_memory", format=format)
assert actual == expected
assert isinstance(actual, PrefixedUrlString)
@pytest.mark.parametrize(
"base_url,name,format,expected",
"ds_base_url,name,format,expected",
[
("/", "name", None, "/_memory/name"),
("/prefix/", "name", None, "/prefix/_memory/name"),
@ -125,8 +125,8 @@ def test_database(ds, base_url, format, expected):
("/", "name.json", "json", "/_memory/name~2Ejson.json"),
],
)
def test_table_and_query(ds, base_url, name, format, expected):
ds._settings["base_url"] = base_url
def test_table_and_query(ds, ds_base_url, name, format, expected):
ds._settings["base_url"] = ds_base_url
actual1 = ds.urls.table("_memory", name, format=format)
assert actual1 == expected
assert isinstance(actual1, PrefixedUrlString)
@ -136,15 +136,15 @@ def test_table_and_query(ds, base_url, name, format, expected):
@pytest.mark.parametrize(
"base_url,format,expected",
"ds_base_url,format,expected",
[
("/", None, "/_memory/facetable/1"),
("/prefix/", None, "/prefix/_memory/facetable/1"),
("/", "json", "/_memory/facetable/1.json"),
],
)
def test_row(ds, base_url, format, expected):
ds._settings["base_url"] = base_url
def test_row(ds, ds_base_url, format, expected):
ds._settings["base_url"] = ds_base_url
actual = ds.urls.row("_memory", "facetable", "1", format=format)
assert actual == expected
assert isinstance(actual, PrefixedUrlString)

View file

@ -191,6 +191,7 @@ async def test_debug_menu_items_are_in_jump_for_debug_menu_permission():
"Debug permissions": "/-/permissions",
"Debug messages": "/-/messages",
"Debug allow rules": "/-/allow-debug",
"Debug autocomplete": "/-/debug/autocomplete",
"Debug threads": "/-/threads",
"Debug actor": "/-/actor",
"Pattern portfolio": "/-/patterns",

View file

@ -1,394 +0,0 @@
import json
from pathlib import Path
import subprocess
import textwrap
REPO_ROOT = Path(__file__).resolve().parents[1]
STATIC_DIR = REPO_ROOT / "datasette" / "static"
def test_navigation_search_tracks_and_renders_recent_items():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const navigationSearchJs = __NAVIGATION_SEARCH_JS__;
class FakeElement {
constructor() {
this.innerHTML = "";
this.value = "";
this.dataset = {};
this.open = false;
}
addEventListener() {}
close() { this.open = false; }
focus() {}
querySelector() {
return { scrollIntoView() {} };
}
showModal() { this.open = true; }
}
class FakeShadowRoot {
constructor() {
this.innerHTML = "";
this.dialog = new FakeElement();
this.input = new FakeElement();
this.results = new FakeElement();
}
querySelector(selector) {
if (selector == "dialog") return this.dialog;
if (selector == ".search-input") return this.input;
if (selector == ".results-container") return this.results;
return new FakeElement();
}
}
global.HTMLElement = class {
constructor() {
this.attributes = {};
}
attachShadow() {
this.shadowRoot = new FakeShadowRoot();
return this.shadowRoot;
}
dispatchEvent() {}
getAttribute(name) {
return this.attributes[name] || null;
}
querySelector() {
return null;
}
setAttribute(name, value) {
this.attributes[name] = value;
}
};
global.CustomEvent = class {
constructor(name, options) {
this.name = name;
this.options = options;
}
};
global.customElements = {
registry: new Map(),
define(name, cls) {
this.registry.set(name, cls);
},
};
global.document = {
addEventListener() {},
activeElement: null,
createElement() {
return {
set textContent(value) {
this.innerHTML = String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
},
};
},
};
global.localStorage = {
store: {},
getItem(key) {
return Object.prototype.hasOwnProperty.call(this.store, key)
? this.store[key]
: null;
},
setItem(key, value) {
this.store[key] = String(value);
},
removeItem(key) {
delete this.store[key];
},
};
global.window = { location: { href: "" } };
vm.runInThisContext(
fs.readFileSync(navigationSearchJs, "utf8"),
{ filename: "navigation-search.js" }
);
const Component = customElements.registry.get("navigation-search");
const element = new Component();
const items = Array.from({ length: 6 }, (_, index) => ({
name: `Item ${index + 1}`,
url: `/item-${index + 1}`,
type: "table",
description: "Table",
}));
items[5].name = "content: recent_datasette_releases";
items[5].display_name = "Recent Datasette releases";
for (const item of items) {
element.matches = [item];
element.renderedMatches = [item];
element.selectedIndex = 0;
element.selectCurrentItem();
}
const stored = JSON.parse(
Object.values(localStorage.store).find((value) => value.includes("/item-6"))
);
if (stored.length !== 5) {
throw new Error(`Expected 5 recent items, got ${stored.length}`);
}
if (stored[0].url !== "/item-6" || stored[4].url !== "/item-2") {
throw new Error(`Unexpected recent order: ${JSON.stringify(stored)}`);
}
if (stored[0].display_name !== "Recent Datasette releases") {
throw new Error(`Missing display_name in recent item: ${JSON.stringify(stored[0])}`);
}
element.matches = [
items[5],
items[4],
{
name: "Other",
url: "/other",
type: "database",
description: "Database",
},
];
element.shadowRoot.input.value = "";
element.renderResults();
const html = element.shadowRoot.results.innerHTML;
if (!html.includes("Recent")) {
throw new Error(`Missing Recent heading: ${html}`);
}
if (!html.includes("Recent Datasette releases") || !html.includes("Item 5")) {
throw new Error(`Missing recent items: ${html}`);
}
if (!html.includes("content: recent_datasette_releases")) {
throw new Error(`Missing canonical item name for display_name item: ${html}`);
}
if (!html.includes("Item 4") || !html.includes("Item 2")) {
throw new Error(`Expected all stored recent items in empty state: ${html}`);
}
if (html.includes("Other")) {
throw new Error(`Rendered non-recent item in empty state: ${html}`);
}
if (!html.includes("Clear recent")) {
throw new Error(`Missing Clear recent control: ${html}`);
}
element.clearRecentItems();
if (localStorage.getItem(element.recentItemsStorageKey()) !== null) {
throw new Error("Expected recent items to be cleared");
}
element.renderResults();
if (element.shadowRoot.results.innerHTML.includes("Clear recent")) {
throw new Error("Clear recent should disappear after clearing");
}
process.stdout.write(JSON.stringify(stored));
""").replace(
"__NAVIGATION_SEARCH_JS__",
json.dumps(str(STATIC_DIR / "navigation-search.js")),
)
result = subprocess.run(
["node", "-e", script],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert [item["url"] for item in json.loads(result.stdout)] == [
"/item-6",
"/item-5",
"/item-4",
"/item-3",
"/item-2",
]
assert json.loads(result.stdout)[0]["display_name"] == "Recent Datasette releases"
def test_navigation_search_renders_jump_sections_from_javascript_plugins():
script = (
textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const datasetteManagerJs = __DATASETTE_MANAGER_JS__;
const navigationSearchJs = __NAVIGATION_SEARCH_JS__;
const documentListeners = {};
class FakeElement {
constructor(tagName = "div", parent = null) {
this._innerHTML = "";
this.value = "";
this.dataset = {};
this.open = false;
this.parent = parent;
this.tagName = tagName.toUpperCase();
}
set textContent(value) {
this.innerHTML = String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
get innerHTML() {
return this._innerHTML;
}
set innerHTML(value) {
this._innerHTML = String(value);
if (this.parent) {
this.parent._innerHTML += this._innerHTML;
}
}
addEventListener() {}
appendChild(child) {
this._innerHTML += child.innerHTML || "";
return child;
}
close() { this.open = false; }
focus() {}
querySelector(selector) {
if (selector.startsWith("[data-jump-section-index=")) {
return new FakeElement("div", this);
}
return { scrollIntoView() {} };
}
showModal() { this.open = true; }
}
class FakeShadowRoot {
constructor() {
this.innerHTML = "";
this.dialog = new FakeElement("dialog");
this.input = new FakeElement("input");
this.results = new FakeElement("div");
}
querySelector(selector) {
if (selector == "dialog") return this.dialog;
if (selector == ".search-input") return this.input;
if (selector == ".results-container") return this.results;
return new FakeElement();
}
}
global.HTMLElement = class {
constructor() {
this.attributes = {};
}
attachShadow() {
this.shadowRoot = new FakeShadowRoot();
return this.shadowRoot;
}
dispatchEvent() {}
getAttribute(name) {
return this.attributes[name] || null;
}
querySelector() {
return null;
}
setAttribute(name, value) {
this.attributes[name] = value;
}
};
global.CustomEvent = class {
constructor(name, options) {
this.name = name;
this.type = name;
this.detail = options ? options.detail : undefined;
}
};
global.customElements = {
registry: new Map(),
define(name, cls) {
this.registry.set(name, cls);
},
};
global.document = {
addEventListener(name, callback) {
documentListeners[name] = documentListeners[name] || [];
documentListeners[name].push(callback);
},
activeElement: null,
createElement(tagName) {
return new FakeElement(tagName);
},
dispatchEvent(event) {
for (const callback of documentListeners[event.type] || []) {
callback(event);
}
},
querySelectorAll() {
return [];
},
};
global.localStorage = {
getItem() { return null; },
setItem() {},
removeItem() {},
};
global.window = { datasetteVersion: "test", location: { href: "" } };
vm.runInThisContext(
fs.readFileSync(datasetteManagerJs, "utf8"),
{ filename: "datasette-manager.js" }
);
for (const callback of documentListeners.DOMContentLoaded || []) {
callback();
}
window.__DATASETTE__.registerPlugin("agent", {
version: "0.1",
makeJumpSections() {
return [
{
id: "agent-chat",
render(node, context) {
if (!context.navigationSearch) {
throw new Error("Expected navigationSearch in render context");
}
node.innerHTML = [
'<section class="agent-jump-start">',
'<button>Start a new agent chat</button>',
'</section>',
].join('');
},
},
];
},
});
vm.runInThisContext(
fs.readFileSync(navigationSearchJs, "utf8"),
{ filename: "navigation-search.js" }
);
const Component = customElements.registry.get("navigation-search");
const element = new Component();
element.shadowRoot.input.value = "";
element.renderResults();
const html = element.shadowRoot.results.innerHTML;
if (!html.includes("Start a new agent chat")) {
throw new Error(`Missing jump section content: ${html}`);
}
process.stdout.write("ok");
""")
.replace(
"__DATASETTE_MANAGER_JS__",
json.dumps(str(STATIC_DIR / "datasette-manager.js")),
)
.replace(
"__NAVIGATION_SEARCH_JS__",
json.dumps(str(STATIC_DIR / "navigation-search.js")),
)
)
result = subprocess.run(
["node", "-e", script],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout.endswith("ok")

892
tests/test_playwright.py Normal file
View file

@ -0,0 +1,892 @@
import json
import socket
import subprocess
import sys
import time
import httpx
import pytest
from datasette.fixtures import write_fixture_database
from datasette.utils.sqlite import sqlite3
def find_free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def wait_for_server(process, url, timeout=10):
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
if process.poll() is not None:
stdout, stderr = process.communicate()
raise AssertionError(
"Datasette server exited early\n"
f"stdout:\n{stdout}\n"
f"stderr:\n{stderr}"
)
try:
response = httpx.get(url, timeout=1.0)
if response.status_code < 500:
return
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
except httpx.HTTPError as ex:
last_error = repr(ex)
time.sleep(0.1)
raise AssertionError(f"Timed out waiting for {url}: {last_error}")
@pytest.fixture
def datasette_server(tmp_path):
fixtures_db_path = tmp_path / "fixtures.db"
write_fixture_database(str(fixtures_db_path))
data_db_path = tmp_path / "data.db"
write_playwright_database(str(data_db_path))
config_path = tmp_path / "datasette.json"
write_playwright_config(config_path)
plugins_dir = tmp_path / "plugins"
write_playwright_plugin(plugins_dir)
port = find_free_port()
process = subprocess.Popen(
[
sys.executable,
"-m",
"datasette",
str(fixtures_db_path),
str(data_db_path),
"--config",
str(config_path),
"--plugins-dir",
str(plugins_dir),
"--host",
"127.0.0.1",
"--port",
str(port),
"--setting",
"num_sql_threads",
"1",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
url = f"http://127.0.0.1:{port}/"
try:
wait_for_server(process, url)
yield url
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def write_playwright_database(db_path):
conn = sqlite3.connect(db_path)
try:
conn.executescript("""
create table projects (
id integer primary key,
title text not null,
metadata text,
logo text,
notes text,
score integer default 5
);
create table defaults_demo (
id integer primary key,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
insert into projects (title, metadata, logo, notes, score) values
(
'Build Datasette',
'{"ok": true}',
'asset-original',
'Initial notes',
5
);
""")
finally:
conn.close()
def write_playwright_config(config_path):
config_path.write_text(
json.dumps(
{
"databases": {
"data": {
"permissions": {
"create-table": True,
"set-column-type": True,
},
"tables": {
"projects": {
"label_column": "title",
"column_types": {
"metadata": "json",
"logo": "asset",
"notes": "textarea",
},
"permissions": {
"alter-table": True,
"insert-row": True,
"update-row": True,
"delete-row": True,
},
},
"defaults_demo": {
"permissions": {
"alter-table": True,
},
},
},
},
},
}
),
"utf-8",
)
def write_playwright_plugin(plugins_dir):
plugins_dir.mkdir()
(plugins_dir / "playwright_plugin.py").write_text(
'''
from datasette import hookimpl
from datasette.column_types import ColumnType, SQLiteType
class AssetColumnType(ColumnType):
name = "asset"
description = "Demo asset picker"
sqlite_types = (SQLiteType.TEXT,)
@hookimpl
def register_column_types(datasette):
return [AssetColumnType]
@hookimpl
def extra_body_script():
return {
"module": True,
"script": """
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("playwright-jump-section", {
version: "0.1",
makeJumpSections() {
return [
{
id: "agent-chat",
render(node, context) {
if (!context.navigationSearch || !context.input) {
throw new Error("Expected navigation search context");
}
node.innerHTML = [
'<section class="agent-jump-start">',
'<button type="button" data-playwright-agent-chat>',
'Start a new agent chat',
'</button>',
'</section>',
].join("");
node.querySelector("button").addEventListener("click", function () {
window.location.href = "/-/playwright-agent";
});
},
},
];
},
});
event.detail.registerPlugin("playwright-asset-field", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "asset") {
return;
}
return {
render(field) {
const wrapper = document.createElement("div");
wrapper.className = "playwright-asset-picker";
wrapper.dataset.column = field.context.column;
wrapper.dataset.database = field.context.database || "";
wrapper.dataset.table = field.context.table || "";
wrapper.dataset.tableUrl = field.context.tableUrl || "";
wrapper.dataset.mode = field.context.mode || "";
wrapper.dataset.columnType = field.context.columnType.type;
field.input.type = "hidden";
const value = document.createElement("span");
value.className = "playwright-asset-value";
const button = document.createElement("button");
button.type = "button";
button.className = "playwright-asset-select";
button.textContent = "Use demo asset";
function sync() {
value.textContent = field.getValue() || "No asset selected";
}
button.addEventListener("click", function () {
field.setValue("asset-from-plugin");
sync();
});
wrapper.appendChild(field.input);
wrapper.appendChild(value);
wrapper.appendChild(button);
sync();
return wrapper;
},
focus(field) {
const button = field.root.querySelector(".playwright-asset-select");
if (button) {
button.focus();
}
},
};
},
});
});
""",
}
''',
"utf-8",
)
def project_rows(datasette_server, **filters):
params = {
"_shape": "objects",
**{key: str(value) for key, value in filters.items()},
}
response = httpx.get(f"{datasette_server}data/projects.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def project_row(datasette_server, pk):
rows = project_rows(datasette_server, id=pk)
assert len(rows) == 1
return rows[0]
def open_jump_menu(page):
page.keyboard.press("/")
page.locator("navigation-search .search-input").wait_for()
@pytest.mark.playwright
def test_datasette_homepage_contains_datasette(page, datasette_server):
page.goto(datasette_server)
assert "Datasette" in page.locator("body").inner_text()
@pytest.mark.playwright
def test_create_table_flow(page, datasette_server):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
assert dialog.locator(".modal-title").inner_text() == "Create a table in data"
placeholder_select = dialog.locator(".table-create-custom-column-type").nth(0)
assert placeholder_select.input_value() == ""
assert (
placeholder_select.locator("option:checked").inner_text() == "- custom type -"
)
assert "table-create-input-placeholder" in placeholder_select.get_attribute("class")
assert (
dialog.locator(".table-create-column-name").nth(0).get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-create-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-create-column-name"
).getBoundingClientRect().height;
const selectHeight = node.querySelector(
".table-create-column-type"
).getBoundingClientRect().height;
return Math.abs(inputHeight - selectHeight) <= 1;
}""")
dialog.locator('input[name="table"]').fill("playwright_created")
dialog.locator(".table-create-column-name").nth(1).fill("title")
dialog.locator(".table-create-more-options").nth(1).click()
dialog.locator(".table-create-not-null-input").nth(1).check()
title_defaults = dialog.locator(".table-create-default-options").nth(1)
assert title_defaults.locator("summary").inner_text() == "Set a default value"
title_defaults.locator("summary").click()
assert "or default to a specific value" in title_defaults.inner_text()
title_default_expr = title_defaults.locator(".table-create-default-expr")
title_default_input = title_defaults.locator(".table-create-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in title_default_expr.locator("option").nth(1).inner_text()
)
title_default_expr.select_option("current_timestamp")
assert title_default_input.is_enabled()
title_default_input.fill("Untitled")
assert title_default_expr.input_value() == ""
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(2).fill("score")
dialog.locator(".table-create-column-type").nth(2).select_option("integer")
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(3).fill("metadata")
dialog.locator(".table-create-column-type").nth(3).select_option("integer")
dialog.locator(".table-create-more-options").nth(3).click()
dialog.locator(".table-create-custom-column-type").nth(3).select_option("json")
assert dialog.locator(".table-create-column-type").nth(3).input_value() == "text"
assert "table-create-input-placeholder" not in dialog.locator(
".table-create-custom-column-type"
).nth(3).get_attribute("class")
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_created")
assert "playwright_created" in page.locator("h1").inner_text()
response = httpx.get(
f"{datasette_server}data/playwright_created.json?_extra=columns,column_types"
)
response.raise_for_status()
data = response.json()
assert data["columns"] == [
"id",
"title",
"score",
"metadata",
]
assert data["column_types"] == {
"metadata": {"type": "json", "config": None},
}
schema_response = httpx.get(
f"{datasette_server}data/-/query.json",
params={
"sql": (
"select sql from sqlite_master where type = 'table' "
"and name = 'playwright_created'"
)
},
)
schema_response.raise_for_status()
schema = schema_response.json()["rows"][0]["sql"]
assert "title" in schema
assert "NOT NULL DEFAULT 'Untitled'" in schema
@pytest.mark.playwright
def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
dialog.locator(".table-create-more-options").nth(1).click()
column_name = dialog.locator(".table-create-column-name").nth(1)
type_select = dialog.locator(".table-create-column-type").nth(1)
foreign_key_select = dialog.locator(".table-create-foreign-key-target").nth(1)
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
def test_create_table_unix_default_expression_updates_column_type(
page, datasette_server
):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
row = dialog.locator(".table-create-column-row").nth(1)
row.locator(".table-create-more-options").click()
row.locator(".table-create-default-options summary").click()
type_select = row.locator(".table-create-column-type")
default_expr = row.locator(".table-create-default-expr")
assert type_select.input_value() == "text"
assert (
"Current Unix time, integer milliseconds since the epoch"
in default_expr.locator("option").last.inner_text()
)
default_expr.select_option("current_unixtime_ms")
assert type_select.input_value() == "integer"
@pytest.mark.playwright
def test_alter_table_foreign_key_selection_updates_blank_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-add-column").click()
column_name = dialog.locator(".table-alter-column-name").last
type_select = dialog.locator(".table-alter-column-type").last
foreign_key_select = dialog.locator(".table-alter-foreign-key-target").last
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
def test_alter_table_unix_default_expression_updates_column_type(
page, datasette_server
):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-add-column").click()
row = dialog.locator(".table-alter-column-row").last
row.locator(".table-alter-default-options summary").click()
type_select = row.locator(".table-alter-column-type")
default_expr = row.locator(".table-alter-default-expr")
assert type_select.input_value() == "text"
assert (
"Current Unix time, integer seconds since the epoch"
in default_expr.locator("option").all_inner_texts()
)
default_expr.select_option("current_unixtime")
assert type_select.input_value() == "integer"
@pytest.mark.playwright
def test_alter_table_existing_default_expression_populates_select(
page, datasette_server
):
page.goto(f"{datasette_server}data/defaults_demo")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
row = dialog.locator(".table-alter-column-row").nth(1)
row.locator(".table-alter-more-options").click()
row.locator(".table-alter-default-options summary").click()
assert row.locator(".table-alter-default-expr").input_value() == (
"current_unixtime_ms"
)
assert row.locator(".table-alter-default").input_value() == ""
@pytest.mark.playwright
def test_alter_table_flow(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
assert dialog.locator(".modal-title").inner_text() == "Alter table projects"
assert dialog.locator(".table-alter-save").is_disabled()
assert (
dialog.locator(".table-alter-column-name").first.get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-alter-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-alter-column-name"
).getBoundingClientRect().height;
const selectHeight = node.querySelector(
".table-alter-column-type"
).getBoundingClientRect().height;
return Math.abs(inputHeight - selectHeight) <= 1;
}""")
type_options = dialog.locator(".table-alter-column-type").first.locator("option")
assert type_options.all_inner_texts() == [
"text",
"integer",
"floating point number",
"blob - binary data",
]
first_more_options = dialog.locator(".table-alter-more-options").first
assert first_more_options.inner_text() == "> Advanced options"
first_more_options.click()
assert first_more_options.inner_text() == "v Hide options"
expanded_options_text = dialog.locator(
".table-alter-column-details"
).first.inner_text()
assert dialog.locator(".table-alter-fields").evaluate(
"node => node.scrollWidth <= node.clientWidth + 1"
)
assert "Not null" in expanded_options_text
assert "This value cannot be left unset" in expanded_options_text
assert "Set a default value" in expanded_options_text
assert "Primary key" in expanded_options_text
assert "This ID uniquely identifies the record" in expanded_options_text
assert "Foreign key" in expanded_options_text
first_defaults = dialog.locator(".table-alter-default-options").first
first_defaults.locator("summary").click()
assert "or default to a specific value" in first_defaults.inner_text()
first_default_expr = first_defaults.locator(".table-alter-default-expr")
first_default_input = first_defaults.locator(".table-alter-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in first_default_expr.locator("option").nth(1).inner_text()
)
first_default_expr.select_option("current_timestamp")
assert first_default_input.is_enabled()
first_default_input.fill("manual")
assert first_default_expr.input_value() == ""
dialog.locator(".table-alter-add-column").click()
assert dialog.locator(".table-alter-save").is_enabled()
dialog.locator(".table-alter-column-name").last.fill("status")
dialog.locator(".table-alter-column-type").last.select_option("text")
dialog.locator(".table-alter-default-options").last.locator("summary").click()
dialog.locator(".table-alter-default").last.fill("planned")
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Add column status as text, with default value planned." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-back").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Apply changes"
dialog.locator(".table-alter-save").click()
columns = []
for _ in range(20):
response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns")
response.raise_for_status()
columns = response.json()["columns"]
if "status" in columns:
break
time.sleep(0.1)
assert "status" in columns
@pytest.mark.playwright
def test_alter_table_primary_key_columns_stay_at_top(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
rows = dialog.locator(".table-alter-column-row")
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button")
for i in range(first_row_move_buttons.count()):
assert first_row_move_buttons.nth(i).is_disabled()
assert (
first_row_move_buttons.nth(i).get_attribute("title")
== "Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").is_disabled()
assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == (
"Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == (
"Primary key columns are always listed first"
)
last_row = rows.nth(rows.count() - 1)
assert last_row.locator(".table-alter-column-name").input_value() == "score"
last_row.locator(".table-alter-move-top").click()
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score"
@pytest.mark.playwright
def test_alter_table_review_rename_primary_key_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
save = dialog.locator(".table-alter-save")
assert save.is_disabled()
dialog.locator(".table-alter-column-name").first.fill("id3")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
review_text = review.inner_text()
assert "Rename column id to id3." in review_text
assert "Set primary key to" not in review_text
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"id",
"id3",
]
@pytest.mark.playwright
def test_alter_table_review_rename_table(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
save = dialog.locator(".table-alter-save")
rename_details = dialog.locator(".table-alter-table-options")
assert rename_details.locator("summary").inner_text() == "Rename table"
assert not dialog.locator(".table-alter-table-name").is_visible()
assert save.is_disabled()
rename_details.locator("summary").click()
table_name = dialog.locator(".table-alter-table-name")
assert table_name.input_value() == "projects"
assert table_name.get_attribute("placeholder") == "table name"
table_name.fill("projects_archive")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Rename table to projects_archive." in review.inner_text()
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"projects_archive",
]
@pytest.mark.playwright
def test_alter_table_review_not_null_wording(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-more-options").first.click()
dialog.locator(".table-alter-not-null-input").first.check()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Change column id: not null (require values)." in review.inner_text()
@pytest.mark.playwright
def test_alter_table_review_warns_when_dropping_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
remove_buttons = dialog.locator(".table-alter-remove-column")
remove_buttons.nth(remove_buttons.count() - 1).click()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Warning: data in dropped columns will be permanently lost." in review_text
assert "Drop column score." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-review-damaging").inner_text() == (
"Drop column score."
)
dialog.locator(".table-alter-back").click()
assert dialog.locator(".table-alter-column-list").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Review changes"
@pytest.mark.playwright
def test_alter_table_cancel_skips_discard_prompt(page, datasette_server):
def open_alter_dialog():
page.locator("details.actions-menu-links").evaluate("node => node.open = true")
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
return dialog
page.goto(f"{datasette_server}data/projects")
page.evaluate("""
() => {
window.__discardConfirmMessages = [];
window.confirm = (message) => {
window.__discardConfirmMessages.push(message);
return false;
};
}
""")
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("cancel_me")
dialog.locator(".table-alter-cancel").click()
assert dialog.evaluate("node => node.open") is False
assert page.evaluate("() => window.__discardConfirmMessages") == []
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("escape_me")
page.keyboard.press("Escape")
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
page.evaluate("() => window.__discardConfirmMessages = []")
dialog.evaluate(
"""node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))"""
)
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
@pytest.mark.playwright
def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server):
page.goto(datasette_server)
open_jump_menu(page)
search = page.locator("navigation-search .search-input")
search.fill("projects")
result = page.locator("navigation-search .result-item", has_text="projects").first
result.wait_for()
result.click()
page.wait_for_url("**/data/projects")
page.goto(datasette_server)
open_jump_menu(page)
results = page.locator("navigation-search .results-container")
results.locator(".results-heading", has_text="Recent").wait_for()
assert "projects" in results.inner_text()
page.locator("navigation-search [data-clear-recent-items]").click()
page.locator("navigation-search .results-container", has_text="Recent").wait_for(
state="detached"
)
@pytest.mark.playwright
def test_navigation_search_renders_jump_sections_from_javascript_plugins(
page, datasette_server
):
page.goto(datasette_server)
open_jump_menu(page)
button = page.locator("navigation-search [data-playwright-agent-chat]")
button.wait_for()
assert button.inner_text() == "Start a new agent chat"
button.click()
page.wait_for_url("**/-/playwright-agent")
@pytest.mark.playwright
def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator('input[name="title"]').fill("Launch Datasette Cloud")
dialog.locator('textarea[name="metadata"]').fill(
'{"ok": false, "source": "playwright"}'
)
dialog.locator('textarea[name="notes"]').fill("Inserted from Playwright")
asset = dialog.locator(".playwright-asset-picker")
asset.wait_for()
assert asset.get_attribute("data-column") == "logo"
assert asset.get_attribute("data-database") == "data"
assert asset.get_attribute("data-table") == "projects"
assert asset.get_attribute("data-mode") == "insert"
asset.locator(".playwright-asset-select").click()
assert asset.locator(".playwright-asset-value").inner_text() == "asset-from-plugin"
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Inserted row 2").wait_for()
row = page.locator('tr[data-row="2"]')
row.wait_for()
assert "Launch Datasette Cloud" in row.inner_text()
data = project_row(datasette_server, 2)
assert data["title"] == "Launch Datasette Cloud"
assert data["metadata"] == '{"ok": false, "source": "playwright"}'
assert data["logo"] == "asset-from-plugin"
assert data["notes"] == "Inserted from Playwright"
assert data["score"] == 5
@pytest.mark.playwright
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
title = dialog.locator('input[name="title"]')
title.wait_for()
title.fill("Build Datasette, edited")
metadata = dialog.locator('textarea[name="metadata"]')
metadata.fill("{")
dialog.locator(
".row-edit-field-validation-error", has_text="Invalid JSON"
).wait_for()
dialog.locator(".row-edit-save").click()
assert dialog.evaluate("node => node.open")
assert project_row(datasette_server, 1)["title"] == "Build Datasette"
metadata.fill('{"ok": true, "edited": true}')
dialog.locator(
".row-edit-field-validation-error", has_text="Invalid JSON"
).wait_for(state="hidden")
dialog.locator('textarea[name="notes"]').fill("Edited from Playwright")
asset = dialog.locator(".playwright-asset-picker")
asset.wait_for()
assert asset.get_attribute("data-mode") == "edit"
asset.locator(".playwright-asset-select").click()
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Updated row 1").wait_for()
row = page.locator('tr[data-row="1"]')
assert "Build Datasette, edited" in row.inner_text()
data = project_row(datasette_server, 1)
assert data["title"] == "Build Datasette, edited"
assert data["metadata"] == '{"ok": true, "edited": true}'
assert data["logo"] == "asset-from-plugin"
assert data["notes"] == "Edited from Playwright"
@pytest.mark.playwright
def test_delete_row_flow_removes_row(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator('tr[data-row="1"] button[data-row-action="delete"]').click()
dialog = page.locator("#row-delete-dialog")
dialog.wait_for()
assert "Delete row 1" in dialog.inner_text()
dialog.locator(".row-delete-confirm").click()
page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for()
page.locator('tr[data-row="1"]').wait_for(state="detached")
assert project_rows(datasette_server, id=1) == []

View file

@ -1062,6 +1062,7 @@ async def test_hook_menu_links(ds_client):
async def test_hook_table_actions(ds_client):
response = await ds_client.get("/fixtures/facetable")
assert get_actions_links(response.text) == []
assert get_actions_buttons(response.text) == []
response_2 = await ds_client.get("/fixtures/facetable?_bot=1&_hello=BOB")
assert ">Table actions<" in response_2.text
assert sorted(
@ -1071,6 +1072,23 @@ async def test_hook_table_actions(ds_client):
{"label": "From async BOB", "href": "/", "description": None},
{"label": "Table: facetable", "href": "/", "description": None},
]
response_3 = await ds_client.get("/fixtures/facetable?_bot=1&_button=1")
assert get_actions_buttons(response_3.text) == [
{
"label": "Plugin button",
"description": "Runs JavaScript from a plugin",
"attrs": {
"aria-label": "Plugin button for facetable",
"class": ["button-as-link", "action-menu-button"],
"data-database": "fixtures",
"data-plugin-action": "plugin-button",
"data-table": "facetable",
"role": "menuitem",
"tabindex": "-1",
"type": "button",
},
}
]
@pytest.mark.asyncio
@ -1098,15 +1116,38 @@ def get_actions_links(html):
links = []
for a_el in details.select("a"):
description = None
if a_el.find("p") is not None:
description = a_el.find("p").text.strip()
a_el.find("p").extract()
description_el = a_el.find(class_="dropdown-description")
if description_el is not None:
description = description_el.text.strip()
description_el.extract()
label = a_el.text.strip()
href = a_el["href"]
links.append({"label": label, "href": href, "description": description})
return links
def get_actions_buttons(html):
soup = Soup(html, "html.parser")
details = soup.find("details", {"class": "actions-menu-links"})
if details is None:
return []
buttons = []
for button_el in details.select("button.action-menu-button"):
description = None
description_el = button_el.find(class_="dropdown-description")
if description_el is not None:
description = description_el.text.strip()
description_el.extract()
buttons.append(
{
"label": button_el.text.strip(),
"description": description,
"attrs": dict(button_el.attrs),
}
)
return buttons
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,expected_url",

View file

@ -14,6 +14,15 @@ from datasette.utils.sqlite import sqlite3, supports_returning
requires_sqlite_returning = pytest.mark.skipif(
not supports_returning(), reason="SQLite does not support RETURNING"
)
EXPECTED_CREATE_TABLE_TEMPLATE_SQL = "\n".join(
(
"create table new_table (",
" id integer primary key,",
" name text",
" -- created text default (datetime('now'))",
")",
)
)
def _template_option_attributes(html, table):
@ -29,6 +38,16 @@ def _template_sql(html, table, operation):
return unescape(match.group(1))
def _template_button_sql(html, operation):
soup = Soup(html, "html.parser")
button = soup.select_one('button[data-sql-template="{}"]'.format(operation))
assert button, "Could not find {} template button".format(operation)
assert button.get(
"data-template-sql"
), "Could not find SQL for {} template button".format(operation)
return button["data-template-sql"]
async def add_numbered_queries(ds, database, count):
for i in range(1, count + 1):
await ds.add_query(
@ -1645,6 +1664,14 @@ async def test_execute_write_get_prepopulates_without_executing():
)
assert "<h2>Query operations</h2>" in response.text
assert "<summary>Start with a template</summary>" in response.text
assert 'data-sql-template="create"' in response.text
assert _template_button_sql(response.text, "create") == (
EXPECTED_CREATE_TABLE_TEMPLATE_SQL
)
assert ">Create table</button>" in response.text
assert '<label for="execute-write-template-table">or table:</label>' in (
response.text
)
assert '<option value="dogs"' in response.text
assert "data-template-insert-sql=" in response.text
assert 'data-sql-template="insert"' in response.text
@ -1660,6 +1687,9 @@ async def test_execute_write_get_prepopulates_without_executing():
assert 'addEventListener("paste"' in response.text
assert "setupSqlParameterRefresh" in response.text
assert "datasetteSqlAnalysis.renderAnalysis" in response.text
assert "window.editor.dispatch" in response.text
assert "window.history.replaceState" in response.text
assert "window.location.href = url.toString();" not in response.text
assert "input[data-execute-write-submit]:disabled" in response.text
assert (
'data-execute-write-disabled-reason aria-live="polite" hidden' in response.text
@ -1678,8 +1708,10 @@ async def test_execute_write_get_prepopulates_without_executing():
"/data/-/execute-write",
actor={"id": "root"},
)
assert '<p class="sql-editor sql-editor-min-lines">' in empty_response.text
assert '<textarea id="sql-editor" name="sql"></textarea>' in empty_response.text
assert 'executeWriteSqlInput.value = "\\n\\n\\n";' in empty_response.text
assert "min-height: calc(5.6em + 8px);" in empty_response.text
assert 'executeWriteSqlInput.value = "\\n\\n\\n";' not in empty_response.text
assert "Enter writable SQL before executing." in empty_response.text
assert 'data-save-query-base-url="/data/-/queries/store"' in empty_response.text
assert '<a href="/data/-/queries/store' not in empty_response.text
@ -1813,6 +1845,7 @@ async def test_execute_write_templates_are_filtered_by_permission_and_server_gen
assert '<option value="dogs"' in writer_response.text
assert '<option value="manual"' in writer_response.text
assert '<option value="cats"' not in writer_response.text
assert 'data-sql-template="create"' not in writer_response.text
assert "function insertSql(" not in writer_response.text
assert "function updateSql(" not in writer_response.text
assert "function deleteSql(" not in writer_response.text
@ -1842,6 +1875,7 @@ async def test_execute_write_templates_are_filtered_by_permission_and_server_gen
assert 'data-sql-template="delete"' in deleter_response.text
assert 'data-sql-template="insert"' not in deleter_response.text
assert 'data-sql-template="update"' not in deleter_response.text
assert 'data-sql-template="create"' not in deleter_response.text
assert viewer_response.status_code == 200
assert "<summary>Start with a template</summary>" not in viewer_response.text
@ -1851,6 +1885,101 @@ async def test_execute_write_templates_are_filtered_by_permission_and_server_gen
assert "data-template-delete-sql" not in viewer_response.text
@pytest.mark.asyncio
async def test_execute_write_create_table_template_is_filtered_by_permission():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-database": {"id": ["creator", "editor", "both"]},
"execute-write-sql": {"id": ["creator", "editor", "both"]},
"create-table": {"id": ["creator", "both"]},
},
"tables": {
"dogs": {
"permissions": {
"view-table": {"id": ["editor", "both"]},
"insert-row": {"id": ["editor", "both"]},
"update-row": {"id": ["editor", "both"]},
"delete-row": {"id": ["editor", "both"]},
}
},
},
}
}
},
)
db = ds.add_memory_database("execute_write_create_template", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
creator_response = await ds.client.get(
"/data/-/execute-write", actor={"id": "creator"}
)
editor_response = await ds.client.get(
"/data/-/execute-write", actor={"id": "editor"}
)
both_response = await ds.client.get("/data/-/execute-write", actor={"id": "both"})
assert creator_response.status_code == 200
assert "<summary>Start with a template</summary>" in creator_response.text
assert _template_button_sql(creator_response.text, "create") == (
EXPECTED_CREATE_TABLE_TEMPLATE_SQL
)
assert "There are no tables that you can currently edit." not in (
creator_response.text
)
assert 'id="execute-write-template-table"' not in creator_response.text
assert 'data-sql-template="insert"' not in creator_response.text
assert 'data-sql-template="update"' not in creator_response.text
assert 'data-sql-template="delete"' not in creator_response.text
assert editor_response.status_code == 200
assert 'data-sql-template="create"' not in editor_response.text
assert '<label for="execute-write-template-table">Table</label>' in (
editor_response.text
)
assert 'data-sql-template="insert"' in editor_response.text
assert 'data-sql-template="update"' in editor_response.text
assert 'data-sql-template="delete"' in editor_response.text
assert both_response.status_code == 200
assert _template_button_sql(both_response.text, "create") == (
EXPECTED_CREATE_TABLE_TEMPLATE_SQL
)
assert '<label for="execute-write-template-table">or table:</label>' in (
both_response.text
)
assert 'data-sql-template="insert"' in both_response.text
assert 'data-sql-template="update"' in both_response.text
assert 'data-sql-template="delete"' in both_response.text
@pytest.mark.asyncio
async def test_execute_write_create_table_refreshes_template_tables():
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("execute_write_create_template_refresh", name="data")
await ds.invoke_startup()
response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "root"},
data={"sql": "create table selectable (id integer primary key, name text)"},
)
assert response.status_code == 200
assert "Query executed" in response.text
assert '<option value="selectable"' in response.text
assert _template_sql(response.text, "selectable", "insert") == (
'insert into "selectable" (\n' ' "name"\n' ")\n" "values (\n" " :name\n" ")"
)
assert await db.table_exists("selectable")
@pytest.mark.asyncio
async def test_execute_write_analyze_endpoint_uses_sql_only():
ds = Datasette(memory=True, default_deny=True)

View file

@ -1,4 +1,5 @@
from datasette.app import Datasette
from datasette.database import Database
from bs4 import BeautifulSoup as Soup
from .fixtures import make_app_client
import pathlib
@ -7,6 +8,67 @@ import urllib.parse
from .utils import inner_html
def table_data_from_soup(soup):
import json
import re
table_script = [
s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "")
][0]
match = re.search(
r"window\._datasetteTableData\s*=\s*({.*?});",
table_script.string,
re.DOTALL,
)
return json.loads(match.group(1))
def database_data_from_soup(soup):
import json
import re
database_script = [
s
for s in soup.find_all("script")
if "_datasetteDatabaseData" in (s.string or "")
][0]
match = re.search(
r"window\._datasetteDatabaseData\s*=\s*({.*?});",
database_script.string,
re.DOTALL,
)
return json.loads(match.group(1))
DEFAULT_EXPRESSION_OPTIONS = [
{
"value": "current_timestamp",
"label": "Current timestamp in UTC, e.g. 2026-05-01 13:34:00",
"sqliteType": "text",
},
{
"value": "current_date",
"label": "Current date in UTC, e.g. 2026-05-01",
"sqliteType": "text",
},
{
"value": "current_time",
"label": "Current time in UTC, e.g. 13:34:00",
"sqliteType": "text",
},
{
"value": "current_unixtime",
"label": "Current Unix time, integer seconds since the epoch",
"sqliteType": "integer",
},
{
"value": "current_unixtime_ms",
"label": "Current Unix time, integer milliseconds since the epoch",
"sqliteType": "integer",
},
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,expected_definition_sql",
@ -663,6 +725,13 @@ async def test_table_html_compound_primary_key(ds_client):
assert [
[str(td) for td in tr.select("td")] for tr in table.select("tbody tr")
] == expected
rows = table.select("tbody tr")
assert rows[0]["data-row"] == "a,b"
assert "data-row-pk-path" not in rows[0].attrs
assert "data-row-label" not in rows[0].attrs
assert rows[1]["data-row"] == "a~2Fb,~2Ec-d"
assert "data-row-pk-path" not in rows[1].attrs
assert "data-row-label" not in rows[1].attrs
@pytest.mark.asyncio
@ -828,6 +897,810 @@ async def test_mobile_column_actions_present(ds_client, path):
assert len(ths) >= 1
@pytest.mark.asyncio
async def test_row_delete_action_data_attributes():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"update-row": {"id": "root"},
"delete-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_delete_actions"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text, score integer);
insert into items (id, name, score) values (1, 'One', 5);
""")
response = await ds.client.get("/data/items", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert table_data_from_soup(soup) == {
"database": "data",
"table": "items",
"tableUrl": "/data/items",
}
assert soup.select_one('button[data-table-action="insert-row"]') is None
row = soup.select_one("table.rows-and-columns tbody tr")
assert row["data-row"] == "1"
assert row["data-row-label"] == "One"
assert {key for key in row.attrs if key.startswith("data-row")} == {
"data-row",
"data-row-label",
}
edit_button = row.select_one(
'button.row-inline-action-edit[data-row-action="edit"]'
)
assert edit_button is not None
assert edit_button["aria-label"] == "Edit row 1 One"
assert edit_button["title"] == "Edit row"
assert edit_button.find("svg") is not None
button = row.select_one(
'button.row-inline-action-delete[data-row-action="delete"]'
)
assert button is not None
assert button["aria-label"] == "Delete row 1 One"
assert button["title"] == "Delete row"
assert button.find("svg") is not None
response = await ds.client.get("/data/items?_col=score", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
row = soup.select_one("table.rows-and-columns tbody tr")
assert row["data-row"] == "1"
assert "data-row-label" not in row.attrs
edit_button = row.select_one(
'button.row-inline-action-edit[data-row-action="edit"]'
)
assert edit_button is not None
assert edit_button["aria-label"] == "Edit row 1"
button = row.select_one(
'button.row-inline-action-delete[data-row-action="delete"]'
)
assert button is not None
assert button["aria-label"] == "Delete row 1"
finally:
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_action_button_and_data():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_action"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.get("/data", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.action-menu-button[data-database-action="create-table"]'
)
assert button is not None
assert button["aria-label"] == "Create table in data"
assert button["role"] == "menuitem"
description = button.find("span", class_="dropdown-description")
assert description.text.strip() == "Create a new table in this database."
description.extract()
assert button.text.strip() == "Create table"
assert any(
"edit-tools.js" in script.get("src", "")
for script in soup.find_all("script")
)
assert database_data_from_soup(soup) == {
"createTable": {
"path": "/data/-/create",
"foreignKeyTargetsPath": "/data/-/foreign-key-targets",
"databaseName": "data",
"columnTypes": ["text", "integer", "float", "blob"],
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
},
}
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
response_without_permission = await ds.client.get(
"/data", actor={"id": "someone-else"}
)
assert response_without_permission.status_code == 200
soup_without_permission = Soup(response_without_permission.text, "html.parser")
assert (
soup_without_permission.select_one(
'button[data-database-action="create-table"]'
)
is None
)
assert not any(
"_datasetteDatabaseData" in (script.string or "")
for script in soup_without_permission.find_all("script")
)
finally:
ds.close()
@pytest.mark.asyncio
async def test_database_create_table_data_includes_custom_column_types():
ds = Datasette(
[],
config={
"databases": {
"data": {
"permissions": {
"create-table": {"id": "root"},
"set-column-type": {"id": "root"},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_database_create_table_custom_types"),
name="data",
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
""")
response = await ds.client.get("/data", actor={"id": "root"})
assert response.status_code == 200
create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[
"createTable"
]
assert create_table_data["customColumnTypes"] == [
{
"name": "email",
"description": "Email address",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "json",
"description": "JSON data",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "textarea",
"description": "Multiline text",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
{
"name": "url",
"description": "URL",
"sqliteTypes": ["text"],
"fixedSqliteType": "text",
},
]
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_alter_action_button_and_data():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"alter-table": {"id": ["root", "alter-only"]},
"set-column-type": {"id": "root"},
"drop-table": {"id": "root"},
},
"column_types": {"name": "textarea"},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_alter_action"), name="data"
)
await db.execute_write_script("""
create table items (
id integer primary key,
name text not null,
score integer default 5,
created text default current_timestamp,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
""")
response = await ds.client.get("/data/items", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.action-menu-button[data-table-action="alter-table"]'
)
assert button is not None
assert button["aria-label"] == "Alter table items"
assert button["role"] == "menuitem"
description = button.find("span", class_="dropdown-description")
assert description.text.strip() == (
"Change columns and primary key for this table."
)
description.extract()
assert button.text.strip() == "Alter table"
assert any(
"edit-tools.js" in script.get("src", "")
for script in soup.find_all("script")
)
alter_data = table_data_from_soup(soup)["alterTable"]
assert alter_data["path"] == "/data/items/-/alter"
assert alter_data["tableName"] == "items"
assert alter_data["primaryKeys"] == ["id"]
assert alter_data["columnTypes"] == ["text", "integer", "float", "blob"]
assert alter_data["foreignKeyTargetsPath"] == (
"/data/-/foreign-key-targets?table=items"
)
assert alter_data["defaultExpressions"] == DEFAULT_EXPRESSION_OPTIONS
assert [option["name"] for option in alter_data["customColumnTypes"]] == [
"email",
"json",
"textarea",
"url",
]
assert alter_data["dropPath"] == "/data/items/-/drop"
assert alter_data["columns"] == [
{
"name": "id",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": None,
"has_default": False,
"is_pk": True,
"foreign_key": None,
"column_type": None,
},
{
"name": "name",
"type": "text",
"sqlite_type": "TEXT",
"notnull": 1,
"default": None,
"has_default": False,
"is_pk": False,
"foreign_key": None,
"column_type": {"type": "textarea", "config": None},
},
{
"name": "score",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": "5",
"has_default": True,
"is_pk": False,
"foreign_key": None,
"column_type": None,
},
{
"name": "created",
"type": "text",
"sqlite_type": "TEXT",
"notnull": 0,
"default": None,
"default_expr": "current_timestamp",
"has_default": True,
"is_pk": False,
"foreign_key": None,
"column_type": None,
},
{
"name": "created_ms",
"type": "integer",
"sqlite_type": "INTEGER",
"notnull": 0,
"default": None,
"default_expr": "current_unixtime_ms",
"has_default": True,
"is_pk": False,
"foreign_key": None,
"column_type": None,
},
]
response_without_permission = await ds.client.get(
"/data/items", actor={"id": "someone-else"}
)
assert response_without_permission.status_code == 200
soup_without_permission = Soup(response_without_permission.text, "html.parser")
assert (
soup_without_permission.select_one(
'button[data-table-action="alter-table"]'
)
is None
)
assert "alterTable" not in table_data_from_soup(soup_without_permission)
# An actor that can alter but not drop should not get a dropPath
response_alter_only = await ds.client.get(
"/data/items", actor={"id": "alter-only"}
)
assert response_alter_only.status_code == 200
alter_only_data = table_data_from_soup(
Soup(response_alter_only.text, "html.parser")
)["alterTable"]
assert "dropPath" not in alter_only_data
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_insert_action_button_and_data():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"insert-row": {"id": "root"},
},
"column_types": {"body": "textarea"},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_action"), name="data"
)
await db.execute_write_script("""
create table items (
id integer primary key,
name text not null,
score integer default 5,
price numeric,
created text default (datetime('now')),
body text,
typeless
);
""")
response = await ds.client.get("/data/items", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.table-insert-row[data-table-action="insert-row"]'
)
assert button is not None
assert button.text.strip() == "Insert row"
assert button.find("svg") is not None
assert button.find_parent("div", class_="table-row-toolbar") is not None
insert_data = table_data_from_soup(soup)["insertRow"]
assert insert_data["path"] == "/data/items/-/insert"
assert insert_data["tableName"] == "items"
assert insert_data["primaryKeys"] == ["id"]
assert [column["name"] for column in insert_data["columns"]] == [
"name",
"score",
"price",
"created",
"body",
"typeless",
]
name, score, price, created, body, typeless = insert_data["columns"]
assert name["notnull"] == 1
assert name["sqlite_type"] == "TEXT"
assert name["value_kind"] == "string"
assert not name["has_default"]
assert score["default"] == "5"
assert score["has_default"]
assert score["sqlite_type"] == "INTEGER"
assert score["value_kind"] == "number"
assert price["sqlite_type"] == "NUMERIC"
assert price["value_kind"] == "string"
assert created["default"] == "datetime('now')"
assert created["has_default"]
assert created["sqlite_type"] == "TEXT"
assert body["sqlite_type"] == "TEXT"
assert body["value_kind"] == "string"
assert body["column_type"] == {"type": "textarea", "config": None}
assert typeless["sqlite_type"] == "BLOB"
assert typeless["value_kind"] == "string"
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_insert_action_includes_compound_primary_keys():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"memberships": {
"permissions": {
"insert-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_table_insert_compound_pk"), name="data"
)
await db.execute_write_script("""
create table memberships (
account text,
username text,
role text,
primary key (account, username)
);
""")
response = await ds.client.get("/data/memberships", actor={"id": "root"})
assert response.status_code == 200
insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[
"insertRow"
]
assert insert_data["tableName"] == "memberships"
assert insert_data["primaryKeys"] == ["account", "username"]
assert [column["name"] for column in insert_data["columns"]] == [
"account",
"username",
"role",
]
assert [column["is_pk"] for column in insert_data["columns"]] == [
True,
True,
False,
]
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_data_includes_foreign_key_autocomplete_urls():
ds = Datasette([])
try:
db = ds.add_database(
Database(ds, memory_name="test_table_foreign_key_autocomplete"), name="data"
)
await db.execute_write_script("""
create table authors (
id integer primary key,
name text
);
create table tags (
slug text unique,
name text
);
create table articles (
id integer primary key,
author_id integer references authors(id),
implicit_author_id integer references authors,
tag_slug text references tags(slug),
title text
);
insert into authors (id, name) values (1, 'Ada Lovelace');
insert into tags (slug, name) values ('science', 'Science');
insert into articles (
id,
author_id,
implicit_author_id,
tag_slug,
title
) values (
1,
1,
1,
'science',
'Notes'
);
""")
response = await ds.client.get("/data/articles")
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
table_data = table_data_from_soup(soup)
assert table_data["foreignKeys"] == {
"author_id": "/data/authors/-/autocomplete",
"implicit_author_id": "/data/authors/-/autocomplete",
}
assert any(
"autocomplete.js" in (script.get("src") or "")
for script in soup.find_all("script")
)
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_fragment_endpoint(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
soup = Soup(response.text, "html.parser")
assert soup.find("html") is None
rows = soup.select("[data-row]")
assert len(rows) == 1
assert rows[0]["data-row"] == "1"
assert rows[0]["data-row-label"] == "hello"
assert {key for key in rows[0].attrs if key.startswith("data-row")} == {
"data-row",
"data-row-label",
}
@pytest.mark.asyncio
async def test_table_fragment_row_parameter_replaces_pk_filters(ds_client):
response = await ds_client.get(
"/fixtures/simple_primary_key/-/fragment?id=2&_row=1"
)
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
rows = soup.select("[data-row]")
assert len(rows) == 1
assert rows[0]["data-row"] == "1"
assert rows[0]["data-row-label"] == "hello"
@pytest.mark.asyncio
async def test_row_page_edit_delete_action_menu_buttons():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"update-row": {"id": "root"},
"delete-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_page_edit_delete_actions"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text, score integer);
insert into items (id, name, score) values (1, 'One', 5);
""")
response = await ds.client.get("/data/items/1", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert table_data_from_soup(soup) == {
"database": "data",
"table": "items",
"tableUrl": "/data/items",
}
script_srcs = [script.get("src") or "" for script in soup.find_all("script")]
assert any("edit-tools.js" in src for src in script_srcs)
assert not any("table.js" in src for src in script_srcs)
row = soup.select_one("table.rows-and-columns tbody tr")
assert row["data-row"] == "1"
assert row["data-row-label"] == "One"
edit_button = soup.select_one(
'details.actions-menu-links button.action-menu-button[data-row-action="edit"]'
)
assert edit_button is not None
assert edit_button["aria-label"] == "Edit row 1 One"
assert edit_button["data-row"] == "1"
assert edit_button["data-row-label"] == "One"
assert edit_button["role"] == "menuitem"
assert edit_button.find("span", class_="dropdown-description").text.strip() == (
"Open a dialog to edit this row."
)
edit_button.find("span").extract()
assert edit_button.text.strip() == "Edit row"
delete_button = soup.select_one(
'details.actions-menu-links button.action-menu-button[data-row-action="delete"]'
)
assert delete_button is not None
assert delete_button["aria-label"] == "Delete row 1 One"
assert delete_button["data-row"] == "1"
assert delete_button["data-row-label"] == "One"
assert delete_button["role"] == "menuitem"
assert delete_button.find(
"span", class_="dropdown-description"
).text.strip() == ("Open a confirmation dialog to delete this row.")
delete_button.find("span").extract()
assert delete_button.text.strip() == "Delete row"
finally:
ds.close()
@pytest.mark.asyncio
async def test_row_delete_redirect_to_table_sets_message():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"delete-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_delete_redirect"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
insert into items (id, name) values (1, 'One');
""")
response = await ds.client.post(
"/data/items/1/-/delete?_redirect_to_table=1", actor={"id": "root"}
)
assert response.status_code == 200
assert response.json() == {"ok": True, "redirect": "/data/items"}
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
["Deleted row 1 (One)", ds.INFO]
]
finally:
ds.close()
@pytest.mark.asyncio
async def test_row_update_sets_message():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"update-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_update_message"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
insert into items (id, name) values (1, 'One');
""")
long_name = "Two " + ("long label " * 12)
truncated_name = long_name[:79] + "\u2026"
response = await ds.client.post(
"/data/items/1/-/update?_message=1",
actor={"id": "root"},
json={"update": {"name": long_name}, "return": True},
)
assert response.status_code == 200
assert response.json()["row"]["name"] == long_name
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
["Updated row 1 ({})".format(truncated_name), ds.INFO]
]
finally:
ds.close()
def test_table_data_uses_base_url(app_client_base_url_prefix):
response = app_client_base_url_prefix.get("/prefix/fixtures/simple_primary_key")
assert response.status_code == 200
import json
import re
soup = Soup(response.text, "html.parser")
table_script = [
s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "")
][0]
match = re.search(
r"window\._datasetteTableData\s*=\s*({.*?});",
table_script.string,
re.DOTALL,
)
assert json.loads(match.group(1)) == {
"database": "fixtures",
"table": "simple_primary_key",
"tableUrl": "/prefix/fixtures/simple_primary_key",
}
def test_table_fragment_custom_table_include():
with make_app_client(
template_dir=str(pathlib.Path(__file__).parent / "test_templates")
) as client:
response = client.get("/fixtures/complex_foreign_keys/-/fragment?f1=1&f2=2")
assert response.status == 200
assert (
'<div class="custom-table-row">'
'1 - 2 - <a href="/fixtures/simple_primary_key/1">hello</a> <em>1</em>'
"</div>"
) == str(Soup(response.text, "html.parser").select_one("div.custom-table-row"))
@pytest.mark.asyncio
async def test_table_fragment_uses_render_cell_hook():
from datasette import hookimpl
from markupsafe import Markup
class TestRenderCellPlugin:
__name__ = "TestRenderCellPlugin"
@hookimpl
def render_cell(self, value, column, table, database):
if database == "data" and table == "items" and column == "name":
return Markup("<strong>{}</strong>".format(value))
return None
ds = Datasette(memory=True)
await ds.invoke_startup()
db = ds.add_memory_database("data")
await db.execute_write("create table items (id integer primary key, name text)")
await db.execute_write("insert into items values (1, 'Alice')")
ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin")
try:
response = await ds.client.get("/data/items/-/fragment?id=1")
assert response.status_code == 200
assert "<strong>Alice</strong>" in response.text
finally:
ds.pm.unregister(name="TestRenderCellPlugin")
ds.close()
@pytest.mark.asyncio
async def test_zero_row_table_renders_thead(ds_client):
response = await ds_client.get("/fixtures/123_starts_with_digits")

View file

@ -70,6 +70,19 @@ def test_trace_query_errors():
assert trace_info["traces"][-1]["error"] == "no such table: non_existent_table"
@pytest.mark.asyncio
async def test_trace_child_tasks_resets_contextvar_on_exception():
from datasette import tracer
before = tracer.trace_task_id.get()
with pytest.raises(ValueError):
with tracer.trace_child_tasks():
assert tracer.trace_task_id.get() is not None
raise ValueError("simulated error")
# The contextvar must be reset even though the block raised
assert tracer.trace_task_id.get() == before
def test_trace_parallel_queries():
with make_app_client(settings={"trace_debug": True}) as client:
response = client.get("/parallel-queries?_trace=1")