mirror of
https://github.com/simonw/datasette.git
synced 2026-07-01 05:04:44 +02:00
Merge remote-tracking branch 'origin/main' into template-context-docs
# Conflicts: # datasette/views/row.py
This commit is contained in:
commit
49b1adba7b
71 changed files with 16280 additions and 1146 deletions
48
.github/workflows/playwright.yml
vendored
Normal file
48
.github/workflows/playwright.yml
vendored
Normal 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 }}
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
16
Justfile
16
Justfile
|
|
@ -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
|
||||
|
|
|
|||
202
datasette/app.py
202
datasette/app.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
29
datasette/default_table_actions.py
Normal file
29
datasette/default_table_actions.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
344
datasette/static/autocomplete.js
Normal file
344
datasette/static/autocomplete.js
Normal 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);
|
||||
})();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
|||
5293
datasette/static/edit-tools.js
Normal file
5293
datasette/static/edit-tools.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
78
datasette/templates/debug_autocomplete.html
Normal file
78
datasette/templates/debug_autocomplete.html
Normal 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 %}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
__version__ = "1.0a32"
|
||||
__version__ = "1.0a34"
|
||||
__version_info__ = tuple(__version__.split("."))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1353
datasette/views/table_create_alter.py
Normal file
1353
datasette/views/table_create_alter.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
707
tests/test_allowed_many.py
Normal 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
|
||||
)
|
||||
|
|
@ -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
253
tests/test_autocomplete.py
Normal 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)
|
||||
]
|
||||
}
|
||||
|
|
@ -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"},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# | | don’t match on the previous grep
|
||||
# | | | we don’t 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
|
||||
|
|
|
|||
91
tests/test_debug_autocomplete.py
Normal file
91
tests/test_debug_autocomplete.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
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
892
tests/test_playwright.py
Normal 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) == []
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue