Compare commits

..

7 commits

Author SHA1 Message Date
Claude
32be92fa24
Add declarative data-modal-cancel attribute to datasette-modal
Clicking any element inside a <datasette-modal> that carries a
data-modal-cancel attribute now calls requestClose("cancel"), so
Cancel buttons no longer need JavaScript wiring. Like other
dismissals this respects the busy property and the closeGuard hook.

The set-column-type, row-delete and create-table dialogs now use the
attribute instead of their own click listeners, and the plugin
documentation example is simplified to match (the regenerated docs
screenshot is unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 17:25:19 +00:00
Claude
14397ac4c1
Apply Black to test_playwright.py
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 17:05:06 +00:00
Claude
7d19e949dd
Increase Playwright fixture server startup timeout to 45s
Each test boots its own Datasette subprocess and the 10 second
wait_for_server timeout was too tight on loaded CI runners, causing
intermittent connection-refused errors at fixture setup (seen on the
webkit run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:49:49 +00:00
Claude
256ce15184
Fix race in mobile column actions dialog test
The dialog's aria-expanded sync on the trigger button runs from the
native dialog close event, which fires in a queued task after the
dialog is already hidden. Use a retrying expect() assertion instead of
reading the attribute immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:24:05 +00:00
Claude
967c1298ea
Add script to regenerate the datasette-modal docs screenshot
docs/generate-datasette-modal-example.sh builds a temporary demo
database and plugins directory containing the createModal() example
from the Modal dialogs documentation, starts a Datasette server, takes
the screenshot with shot-scraper, quantizes it to an 8-bit palette PNG
and stops the server again. Output is byte-identical to the committed
docs/datasette-modal-example.png.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:19:37 +00:00
Claude
5fd27d9347
Add screenshot of the datasette-modal example to the docs
Taken with shot-scraper against a local Datasette instance running the
createModal() example plugin code from the Modal dialogs docs section,
then palette-quantized to keep the file size down (87KB at 1520x920).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:16:15 +00:00
Claude
0693f2f099
Extract shared datasette-modal web component for all modal dialogs
All eight modal dialogs (create table, alter table, insert/edit row,
delete row, set column type, column chooser, mobile column actions and
the navigation jump menu) previously each implemented their own <dialog>
creation, header/footer markup, backdrop-click and Escape handling,
busy-state guards, focus restoration and near-identical frame CSS.

This extracts all of that into a new <datasette-modal> web component
(datasette/static/datasette-modal.js) that wraps a native <dialog> and
provides:

- The standard modal frame, header (title + meta chip), footer and
  button styles, distributed via a stylesheet adopted into whichever
  document or shadow root the element is connected to - so it also
  works inside the shadow DOM of column-chooser and navigation-search
- Close on backdrop click and Escape, a busy property that blocks
  dismissal during saves, and a closeGuard hook for discard-changes
  confirmation prompts
- Focus restoration to the triggering element on close
- datasette-modal-open and datasette-modal-close events
- Per-dialog sizing via --datasette-modal-width /
  --datasette-modal-max-height custom properties

The component is exposed as window.DatasetteModal and via a new
datasetteManager.createModal() method, and is documented in
docs/javascript_plugins.rst as a stable public API for plugins.

This removes roughly 1,200 lines of duplicated frame markup, event
wiring and CSS across table.js, edit-tools.js, mobile-column-actions.js,
column-chooser.js, navigation-search.js and app.css, while keeping the
existing dialog ids, class names and inner structure intact.

Also adds Playwright coverage for the column chooser, mobile column
actions and set-column-type dialogs, which previously had none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TShiUYVMmmF4zyJR6GMw34
2026-07-02 16:01:36 +00:00
111 changed files with 2669 additions and 9778 deletions

View file

@ -1,39 +0,0 @@
name: "Setup SQLite version"
description: "Build and activate a specific SQLite version from its amalgamation archive"
inputs:
version:
description: "The SQLite version to install"
required: true
cflags:
description: "CFLAGS to use when compiling SQLite"
required: false
default: ""
skip-activate:
description: "Set to true to skip modifying the library path"
required: false
default: "false"
fallback-urls:
description: "Whitespace-separated fallback download URLs to try after sqlite.org"
required: false
default: ""
outputs:
sqlite-location:
description: "Directory containing the compiled SQLite library"
value: ${{ steps.build.outputs.sqlite-location }}
runs:
using: "composite"
steps:
- shell: bash
run: mkdir -p "$RUNNER_TEMP/sqlite-versions/downloads"
- uses: actions/cache@v6
with:
path: ${{ runner.temp }}/sqlite-versions/downloads
key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1
- id: build
shell: bash
run: bash "$GITHUB_ACTION_PATH/setup-sqlite-version.sh"
env:
SQLITE_VERSION: ${{ inputs.version }}
SQLITE_CFLAGS: ${{ inputs.cflags }}
SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}
SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}

View file

@ -1,144 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}"
cflags="${SQLITE_CFLAGS:-}"
skip_activate="${SQLITE_SKIP_ACTIVATE:-false}"
extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}"
case "$version_spec" in
3.46 | 3.46.0)
sqlite_version="3.46.0"
sqlite_year="2024"
amalgamation_id="3460000"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip"
;;
3.25 | 3.25.0)
sqlite_version="3.25.0"
sqlite_year="2018"
amalgamation_id="3250000"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3250000.zip?v=1"
;;
*)
echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh."
exit 1
;;
esac
case "$(uname -s)" in
Linux)
library_name="libsqlite3.so.0"
library_path_var="LD_LIBRARY_PATH"
;;
Darwin)
library_name="libsqlite3.dylib"
library_path_var="DYLD_LIBRARY_PATH"
;;
*)
echo "::error::Unsupported platform $(uname -s)"
exit 1
;;
esac
runner_temp="${RUNNER_TEMP:-}"
if [ -z "$runner_temp" ]; then
runner_temp="$(mktemp -d)"
fi
filename="sqlite-amalgamation-${amalgamation_id}"
official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip"
download_dir="${runner_temp}/sqlite-versions/downloads"
source_root="${runner_temp}/sqlite-versions/source"
source_dir="${source_root}/${filename}"
build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}"
archive_path="${download_dir}/${filename}.zip"
mkdir -p "$download_dir" "$source_root" "$build_dir"
download_archive() {
local url
local candidate_path="${archive_path}.tmp"
local urls=("$official_url")
for url in $builtin_fallback_urls $extra_fallback_urls; do
urls+=("$url")
done
rm -f "$candidate_path"
for url in "${urls[@]}"; do
echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}"
if curl \
--fail \
--location \
--show-error \
--retry 5 \
--retry-delay 2 \
--retry-max-time 180 \
--retry-all-errors \
--connect-timeout 20 \
--max-time 240 \
--output "$candidate_path" \
"$url"; then
mv "$candidate_path" "$archive_path"
return 0
fi
echo "::warning::Download failed from ${url}"
rm -f "$candidate_path"
done
echo "::error::Could not download SQLite ${sqlite_version} amalgamation"
return 1
}
if [ ! -f "${source_dir}/sqlite3.c" ]; then
if [ ! -f "$archive_path" ]; then
download_archive
fi
rm -rf "$source_dir"
unzip -q "$archive_path" -d "$source_root"
fi
if [ ! -f "${source_dir}/sqlite3.c" ]; then
echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}"
exit 1
fi
read -r -a cflag_args <<< "$cflags"
echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}"
gcc \
-fPIC \
-shared \
"${cflag_args[@]}" \
"${source_dir}/sqlite3.c" \
"-I${source_dir}" \
-o "${build_dir}/${library_name}"
if [ "$library_name" = "libsqlite3.so.0" ]; then
ln -sf "$library_name" "${build_dir}/libsqlite3.so"
fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT"
else
echo "sqlite-location=${build_dir}"
fi
case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in
true | 1 | yes)
echo "Skipping ${library_path_var} activation"
;;
*)
existing_value="${!library_path_var:-}"
if [ -n "${GITHUB_ENV:-}" ]; then
if [ -n "$existing_value" ]; then
echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV"
else
echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV"
fi
fi
echo "Added ${build_dir} to ${library_path_var}"
;;
esac

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

@ -0,0 +1,16 @@
name: Read the Docs Pull Request Preview
on:
pull_request:
types:
- opened
permissions:
pull-requests: write
jobs:
documentation-links:
runs-on: ubuntu-latest
steps:
- uses: readthedocs/actions/preview@v1
with:
project-slug: "datasette"

View file

@ -16,7 +16,7 @@ jobs:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
@ -25,14 +25,14 @@ jobs:
cache: pip
cache-dependency-path: pyproject.toml
- name: Cache uv
uses: actions/cache@v6
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@v6
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}

View file

@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Configure npm caching
with:
path: ~/.npm

View file

@ -14,7 +14,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -35,7 +35,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -56,7 +56,7 @@ jobs:
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -92,7 +92,7 @@ jobs:
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Build and push to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -13,7 +13,7 @@ jobs:
deploy_docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Build and push to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -9,7 +9,7 @@ jobs:
spellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # We need all commits to find docs/ changes
- name: Set up Git user

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

@ -12,7 +12,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
with:
@ -20,7 +20,7 @@ jobs:
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- name: Cache Playwright browsers
uses: actions/cache@v6
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers

View file

@ -25,7 +25,7 @@ jobs:
#"3.23.1" # 2018-04-10, before UPSERT
]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -34,7 +34,7 @@ jobs:
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up SQLite ${{ matrix.sqlite-version }}
uses: ./.github/actions/setup-sqlite-version
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
with:
version: ${{ matrix.sqlite-version }}
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"

View file

@ -13,7 +13,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:

View file

@ -10,6 +10,6 @@ jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3

View file

@ -11,7 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
env:

View file

@ -33,11 +33,10 @@ export DATASETTE_SECRET := "not_a_secret"
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
# Run linters: black, ruff, prettier, cog
# Run linters: black, ruff, cog
@lint: codespell
uv run black datasette tests --check
uv run ruff check datasette tests
npm run prettier -- --check
uv run cog --check README.md docs/*.rst
# Apply ruff fixes

View file

@ -1,14 +1,8 @@
from datasette.permissions import Permission # noqa
from datasette.version import __version_info__, __version__ # noqa
from datasette.events import Event # noqa
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
from datasette.utils.asgi import ( # noqa
Forbidden,
NotFound,
PayloadTooLarge,
Request,
Response,
)
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
from datasette.utils import actor_matches_allow # noqa
from datasette.views import Context # noqa
from .hookspecs import hookimpl # noqa

View file

@ -111,7 +111,6 @@ from .utils import (
baseconv,
call_with_supported_arguments,
detect_json1,
add_cors_headers,
display_actor,
escape_css_string,
escape_sqlite,
@ -131,10 +130,8 @@ from .utils import (
redact_keys,
row_sql_params_pks,
)
from .tokens import TokenInvalid
from .utils.asgi import (
AsgiLifespan,
BadRequest,
Forbidden,
NotFound,
DatabaseNotFound,
@ -209,11 +206,6 @@ SETTINGS = (
100,
"Maximum rows that can be inserted at a time using the bulk insert API",
),
Setting(
"max_post_body_bytes",
2 * 1024 * 1024,
"Maximum size in bytes for a POST body read into memory, e.g. JSON API requests - set 0 to disable this limit",
),
Setting(
"num_sql_threads",
3,
@ -753,7 +745,19 @@ class Datasette:
# Compare schema versions to see if we should skip it
if schema_version == current_schema_versions.get(database_name):
continue
await populate_schema_tables(internal_db, db, schema_version)
placeholders = "(?, ?, ?, ?)"
values = [database_name, str(db.path), db.is_memory, schema_version]
if db.path is None:
placeholders = "(?, null, ?, ?)"
values = [database_name, db.is_memory, schema_version]
await internal_db.execute_write(
"""
INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)
VALUES {}
""".format(placeholders),
values,
)
await populate_schema_tables(internal_db, db)
@property
def urls(self):
@ -901,9 +905,7 @@ class Datasette:
Verify an API token by trying all registered token handlers.
Returns an actor dict from the first handler that recognizes the
token, or None if no handler accepts it. A handler may raise
TokenInvalid for a token it recognizes but rejects (bad signature,
expired) - Datasette turns that into a 401 response.
token, or None if no handler accepts it.
"""
for token_handler in self._token_handlers():
result = await token_handler.verify_token(self, token)
@ -2166,18 +2168,6 @@ class Datasette:
for name, d in self.databases.items()
]
async def _connected_databases_for_actor(self, actor):
page = await self.allowed_resources("view-database", actor)
allowed_names = {resource.parent async for resource in page.all()}
return [
database
for database in self._connected_databases()
if database["name"] in allowed_names
]
async def _databases_data(self, request):
return {"databases": await self._connected_databases_for_actor(request.actor)}
def _versions(self):
conn = sqlite3.connect(":memory:")
self._prepare_connection(conn, "_memory")
@ -2524,8 +2514,8 @@ class Datasette:
def add_route(view, regex):
routes.append((regex, view))
add_route(IndexView.as_view(self), r"/(\.(?P<format>json))?$")
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>json))?$")
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
add_route(permanent_redirect("/-/"), r"/-$")
add_route(favicon, "/favicon.ico")
@ -2561,10 +2551,7 @@ class Datasette:
)
add_route(
JsonDataView.as_view(
self,
"plugins.json",
self._plugins,
needs_request=True,
self, "plugins.json", self._plugins, needs_request=True
),
r"/-/plugins(\.(?P<format>json))?$",
)
@ -2577,18 +2564,11 @@ class Datasette:
r"/-/config(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(
self, "threads.json", self._threads, permission="permissions-debug"
),
JsonDataView.as_view(self, "threads.json", self._threads),
r"/-/threads(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(
self,
"databases.json",
self._databases_data,
needs_request=True,
),
JsonDataView.as_view(self, "databases.json", self._connected_databases),
r"/-/databases(\.(?P<format>json))?$",
)
add_route(
@ -2601,7 +2581,7 @@ class Datasette:
JsonDataView.as_view(
self,
"actions.json",
lambda: {"actions": self._actions()},
self._actions,
template="debug_actions.html",
permission="permissions-debug",
),
@ -2809,10 +2789,6 @@ class Datasette:
db, table_name, _ = await self.resolve_table(request)
pk_values = urlsafe_components(request.url_vars["pks"])
sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)
if len(pk_values) != len(pks):
raise BadRequest(
"URL row identifier does not match the primary key for this table"
)
results = await db.execute(sql, params, truncate=True)
row = results.first()
if row is None:
@ -2871,11 +2847,7 @@ class DatasetteRouter:
if base_url != "/" and path.startswith(base_url):
path = "/" + path[len(base_url) :]
scope = dict(scope, route_path=path)
request = Request(
scope,
receive,
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
)
request = Request(scope, receive)
# Populate request_messages if ds_messages cookie is present
try:
request._messages = self.ds.unsign(
@ -2895,24 +2867,13 @@ class DatasetteRouter:
# Handle authentication
default_actor = scope.get("actor") or None
actor = None
token_error = None
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
for result in results:
try:
result = await await_me_maybe(result)
except TokenInvalid as ex:
# A presented token was recognized but rejected - fail the
# request with a 401 even if another credential is valid,
# but keep awaiting the remaining coroutines first
if token_error is None:
token_error = ex
continue
result = await await_me_maybe(result)
if result and actor is None:
actor = result
# Don't break — we must await all coroutines to avoid
# "coroutine was never awaited" warnings
if token_error is not None:
return await self.handle_401(request, send, token_error)
scope_modifications["actor"] = actor or default_actor
scope = dict(scope, **scope_modifications)
@ -2944,15 +2905,6 @@ class DatasetteRouter:
except Exception as exception:
return await self.handle_exception(request, send, exception)
async def handle_401(self, request, send, exception):
# A presented bearer token was recognized by a handler but rejected.
# Bearer tokens are API credentials, so this is always JSON.
headers = {"www-authenticate": 'Bearer error="invalid_token"'}
if self.ds.cors:
add_cors_headers(headers)
response = Response.error([str(exception)], 401, headers=headers)
await response.asgi_send(send)
async def handle_404(self, request, send, exception=None):
# If path contains % encoding, redirect to tilde encoding
if "%" in request.path:

View file

@ -17,7 +17,6 @@ from .utils import (
detect_fts,
detect_primary_keys,
detect_spatialite,
escape_sqlite,
get_all_foreign_keys,
get_outbound_foreign_keys,
md5_not_usedforsecurity,
@ -247,7 +246,6 @@ class Database:
request=None,
return_all=False,
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
transaction=True,
):
self._check_not_closed()
if returning_limit < 0:
@ -260,9 +258,7 @@ class Database:
)
with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn(
_inner, block=block, request=request, transaction=transaction
)
results = await self.execute_write_fn(_inner, block=block, request=request)
return results
async def execute_write_script(self, sql, block=True, request=None):
@ -352,7 +348,6 @@ class Database:
self.ds._prepare_connection(self._write_connection, self.name)
if transaction:
with self._write_connection:
self._write_connection.execute("BEGIN IMMEDIATE")
result = fn(self._write_connection)
else:
result = fn(self._write_connection)
@ -482,7 +477,6 @@ class Database:
try:
if task.transaction:
with conn:
conn.execute("BEGIN IMMEDIATE")
result = task.fn(conn)
else:
result = task.fn(conn)
@ -609,7 +603,7 @@ class Database:
try:
table_count = (
await self.execute(
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
custom_time_limit=limit,
)
).rows[0][0]

View file

@ -61,12 +61,6 @@ def register_actions():
description="Create tables",
resource_class=DatabaseResource,
),
Action(
name="create-view",
abbr="cv",
description="Create views",
resource_class=DatabaseResource,
),
Action(
name="store-query",
abbr="sq",
@ -117,12 +111,6 @@ def register_actions():
description="Drop tables",
resource_class=TableResource,
),
Action(
name="drop-view",
abbr="dv",
description="Drop views",
resource_class=TableResource,
),
# Query-level actions (child-level)
Action(
name="view-query",

View file

@ -96,10 +96,6 @@ class ConfigPermissionProcessor:
"""Evaluate an allow block against the current actor."""
if allow_block is None:
return None
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
# integers, but should retain the CLI's boolean 1/0 behavior.
if isinstance(allow_block, int) and allow_block in (0, 1):
return bool(allow_block)
return actor_matches_allow(self.actor, allow_block)
def is_in_restriction_allowlist(

View file

@ -5,8 +5,6 @@ from typing import ClassVar
from asyncinject import Registry
from datasette.utils.asgi import BadRequest
def extra_names_from_request(request):
extra_bits = request.args.getlist("_extra")
@ -115,17 +113,6 @@ class ExtraRegistry:
self._allowed_names[key] = names
return names
def validate_requested(self, requested, scope):
"""
Raise BadRequest if any requested extra name is not a public extra
for this scope. Used by data formats such as .json - HTML pages
silently ignore unknown names instead.
"""
allowed = self._allowed_names_for_scope(scope, include_internal=False)
unknown = sorted(name for name in requested if name not in allowed)
if unknown:
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
async def resolve(self, requested, context, scope, include_internal=False):
allowed_names = self._allowed_names_for_scope(scope, include_internal)
requested_names = [name for name in requested if name in allowed_names]

View file

@ -85,7 +85,7 @@ class Facet:
self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries:
self.table = table
self.sql = sql or f"select * from {escape_sqlite(table)}"
self.sql = sql or f"select * from [{table}]"
self.params = params or []
self.table_config = table_config
# row_count can be None, in which case we calculate it ourselves:

View file

@ -1,19 +1,9 @@
from datasette import hookimpl, Response
from .utils import add_cors_headers
@hookimpl(trylast=True)
def forbidden(datasette, request, message):
async def inner():
if (
request.path.split("?")[0].endswith(".json")
or "application/json" in (request.headers.get("accept") or "")
or request.headers.get("content-type") == "application/json"
):
headers = {}
if datasette.cors:
add_cors_headers(headers)
return Response.error(message, 403, headers=headers)
return Response.html(
await datasette.render_template(
"error.html",

View file

@ -1,5 +1,5 @@
from datasette import hookimpl, Response
from .utils import add_cors_headers, error_body
from .utils import add_cors_headers
from .utils.asgi import (
Base400,
)
@ -28,7 +28,6 @@ def handle_exception(datasette, request, exception):
rich.get_console().print_exception(show_locals=True)
title = None
plain_message = None
if isinstance(exception, Base400):
status = exception.status
info = {}
@ -37,7 +36,6 @@ def handle_exception(datasette, request, exception):
status = exception.status
info = exception.error_dict
message = exception.message
plain_message = exception.plain_message
if exception.message_is_html:
message = Markup(message)
title = exception.title
@ -47,13 +45,6 @@ def handle_exception(datasette, request, exception):
message = str(exception)
traceback.print_exc()
templates = [f"{status}.html", "error.html"]
headers = {}
if datasette.cors:
add_cors_headers(headers)
if request.path.split("?")[0].endswith(".json"):
body = dict(info)
body.update(error_body(plain_message or message, status))
return Response.json(body, status=status, headers=headers)
info.update(
{
"ok": False,
@ -62,18 +53,24 @@ def handle_exception(datasette, request, exception):
"title": title,
}
)
environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html(
await template.render_async(
dict(
info,
urls=datasette.urls,
menu_links=lambda: [],
)
),
status=status,
headers=headers,
)
headers = {}
if datasette.cors:
add_cors_headers(headers)
if request.path.split("?")[0].endswith(".json"):
return Response.json(info, status=status, headers=headers)
else:
environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html(
await template.render_async(
dict(
info,
urls=datasette.urls,
menu_links=lambda: [],
)
),
status=status,
headers=headers,
)
return inner

View file

@ -1,7 +1,6 @@
import json
from datasette.extras import extra_names_from_request
from datasette.utils import (
error_body,
value_as_boolean,
remove_infinites,
CustomJSONEncoder,
@ -53,7 +52,8 @@ def json_renderer(request, args, data, error, truncated=None):
if error:
shape = "objects"
status_code = 400
data.update(error_body(error, status_code))
data["error"] = error
data["ok"] = False
if truncated is not None:
data["truncated"] = truncated
@ -87,8 +87,7 @@ def json_renderer(request, args, data, error, truncated=None):
object_rows[pk_string] = row
data = object_rows
if shape_error:
status_code = 400
data = error_body(shape_error, status_code)
data = {"ok": False, "error": shape_error}
elif shape == "array":
data = data["rows"]
@ -101,7 +100,12 @@ def json_renderer(request, args, data, error, truncated=None):
data["rows"] = [list(row.values()) for row in data["rows"]]
else:
status_code = 400
data = error_body(f"Invalid _shape: {shape}", status_code)
data = {
"ok": False,
"error": f"Invalid _shape: {shape}",
"status": 400,
"title": None,
}
# Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136

File diff suppressed because it is too large Load diff

View file

@ -41,76 +41,22 @@ class ColumnChooser extends HTMLElement {
* { box-sizing: border-box; margin: 0; padding: 0; }
dialog {
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: 100%;
max-width: 420px;
max-height: min(640px, calc(100vh - 32px));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
/* Frame styles come from the shared <datasette-modal> component */
datasette-modal {
--datasette-modal-width: min(420px, 95vw);
--datasette-modal-max-height: min(640px, calc(100vh - 32px));
}
datasette-modal > dialog {
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
dialog[open] {
display: flex;
flex-direction: column;
datasette-modal > dialog[open] {
height: min(640px, calc(100vh - 32px));
}
dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.modal-title {
font-size: 1rem;
font-weight: 600;
}
.modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
}
.list-toolbar {
padding: 6px 24px;
border-bottom: 1px solid var(--rule);
@ -299,48 +245,6 @@ class ColumnChooser extends HTMLElement {
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: #1448c0; }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.btn-ghost:hover { background: var(--rule); color: var(--ink); }
.list-wrap::-webkit-scrollbar { width: 5px; }
.list-wrap::-webkit-scrollbar-track { background: transparent; }
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
@ -348,11 +252,7 @@ class ColumnChooser extends HTMLElement {
input, textarea { -webkit-user-select: auto; user-select: auto; }
</style>
<dialog aria-labelledby="modalTitle">
<div class="modal-header">
<span class="modal-title" id="modalTitle">Choose columns</span>
<span class="modal-meta" id="selectedCount"></span>
</div>
<datasette-modal modal-title="Choose columns">
<div class="list-toolbar">
<button id="selectAllBtn">Select all</button>
<button id="deselectAllBtn">Deselect all</button>
@ -367,11 +267,11 @@ class ColumnChooser extends HTMLElement {
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn btn-primary" id="applyBtn">Apply</button>
</div>
</dialog>
</datasette-modal>
`;
// DOM refs
this._dialog = this.shadowRoot.querySelector("dialog");
this._modal = this.shadowRoot.querySelector("datasette-modal");
this._listWrap = this.shadowRoot.getElementById("listWrap");
this._dragList = this.shadowRoot.getElementById("dragList");
this._pulseTop = this.shadowRoot.getElementById("pulseTop");
@ -380,20 +280,16 @@ class ColumnChooser extends HTMLElement {
this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn");
this._cancelBtn = this.shadowRoot.getElementById("cancelBtn");
this._applyBtn = this.shadowRoot.getElementById("applyBtn");
this._countEl = this.shadowRoot.getElementById("selectedCount");
this._footerEl = this.shadowRoot.getElementById("footerInfo");
// Event listeners
// Event listeners - dismissal (backdrop click, Escape) is handled
// by the <datasette-modal> component
this._selectAllBtn.addEventListener("click", () => this._selectAll());
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
this._cancelBtn.addEventListener("click", () => this._close());
this._applyBtn.addEventListener("click", () => this._apply());
this._dialog.addEventListener("click", (e) => {
if (e.target === this._dialog) this._close();
});
this._dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this._close();
this._modal.addEventListener("datasette-modal-close", () => {
this._restoreSavedState();
});
}
@ -405,6 +301,10 @@ class ColumnChooser extends HTMLElement {
* @param {function(string[]): void} opts.onApply - Called with the selected columns in order when Apply is clicked.
*/
open({ columns, selected = [], onApply }) {
if (!this._modal.dialog) {
// datasette-modal.js is missing or <dialog> is unsupported
return;
}
this._items = [...columns];
this._checked = new Set(selected);
this._onApply = onApply || null;
@ -414,17 +314,20 @@ class ColumnChooser extends HTMLElement {
this._savedChecked = new Set(this._checked);
this._render();
this._dialog.showModal();
this._modal.showModal();
}
// ── Internal methods ──
_close() {
_restoreSavedState() {
this._items = this._savedItems ? [...this._savedItems] : this._items;
this._checked = this._savedChecked
? new Set(this._savedChecked)
: this._checked;
this._dialog.close();
}
_close() {
this._modal.close();
}
_selectAll() {
@ -445,7 +348,7 @@ class ColumnChooser extends HTMLElement {
_apply() {
const selected = this._items.filter((col) => this._checked.has(col));
this._dialog.close();
this._modal.close();
if (this._onApply) {
this._onApply(selected);
}
@ -493,7 +396,7 @@ class ColumnChooser extends HTMLElement {
_updateCounts() {
const n = this._checked.size;
this._countEl.textContent = `${n} of ${this._items.length} selected`;
this._modal.setMeta(`${n} of ${this._items.length} selected`);
this._footerEl.textContent = `${this._items.length} columns`;
}

View file

@ -215,6 +215,21 @@ const datasetteManager = {
});
},
/**
* Create a <datasette-modal> element, append it to the document and
* return it. A convenience wrapper for window.DatasetteModal.create() -
* see the "Modal dialogs" section of the JavaScript plugins
* documentation for the supported options.
*
* Returns null in browsers without <dialog> support.
*/
createModal: (options) => {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
return window.DatasetteModal.create(options);
},
/** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */
selectors: DOM_SELECTORS,

View file

@ -0,0 +1,584 @@
/**
* <datasette-modal> is Datasette's shared modal dialog Web Component.
*
* This element, and the DatasetteModal class exposed as
* window.DatasetteModal, are part of Datasette's public JavaScript API
* for plugins. See the "Modal dialogs" section of the JavaScript
* plugins documentation.
*
* The component wraps a native <dialog> element and provides:
*
* - The standard Datasette modal frame: sizing, rounded corners,
* backdrop, animations, and optional .modal-header scaffolding
* - Close-on-backdrop-click and Escape key handling
* - Declarative cancel buttons: clicking any element inside the modal
* with a `data-modal-cancel` attribute calls requestClose("cancel")
* - A `busy` property that blocks user-initiated dismissal while an
* operation is in flight
* - A `closeGuard` hook for "discard unsaved changes?" style prompts
* - Focus restoration to the triggering element on close
* - `datasette-modal-open` and `datasette-modal-close` events
*
* Markup structure once connected:
*
* <datasette-modal modal-title="Example">
* <dialog id class aria-labelledby>
* <div class="modal-header">
* <span class="modal-title">Example</span>
* <span class="modal-meta" hidden></span>
* </div>
* ...consumer content, typically ending in a .modal-footer...
* </dialog>
* </datasette-modal>
*
* The component uses light DOM so page CSS and plugins can style the
* dialog contents. The shared frame styles are distributed via a
* stylesheet that the component adopts into whatever document or
* shadow root it is connected to, which means the component also
* works inside the shadow DOM of other web components.
*/
(function () {
var FRAME_CSS = `
datasette-modal {
display: contents;
}
@keyframes datasette-modal-slide-in {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes datasette-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
datasette-modal > dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--datasette-modal-border-radius, var(--modal-border-radius, 0.75rem));
padding: 0;
margin: auto;
width: var(--datasette-modal-width, min(520px, calc(100vw - 32px)));
max-width: 95vw;
max-height: var(--datasette-modal-max-height, min(720px, calc(100vh - 32px)));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
color: var(--ink);
}
datasette-modal > dialog[open] {
display: flex;
flex-direction: column;
}
datasette-modal > dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
datasette-modal .modal-header {
padding: 20px 24px 14px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
datasette-modal .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
datasette-modal .modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
flex-shrink: 0;
}
datasette-modal .modal-meta[hidden] {
display: none;
}
datasette-modal .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
datasette-modal .modal-footer [hidden] {
display: none;
}
datasette-modal .footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
datasette-modal .btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
datasette-modal .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
datasette-modal .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
datasette-modal .btn-primary {
background: var(--accent);
color: #fff;
}
datasette-modal .btn-primary:hover {
background: #1949b8;
}
datasette-modal .btn-primary:disabled,
datasette-modal .btn-primary:disabled:hover {
background: #a0aec0;
color: #fff;
}
datasette-modal .btn-danger {
background: #b91c1c;
color: #fff;
margin-right: auto;
}
datasette-modal .btn-danger:hover {
background: #991b1b;
}
datasette-modal .btn-danger:disabled,
datasette-modal .btn-danger:disabled:hover {
background: #d98c8c;
color: #fff;
}
datasette-modal .btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (max-width: 640px) {
datasette-modal > dialog {
width: var(--datasette-modal-small-screen-width, 95vw);
max-height: var(--datasette-modal-small-screen-max-height, 85vh);
border-radius: 0.5rem;
}
datasette-modal .modal-header {
padding-left: 18px;
padding-right: 18px;
}
datasette-modal .modal-footer {
padding-left: 18px;
padding-right: 18px;
}
}
`;
var sharedFrameSheet = null;
var styledRoots = new WeakSet();
var titleIdCounter = 0;
/* Make the shared frame styles available in a document or shadow root */
function adoptFrameStyles(rootNode) {
if (!rootNode || styledRoots.has(rootNode)) {
return;
}
styledRoots.add(rootNode);
if (
typeof CSSStyleSheet !== "undefined" &&
"adoptedStyleSheets" in rootNode
) {
try {
if (!sharedFrameSheet) {
sharedFrameSheet = new CSSStyleSheet();
sharedFrameSheet.replaceSync(FRAME_CSS);
}
rootNode.adoptedStyleSheets =
rootNode.adoptedStyleSheets.concat(sharedFrameSheet);
return;
} catch (_error) {
// Fall back to a <style> element below
}
}
var style = document.createElement("style");
style.setAttribute("data-datasette-modal", "");
style.textContent = FRAME_CSS;
(rootNode.head || rootNode).appendChild(style);
}
class DatasetteModal extends HTMLElement {
static get observedAttributes() {
return ["modal-title", "modal-meta"];
}
/** True if the browser supports everything the component needs */
static get supported() {
return typeof window.HTMLDialogElement !== "undefined";
}
/**
* Create a <datasette-modal>, append it to the document (or
* options.parent) and return it. Returns null in browsers without
* <dialog> support.
*
* Options:
* - id: id attribute for the inner <dialog>
* - className: class attribute for the inner <dialog>
* - title: text for the standard header title (omit for no header)
* - meta: text for the header meta chip
* - titleId: id for the title element (defaults to "<id>-title")
* - labelledBy: aria-labelledby override for the dialog
* - describedBy: aria-describedby for the dialog
* - content: HTML string or DOM node placed after the header
* - parent: element to append to (defaults to document.body)
*/
static create(options) {
options = options || {};
if (!DatasetteModal.supported) {
return null;
}
var modal = document.createElement("datasette-modal");
if (options.id) {
modal.setAttribute("dialog-id", options.id);
}
if (options.className) {
modal.setAttribute("dialog-class", options.className);
}
if (options.title !== undefined && options.title !== null) {
modal.setAttribute("modal-title", options.title);
}
if (options.meta !== undefined && options.meta !== null) {
modal.setAttribute("modal-meta", options.meta);
}
if (options.titleId) {
modal.setAttribute("title-id", options.titleId);
}
if (options.labelledBy) {
modal.setAttribute("labelled-by", options.labelledBy);
}
if (options.describedBy) {
modal.setAttribute("described-by", options.describedBy);
}
if (options.content !== undefined && options.content !== null) {
if (typeof options.content === "string") {
modal.innerHTML = options.content;
} else {
modal.appendChild(options.content);
}
}
(options.parent || document.body).appendChild(modal);
return modal;
}
constructor() {
super();
this._dialog = null;
this._titleElement = null;
this._metaElement = null;
this._trigger = null;
this._restoreFocus = true;
this._busy = false;
/**
* Optional function called with a reason string ("escape",
* "backdrop" or the reason passed to requestClose()) when the
* user tries to dismiss the modal. Return false to keep the
* modal open. Not called for direct close() calls.
*/
this.closeGuard = null;
}
connectedCallback() {
adoptFrameStyles(this.getRootNode());
this._build();
}
attributeChangedCallback(name) {
if (!this._dialog) {
return;
}
if (name === "modal-title" && this._titleElement) {
this._titleElement.textContent = this.getAttribute("modal-title") || "";
}
if (name === "modal-meta" && this._metaElement) {
this._syncMeta();
}
}
/** The underlying HTMLDialogElement, or null if unsupported */
get dialog() {
return this._dialog;
}
/** True if the modal is currently open */
get open() {
return !!(this._dialog && this._dialog.open);
}
/** The .modal-title element, or null if there is no header */
get titleElement() {
return this._titleElement;
}
/** The .modal-meta element, or null if there is no header */
get metaElement() {
return this._metaElement;
}
/**
* While true, Escape, backdrop clicks and requestClose() will not
* close the modal. Use this while a save or delete is in flight.
*/
get busy() {
return this._busy;
}
set busy(value) {
this._busy = !!value;
this.toggleAttribute("busy", this._busy);
}
/** Set the header title text */
setTitle(text) {
this.setAttribute("modal-title", text == null ? "" : text);
}
/** Set the header meta chip text - blank hides the chip */
setMeta(text) {
this.setAttribute("modal-meta", text == null ? "" : text);
}
/**
* Open the modal. Records options.trigger (defaults to the
* currently focused element) so focus can be restored on close.
*/
showModal(options) {
options = options || {};
if (!this.isConnected) {
document.body.appendChild(this);
}
if (!this._dialog) {
return;
}
if (options.trigger !== undefined) {
this._trigger = options.trigger;
} else if (!this._dialog.open) {
var active = document.activeElement;
this._trigger = active && active !== document.body ? active : null;
}
this._restoreFocus = true;
if (!this._dialog.open) {
this._dialog.showModal();
this.dispatchEvent(
new CustomEvent("datasette-modal-open", {
bubbles: true,
composed: true,
}),
);
}
}
/**
* Close the modal unconditionally, skipping busy and closeGuard.
* Pass {restoreFocus: false} to leave focus where it is.
*/
close(options) {
options = options || {};
if (!this._dialog) {
return;
}
this._restoreFocus = options.restoreFocus !== false;
if (this._dialog.open) {
this._dialog.close();
}
}
/**
* Ask the modal to close on the user's behalf. Does nothing while
* busy, and consults closeGuard if one is set. Returns true if the
* modal was closed.
*/
requestClose(reason) {
if (!this._dialog || !this._dialog.open || this._busy) {
return false;
}
if (
typeof this.closeGuard === "function" &&
!this.closeGuard(reason || "dismiss")
) {
return false;
}
this.close();
return true;
}
_build() {
if (this._dialog || !DatasetteModal.supported) {
return;
}
var dialog = document.createElement("dialog");
var dialogId = this.getAttribute("dialog-id");
if (dialogId) {
dialog.id = dialogId;
}
var dialogClass = this.getAttribute("dialog-class");
if (dialogClass) {
dialog.className = dialogClass;
}
// Move any existing light DOM content into the dialog
var content = document.createDocumentFragment();
while (this.firstChild) {
content.appendChild(this.firstChild);
}
if (this.hasAttribute("modal-title")) {
var header = document.createElement("div");
header.className = "modal-header";
this._titleElement = document.createElement("span");
this._titleElement.className = "modal-title";
this._titleElement.id =
this.getAttribute("title-id") ||
(dialogId
? dialogId + "-title"
: "datasette-modal-title-" + ++titleIdCounter);
this._titleElement.textContent = this.getAttribute("modal-title");
this._metaElement = document.createElement("span");
this._metaElement.className = "modal-meta";
header.appendChild(this._titleElement);
header.appendChild(this._metaElement);
dialog.appendChild(header);
this._syncMeta();
}
var labelledBy =
this.getAttribute("labelled-by") ||
(this._titleElement ? this._titleElement.id : null);
if (labelledBy) {
dialog.setAttribute("aria-labelledby", labelledBy);
}
var describedBy = this.getAttribute("described-by");
if (describedBy) {
dialog.setAttribute("aria-describedby", describedBy);
}
dialog.appendChild(content);
this.appendChild(dialog);
this._dialog = dialog;
dialog.addEventListener("click", (ev) => {
if (ev.target === dialog) {
this.requestClose("backdrop");
return;
}
// Declarative cancel buttons: any element inside the modal
// with a data-modal-cancel attribute requests a close
var cancelTrigger =
ev.target.closest && ev.target.closest("[data-modal-cancel]");
if (cancelTrigger && dialog.contains(cancelTrigger)) {
this.requestClose("cancel");
}
});
dialog.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape") {
return;
}
ev.preventDefault();
this.requestClose("escape");
});
dialog.addEventListener("cancel", (ev) => {
ev.preventDefault();
this.requestClose("escape");
});
dialog.addEventListener("close", () => {
var restoreFocus = this._restoreFocus;
this._restoreFocus = true;
if (
restoreFocus &&
this._trigger &&
this._trigger.isConnected &&
typeof this._trigger.focus === "function"
) {
this._trigger.focus();
}
this.dispatchEvent(
new CustomEvent("datasette-modal-close", {
bubbles: true,
composed: true,
}),
);
});
}
_syncMeta() {
var meta = this.getAttribute("modal-meta") || "";
this._metaElement.textContent = meta;
this._metaElement.hidden = meta === "";
}
}
customElements.define("datasette-modal", DatasetteModal);
window.DatasetteModal = DatasetteModal;
})();

File diff suppressed because it is too large Load diff

View file

@ -54,7 +54,8 @@ function initMobileColumnActions(manager) {
if (
!window.URLSearchParams ||
!window.HTMLDialogElement ||
!window.DatasetteModal ||
!window.DatasetteModal.supported ||
!manager.columnActions
) {
triggerButton.style.display = "none";
@ -66,32 +67,28 @@ function initMobileColumnActions(manager) {
return;
}
var dialog = document.createElement("dialog");
dialog.className = "mobile-column-actions-dialog";
dialog.id = MOBILE_COLUMN_DIALOG_ID;
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
<span class="modal-meta"></span>
</div>
var modal = window.DatasetteModal.create({
id: MOBILE_COLUMN_DIALOG_ID,
className: "mobile-column-actions-dialog",
title: "Column actions",
titleId: MOBILE_COLUMN_DIALOG_TITLE_ID,
content: `
<div class="list-wrap mobile-column-list"></div>
<div class="modal-footer">
<span class="footer-info">Tap a column to reveal actions.</span>
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
</div>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
triggerButton.setAttribute("aria-haspopup", "dialog");
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
triggerButton.setAttribute("aria-expanded", "false");
var countEl = dialog.querySelector(".modal-meta");
var listWrap = dialog.querySelector(".mobile-column-list");
var doneButton = dialog.querySelector(".mobile-column-actions-done");
var expandedSectionId = null;
var shouldRestoreFocus = true;
function updateExpandedSection() {
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
@ -129,12 +126,12 @@ function initMobileColumnActions(manager) {
function closeDialog(options) {
options = options || {};
shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
var restoreFocus = options.restoreFocus !== false;
if (modal.open) {
modal.close({ restoreFocus: restoreFocus });
} else {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
if (restoreFocus) {
triggerButton.focus();
}
}
@ -156,9 +153,7 @@ function initMobileColumnActions(manager) {
expandedSectionId = null;
}
countEl.textContent = `${headers.length} column${
headers.length === 1 ? "" : "s"
}`;
modal.setMeta(`${headers.length} column${headers.length === 1 ? "" : "s"}`);
listWrap.innerHTML = "";
if (manager.columnActions.shouldShowShowAllColumns()) {
@ -265,9 +260,7 @@ function initMobileColumnActions(manager) {
if (!renderDialog()) {
return;
}
if (!dialog.open) {
dialog.showModal();
}
modal.showModal({ trigger: triggerButton });
triggerButton.setAttribute("aria-expanded", "true");
var focusTarget =
dialog.querySelector(".mobile-column-top-action") ||
@ -277,7 +270,7 @@ function initMobileColumnActions(manager) {
}
triggerButton.addEventListener("click", function () {
if (dialog.open) {
if (modal.open) {
closeDialog();
} else {
openDialog();
@ -288,26 +281,12 @@ function initMobileColumnActions(manager) {
closeDialog();
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeDialog();
}
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
closeDialog();
});
dialog.addEventListener("close", function () {
modal.addEventListener("datasette-modal-close", function () {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
});
window.addEventListener("resize", function () {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && modal.open) {
closeDialog({ restoreFocus: false });
}
});

View file

@ -16,7 +16,6 @@ class NavigationSearch extends HTMLElement {
this.renderedMatches = [];
this.debounceTimer = null;
this.restoreFocusTarget = null;
this.shouldRestoreFocus = true;
this.render();
this.setupEventListeners();
@ -29,38 +28,10 @@ class NavigationSearch extends HTMLElement {
display: contents;
}
dialog {
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
max-width: 90vw;
width: 600px;
max-height: 80vh;
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
}
dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
/* Frame styles come from the shared <datasette-modal> component */
datasette-modal {
--datasette-modal-width: min(600px, 90vw);
--datasette-modal-max-height: 80vh;
}
.search-container {
@ -255,12 +226,6 @@ class NavigationSearch extends HTMLElement {
/* Mobile optimizations */
@media (max-width: 640px) {
dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.search-input-wrapper {
padding: 1rem;
}
@ -280,7 +245,7 @@ class NavigationSearch extends HTMLElement {
}
</style>
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
<datasette-modal labelled-by="${this.titleId}">
<div class="search-container">
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
@ -309,12 +274,12 @@ class NavigationSearch extends HTMLElement {
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</dialog>
</datasette-modal>
`;
this._modal = this.shadowRoot.querySelector("datasette-modal");
}
setupEventListeners() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
const closeButton = this.shadowRoot.querySelector(".close-search");
const resultsContainer =
@ -322,7 +287,7 @@ class NavigationSearch extends HTMLElement {
// Global keyboard listener for "/"
document.addEventListener("keydown", (e) => {
if (e.key === "/" && !this.isInputFocused() && !dialog.open) {
if (e.key === "/" && !this.isInputFocused() && !this._modal.open) {
e.preventDefault();
this.openMenu();
}
@ -380,19 +345,8 @@ class NavigationSearch extends HTMLElement {
}
});
// Close on backdrop click
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
this.closeMenu();
}
});
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this.closeMenu();
});
dialog.addEventListener("close", () => {
// Backdrop clicks and the Escape key are handled by <datasette-modal>
this._modal.addEventListener("datasette-modal-close", () => {
this.onMenuClosed();
});
@ -465,18 +419,17 @@ class NavigationSearch extends HTMLElement {
}
updateComboboxState() {
const dialog = this.shadowRoot.querySelector("dialog");
const isOpen = this._modal.open;
const input = this.shadowRoot.querySelector(".search-input");
const matches = this.renderedMatches || [];
this.setElementAttribute(
input,
"aria-expanded",
dialog && dialog.open && matches.length > 0 ? "true" : "false",
isOpen && matches.length > 0 ? "true" : "false",
);
if (
dialog &&
dialog.open &&
isOpen &&
this.selectedIndex >= 0 &&
this.selectedIndex < matches.length
) {
@ -854,14 +807,14 @@ class NavigationSearch extends HTMLElement {
}
openMenu(trigger) {
const dialog = this.shadowRoot.querySelector("dialog");
if (!this._modal.dialog) {
// datasette-modal.js is missing or <dialog> is unsupported
return;
}
const input = this.shadowRoot.querySelector(".search-input");
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
this.shouldRestoreFocus = true;
if (!dialog.open) {
dialog.showModal();
}
this._modal.showModal({ trigger: this.restoreFocusTarget });
this.setNavigationTriggersExpanded(true);
input.value = "";
input.focus();
@ -874,10 +827,8 @@ class NavigationSearch extends HTMLElement {
}
closeMenu(options = {}) {
const dialog = this.shadowRoot.querySelector("dialog");
this.shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
if (this._modal.open) {
this._modal.close({ restoreFocus: options.restoreFocus !== false });
} else {
this.onMenuClosed();
}
@ -889,13 +840,6 @@ class NavigationSearch extends HTMLElement {
this.removeElementAttribute(input, "aria-activedescendant");
this.setNavigationTriggersExpanded(false);
this.setStatus("");
if (
this.shouldRestoreFocus &&
this.restoreFocusTarget &&
typeof this.restoreFocusTarget.focus === "function"
) {
this.restoreFocusTarget.focus();
}
this.restoreFocusTarget = null;
}

View file

@ -114,7 +114,12 @@ function getSetColumnTypeConfig(column) {
}
function canSetColumnType() {
return !!(getSetColumnTypeData() && window.HTMLDialogElement && window.fetch);
return !!(
getSetColumnTypeData() &&
window.DatasetteModal &&
window.DatasetteModal.supported &&
window.fetch
);
}
function setColumnTypeActionLabel(column) {
@ -157,6 +162,7 @@ function createSetColumnTypeOption(value, name, description, checked) {
function setSetColumnTypeDialogBusy(state, isBusy) {
state.isBusy = isBusy;
state.modal.busy = isBusy;
state.saveButton.disabled = isBusy;
state.cancelButton.disabled = isBusy;
Array.from(
@ -181,33 +187,31 @@ function ensureSetColumnTypeDialog() {
if (setColumnTypeDialogState) {
return setColumnTypeDialogState;
}
if (!window.HTMLDialogElement) {
if (!window.DatasetteModal || !window.DatasetteModal.supported) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
dialog.className = "set-column-type-dialog";
dialog.setAttribute("aria-labelledby", "set-column-type-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="set-column-type-title">Set custom type</span>
<span class="modal-meta"></span>
</div>
var modal = window.DatasetteModal.create({
id: SET_COLUMN_TYPE_DIALOG_ID,
className: "set-column-type-dialog",
title: "Set custom type",
titleId: "set-column-type-title",
content: `
<p class="set-column-type-status"></p>
<p class="set-column-type-error" hidden></p>
<div class="set-column-type-options"></div>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="btn btn-ghost set-column-type-cancel" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
</div>
`;
document.body.appendChild(dialog);
`,
});
var dialog = modal.dialog;
setColumnTypeDialogState = {
modal: modal,
dialog: dialog,
meta: dialog.querySelector(".modal-meta"),
status: dialog.querySelector(".set-column-type-status"),
error: dialog.querySelector(".set-column-type-error"),
optionsWrap: dialog.querySelector(".set-column-type-options"),
@ -219,72 +223,57 @@ function ensureSetColumnTypeDialog() {
isBusy: false,
};
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
if (!setColumnTypeDialogState.isBusy) {
dialog.close();
}
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog && !setColumnTypeDialogState.isBusy) {
dialog.close();
}
});
dialog.addEventListener("cancel", function (ev) {
if (setColumnTypeDialogState.isBusy) {
ev.preventDefault();
}
});
dialog.addEventListener("close", function () {
modal.addEventListener("datasette-modal-close", function () {
clearSetColumnTypeDialogError(setColumnTypeDialogState);
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
});
setColumnTypeDialogState.saveButton.addEventListener("click", async function () {
var state = setColumnTypeDialogState;
var selected = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
var selectedType = selected ? selected.value : "";
var currentType = state.currentConfig.current
? state.currentConfig.current.type
: "";
setColumnTypeDialogState.saveButton.addEventListener(
"click",
async function () {
var state = setColumnTypeDialogState;
var selected = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
var selectedType = selected ? selected.value : "";
var currentType = state.currentConfig.current
? state.currentConfig.current.type
: "";
if (selectedType === currentType) {
state.dialog.close();
return;
}
clearSetColumnTypeDialogError(state);
setSetColumnTypeDialogBusy(state, true);
var payload = {
column: state.currentColumn,
column_type: selectedType ? { type: selectedType } : null,
};
try {
var response = await fetch(getSetColumnTypeData().path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
var data = await response.json();
if (!response.ok || data.ok === false) {
var message = (data.errors || ["Request failed"]).join(" ");
throw new Error(message);
if (selectedType === currentType) {
state.modal.close();
return;
}
location.reload();
} catch (error) {
setSetColumnTypeDialogBusy(state, false);
showSetColumnTypeDialogError(state, error.message || "Request failed");
}
});
clearSetColumnTypeDialogError(state);
setSetColumnTypeDialogBusy(state, true);
var payload = {
column: state.currentColumn,
column_type: selectedType ? { type: selectedType } : null,
};
try {
var response = await fetch(getSetColumnTypeData().path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
var data = await response.json();
if (!response.ok || data.ok === false) {
var message = (data.errors || ["Request failed"]).join(" ");
throw new Error(message);
}
location.reload();
} catch (error) {
setSetColumnTypeDialogBusy(state, false);
showSetColumnTypeDialogError(state, error.message || "Request failed");
}
},
);
return setColumnTypeDialogState;
}
@ -306,7 +295,7 @@ function openSetColumnTypeDialog(th) {
state.currentColumn = column;
state.currentConfig = columnConfig;
state.status.textContent = `Column: ${column}`;
state.meta.textContent = getColumnTypeText(th) || "Type unavailable";
state.modal.setMeta(getColumnTypeText(th) || "Type unavailable");
state.footerInfo.textContent = columnConfig.current
? `Current custom type: ${columnConfig.current.type}`
: "No custom type set.";
@ -341,9 +330,7 @@ function openSetColumnTypeDialog(th) {
state.optionsWrap.appendChild(emptyState);
}
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.showModal();
var selectedOption = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
@ -367,9 +354,10 @@ function shouldShowShowAllColumns() {
function hasMultipleVisibleColumns(manager) {
return (
Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter(
(th) => th.dataset.column && th.dataset.isLinkColumn !== "1",
).length > 1
Array.from(
document.querySelectorAll(manager.selectors.tableHeaders),
).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1")
.length > 1
);
}
@ -649,10 +637,12 @@ function filterRowNumberFromName(name) {
}
function nextFilterRowNumber(manager) {
return filterRowsWithControls(manager).reduce((max, row) => {
var column = row.querySelector("select");
return Math.max(max, filterRowNumberFromName(column && column.name));
}, 0) + 1;
return (
filterRowsWithControls(manager).reduce((max, row) => {
var column = row.querySelector("select");
return Math.max(max, filterRowNumberFromName(column && column.name));
}, 0) + 1
);
}
function setFilterRowNumber(row, number) {
@ -679,9 +669,11 @@ function updateFilterRowButtons(manager) {
if (addButton) {
addButton.hidden = index !== rows.length - 1 || !column.value;
}
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
return button && !button.hidden;
}).length;
var visibleButtonCount = [removeButton, addButton].filter(
function (button) {
return button && !button.hidden;
},
).length;
row.classList.toggle(
"filter-controls-row-has-buttons",
visibleButtonCount > 0,
@ -703,7 +695,9 @@ function cloneFilterRow(row) {
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());
clone
.querySelectorAll(".filter-row-icon")
.forEach((button) => button.remove());
return clone;
}

View file

@ -62,6 +62,7 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
"description_html": query.description_html,
"hide_sql": query.hide_sql,
"fragment": query.fragment,
"params": list(query.parameters),
"parameters": list(query.parameters),
"is_write": query.is_write,
"is_private": query.is_private,
@ -83,6 +84,7 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
return {
"queries": [stored_query_to_dict(query) for query in page.queries],
"next": page.next,
"has_more": page.has_more,
"limit": page.limit,
}

View file

@ -6,20 +6,8 @@
padding: 1.5em;
margin-bottom: 2em;
}
.permission-form form {
max-width: 60rem;
}
.permission-form-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-form-result {
margin-top: 1rem;
max-width: 60rem;
}
.form-section {
margin-bottom: 1.25em;
margin-bottom: 1em;
}
.form-section label {
display: block;
@ -27,51 +15,22 @@
font-weight: bold;
}
.form-section input[type="text"],
.form-section input[type="number"],
.form-section select,
.permission-textarea {
background-color: #fff;
border: 1px solid #aaa;
border-radius: 4px;
box-sizing: border-box;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
color: #222;
font-family: inherit;
font-size: 1rem;
line-height: 1.4;
max-width: none;
width: 100%;
}
.form-section input[type="text"] {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.form-section input[type="number"] {
height: 3rem;
max-width: 7rem;
padding: 0.6rem 0.75rem;
}
.form-section select {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.permission-textarea {
font-family: monospace;
min-height: 12rem;
padding: 0.75rem;
resize: vertical;
width: 100%;
max-width: 500px;
padding: 0.5em;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 3px;
}
.form-section input[type="text"]:focus,
.form-section input[type="number"]:focus,
.form-section select:focus,
.permission-textarea:focus {
.form-section select:focus {
outline: 2px solid #0066cc;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
outline: none;
}
.form-section small {
display: block;
margin-top: 0.45em;
margin-top: 0.3em;
color: #666;
}
.form-actions {
@ -183,9 +142,4 @@
text-align: center;
color: #666;
}
@media only screen and (max-width: 576px) {
.permission-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View file

@ -44,10 +44,10 @@
</style>
<nav class="permissions-debug-tabs">
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
</nav>

View file

@ -3,11 +3,29 @@
{% block title %}Debug allow rules{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
<style>
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
p.message-warning {
white-space: pre-wrap;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
@ -20,28 +38,24 @@ p.message-warning {
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
<div class="permission-form">
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
<div class="permission-form-grid">
<div class="form-section">
<label for="allow-block">Allow block</label>
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
</div>
<div class="form-section">
<label for="allow-actor">Actor</label>
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">Apply allow block to actor</button>
</div>
</form>
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
<div class="two-col">
<p><label>Allow block</label></p>
<textarea name="allow">{{ allow_input }}</textarea>
</div>
<div class="two-col">
<p><label>Actor</label></p>
<textarea name="actor">{{ actor_input }}</textarea>
</div>
<div style="margin-top: 1em;">
<input type="submit" value="Apply allow block to actor">
</div>
</form>
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
</div>
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
{% endblock %}

View file

@ -9,6 +9,7 @@
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ static('datasette-manager.js') }}" defer></script>
<script src="{{ static('datasette-modal.js') }}" defer></script>
{% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
{% endfor %}

View file

@ -9,7 +9,7 @@
{% include "_permissions_debug_tabs.html" %}
<p style="margin-bottom: 2em;">
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
Actions are used by the permission system to control access to different features.
</p>
@ -26,7 +26,7 @@
</tr>
</thead>
<tbody>
{% for action in data.actions %}
{% for action in data %}
<tr>
<td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>

View file

@ -49,7 +49,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small>
</div>
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
(function() {
const params = populateFormFromURL();
const action = params.get('action');
const page = params.get('_page');
const page = params.get('page');
if (action) {
fetchResults(page ? parseInt(page) : 1);
}
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value && key !== '_size' && key !== '_page') {
if (value && key !== 'page_size') {
params.append(key, value);
}
}
const pageSize = document.getElementById('page_size').value || '50';
params.append('_page', page.toString());
params.append('_size', pageSize);
params.append('page', page.toString());
params.append('page_size', pageSize);
try {
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Explain a permission decision{% endblock %}
{% block title %}Permission Check{% endblock %}
{% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
@ -13,35 +13,29 @@
border-radius: 5px;
}
#output.allowed {
background-color: #f3fbf4;
background-color: #e8f5e9;
border: 2px solid #4caf50;
}
#output.denied {
background-color: #fff7f7;
background-color: #ffebee;
border: 2px solid #f44336;
}
#output h2 {
margin-top: 0;
}
#output h3 {
margin-bottom: 0.5em;
}
#output .result-badge,
.effect-badge,
.rule-status {
#output .result-badge {
display: inline-block;
padding: 0.2em 0.5em;
padding: 0.3em 0.8em;
border-radius: 3px;
font-weight: bold;
font-size: 1.1em;
}
#output .allowed-badge,
.effect-allow {
background-color: #2e7d32;
#output .allowed-badge {
background-color: #4caf50;
color: white;
}
#output .denied-badge,
.effect-deny {
background-color: #c62828;
#output .denied-badge {
background-color: #f44336;
color: white;
}
.details-section {
@ -54,130 +48,70 @@
.details-section dd {
margin-left: 1em;
}
.explanation-section {
background: rgba(255, 255, 255, 0.75);
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 1em;
padding: 0 1em 1em;
}
.rules-table {
border-collapse: collapse;
width: 100%;
}
.rules-table th,
.rules-table td {
border-bottom: 1px solid #ddd;
padding: 0.5em;
text-align: left;
vertical-align: top;
}
.rule-status {
background: #e8f5e9;
color: #1b5e20;
}
.rule-ignored {
background: #eee;
color: #555;
font-weight: normal;
}
.requirement-allowed {
color: #1b5e20;
}
.requirement-denied {
color: #b71c1c;
}
@media only screen and (max-width: 576px) {
.rules-table,
.rules-table tbody,
.rules-table tr,
.rules-table td {
display: block;
}
.rules-table thead {
display: none;
}
.rules-table td::before {
content: attr(data-label) ": ";
font-weight: bold;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Explain a permission decision</h1>
<h1>Permission check</h1>
{% set current_tab = "check" %}
{% include "_permissions_debug_tabs.html" %}
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p>
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
{% if request.actor %}
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
{% else %}
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
{% endif %}
<div class="permission-form">
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
<div class="form-section">
<label for="actor">Actor JSON:</label>
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
</div>
<div class="form-section">
<label for="action">Action:</label>
<label for="action">Action (permission name):</label>
<select id="action" name="action" required>
<option value="">Select an action...</option>
{% for action in actions %}
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
{% for action_name in sorted_actions %}
<option value="{{ action_name }}">{{ action_name }}</option>
{% endfor %}
</select>
<small id="action-help">The operation to evaluate</small>
<small>The permission action to check</small>
</div>
<div class="form-section" id="parent-section">
<label for="parent">Parent resource:</label>
<div class="form-section">
<label for="parent">Parent resource (optional):</label>
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
<small>The database or other parent resource</small>
<small>For database-level permissions, specify the database name</small>
</div>
<div class="form-section" id="child-section">
<label for="child">Child resource:</label>
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
<small>The table, query or other child resource</small>
<div class="form-section">
<label for="child">Child resource (optional):</label>
<input type="text" id="child" name="child" placeholder="e.g., table name">
<small>For table-level permissions, specify the table name (requires parent)</small>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
</div>
</form>
</div>
<div id="output" style="display: none;">
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
<p id="result-summary"></p>
<dl class="details-section">
<dt>Actor:</dt>
<dd><code id="result-actor"></code></dd>
<dt>Action:</dt>
<dd><code id="result-action"></code></dd>
<dt>Resource:</dt>
<dd><code id="result-resource"></code></dd>
<dd id="result-action"></dd>
<dt>Resource Path:</dt>
<dd id="result-resource"></dd>
<dt>Actor ID:</dt>
<dd id="result-actor"></dd>
<div id="additional-details"></div>
</dl>
<section class="explanation-section">
<h3>Matching rules</h3>
<div id="matching-rules"></div>
</section>
<section class="explanation-section" id="restrictions-section">
<h3>Actor restrictions</h3>
<div id="restriction-results"></div>
</section>
<section class="explanation-section" id="requirements-section">
<h3>Required actions</h3>
<div id="requirement-results"></div>
</section>
<details style="margin-top: 1em;">
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
@ -185,134 +119,152 @@
</div>
<script>
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
const form = document.getElementById('check-form');
const output = document.getElementById('output');
const submitBtn = document.getElementById('submit-btn');
const actionSelect = document.getElementById('action');
function updateResourceFields() {
const action = actions[actionSelect.value];
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
let help = action && action.description ? action.description : 'The operation to evaluate';
if (action && action.also_requires) {
help += `; also requires ${action.also_requires}`;
}
document.getElementById('action-help').textContent = help;
}
async function performCheck() {
submitBtn.disabled = true;
submitBtn.textContent = 'Explaining...';
const params = new URLSearchParams(new FormData(form));
submitBtn.textContent = 'Checking...';
const formData = new FormData(form);
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value) {
params.append(key, value);
}
}
try {
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
headers: {'Accept': 'application/json'}
method: 'GET',
headers: {
'Accept': 'application/json',
}
});
const data = await response.json();
if (response.ok) {
displayResult(data);
} else {
displayError(data);
}
} catch (error) {
displayError({error: error.message});
alert('Error: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Explain decision';
submitBtn.textContent = 'Check Permission';
}
}
// Populate form on initial load
(function() {
const params = populateFormFromURL();
const action = params.get('action');
if (action) {
performCheck();
}
})();
function displayResult(data) {
output.style.display = 'block';
// Set badge and styling
const resultBadge = document.getElementById('result-badge');
output.className = data.allowed ? 'allowed' : 'denied';
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
document.getElementById('result-summary').textContent = data.explanation.summary;
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
document.getElementById('result-action').textContent = data.action;
document.getElementById('result-resource').textContent = data.resource.path;
displayRules(data.explanation);
displayRestrictions(data.explanation.restrictions);
displayRequirements(data.explanation.required_actions);
if (data.allowed) {
output.className = 'allowed';
resultBadge.className = 'result-badge allowed-badge';
resultBadge.textContent = 'ALLOWED ✓';
} else {
output.className = 'denied';
resultBadge.className = 'result-badge denied-badge';
resultBadge.textContent = 'DENIED ✗';
}
// Basic details
document.getElementById('result-action').textContent = data.action || 'N/A';
document.getElementById('result-resource').textContent = data.resource?.path || '/';
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
// Additional details
const additionalDetails = document.getElementById('additional-details');
additionalDetails.innerHTML = '';
if (data.reason !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Reason:';
const dd = document.createElement('dd');
dd.textContent = data.reason || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.source_plugin !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Source Plugin:';
const dd = document.createElement('dd');
dd.textContent = data.source_plugin || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.used_default !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Used Default:';
const dd = document.createElement('dd');
dd.textContent = data.used_default ? 'Yes' : 'No';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.depth !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Depth:';
const dd = document.createElement('dd');
dd.textContent = data.depth;
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
// Raw JSON
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
}
function displayRules(explanation) {
const container = document.getElementById('matching-rules');
if (!explanation.matched_rules.length) {
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
return;
}
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
for (const rule of explanation.matched_rules) {
const status = rule.decisive
? '<span class="rule-status">Decisive</span>'
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
html += '<tr>';
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
html += `<td data-label="Role in decision">${status}</td>`;
html += '</tr>';
}
container.innerHTML = html + '</tbody></table>';
}
function displayRestrictions(restrictions) {
const section = document.getElementById('restrictions-section');
const container = document.getElementById('restriction-results');
section.style.display = restrictions.length ? 'block' : 'none';
container.innerHTML = restrictions.map(restriction => {
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
}).join('');
}
function displayRequirements(requirements) {
const section = document.getElementById('requirements-section');
const container = document.getElementById('requirement-results');
section.style.display = requirements.length ? 'block' : 'none';
container.innerHTML = requirements.map(requirement => {
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
}).join('');
// Scroll to output
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function displayError(data) {
output.style.display = 'block';
output.className = 'denied';
const resultBadge = document.getElementById('result-badge');
resultBadge.className = 'result-badge denied-badge';
resultBadge.textContent = 'ERROR';
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
document.getElementById('result-actor').textContent = '—';
document.getElementById('result-action').textContent = '—';
document.getElementById('result-resource').textContent = '—';
document.getElementById('matching-rules').innerHTML = '';
document.getElementById('restrictions-section').style.display = 'none';
document.getElementById('requirements-section').style.display = 'none';
document.getElementById('result-action').textContent = 'N/A';
document.getElementById('result-resource').textContent = 'N/A';
document.getElementById('result-actor').textContent = 'N/A';
const additionalDetails = document.getElementById('additional-details');
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
form.addEventListener('submit', event => {
event.preventDefault();
performCheck();
});
actionSelect.addEventListener('change', updateResourceFields);
// Disable child input if parent is empty
const parentInput = document.getElementById('parent');
const childInput = document.getElementById('child');
(function initializeFromUrl() {
const params = populateFormFromURL();
updateResourceFields();
if (params.get('action')) {
performCheck();
childInput.addEventListener('focus', () => {
if (!parentInput.value) {
alert('Please specify a parent resource first before adding a child resource.');
parentInput.focus();
}
})();
});
</script>
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Permission activity{% endblock %}
{% block title %}Debug permissions{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
@ -20,45 +20,60 @@
.check-action, .check-when, .check-result {
font-size: 1.3em;
}
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Permission activity</h1>
<h1>Permission playground</h1>
{% set current_tab = "permissions" %}
{% include "_permissions_debug_tabs.html" %}
<h2>Raw simulator</h2>
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
<div class="permission-form">
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
<div class="permission-form-grid">
<div>
<div class="form-section">
<label for="activity-actor">Actor</label>
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
</div>
<div class="two-col">
<div class="form-section">
<label>Actor</label>
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
</div>
<div>
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
</div>
</div>
<div class="two-col" style="vertical-align: top">
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
</div>
</div>
<div class="form-actions">
@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) {
});
</script>
<h2>Recent permission checks</h2>
<h1>Recent permissions checks</h1>
<p>
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},

View file

@ -37,7 +37,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small>
</div>
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
(function() {
const params = populateFormFromURL();
const action = params.get('action');
const page = params.get('_page');
const page = params.get('page');
if (action) {
fetchResults(page ? parseInt(page) : 1);
}
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value && key !== '_size' && key !== '_page') {
if (value && key !== 'page_size') {
params.append(key, value);
}
}
const pageSize = document.getElementById('page_size').value || '50';
params.append('_page', page.toString());
params.append('_size', pageSize);
params.append('page', page.toString());
params.append('page_size', pageSize);
try {
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {

View file

@ -18,21 +18,6 @@ if TYPE_CHECKING:
from datasette.app import Datasette
class TokenInvalid(Exception):
"""
Raised by a TokenHandler when a token it recognizes is invalid -
for example a bad signature, malformed payload or expired token.
Datasette responds to this with an HTTP 401 error. Handlers should
return None instead for tokens they do not recognize at all, so that
other registered handlers get a chance to verify them.
"""
def __init__(self, message="Invalid token"):
self.message = message
super().__init__(message)
@dataclasses.dataclass
class TokenRestrictions:
"""
@ -123,12 +108,8 @@ class TokenHandler:
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
"""
Verify a token and return an actor dict.
Return None if this handler does not recognize the token at all,
so other handlers can try it. Raise TokenInvalid if the token is
recognized but invalid (bad signature, malformed, expired) - the
request will fail with a 401 error.
Verify a token and return an actor dict, or None if this handler
does not recognize the token.
"""
raise NotImplementedError
@ -166,32 +147,29 @@ class SignedTokenHandler(TokenHandler):
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
prefix = "dstok_"
if not token.startswith(prefix):
# Not one of our tokens - leave it for other handlers
if not datasette.setting("allow_signed_tokens"):
return None
if not datasette.setting("allow_signed_tokens"):
raise TokenInvalid(
"Signed tokens are not enabled for this Datasette instance"
)
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
if not token.startswith(prefix):
return None
raw = token[len(prefix) :]
try:
decoded = datasette.unsign(raw, namespace="token")
except itsdangerous.BadSignature:
raise TokenInvalid("Invalid token signature")
return None
if "t" not in decoded:
raise TokenInvalid("Invalid token: no timestamp")
return None
created = decoded["t"]
if not isinstance(created, int):
raise TokenInvalid("Invalid token: invalid timestamp")
return None
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
raise TokenInvalid("Invalid token: invalid duration")
return None
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
@ -202,7 +180,7 @@ class SignedTokenHandler(TokenHandler):
if duration:
if time.time() - created > duration:
raise TokenInvalid("Token has expired")
return None
actor = {"id": decoded["a"], "token": "dstok"}

View file

@ -1,5 +1,4 @@
import asyncio
import binascii
from contextlib import contextmanager
import aiofiles
import click
@ -237,8 +236,10 @@ class CustomJSONEncoder(json.JSONEncoder):
- ``sqlite3.Row`` becomes a tuple
- ``sqlite3.Cursor`` becomes a list
Binary blobs are encoded as an object, with the actual data base64-encoded,
like so: ::
If a binary blob can be decoded as UTF-8, the encoder returns it as text.
If it can't (for example, images), it is encoded as an object, with the actual
data base64-encoded, like so: ::
{
"$base64": True,
@ -254,42 +255,17 @@ class CustomJSONEncoder(json.JSONEncoder):
if isinstance(obj, sqlite3.Cursor):
return list(obj)
if isinstance(obj, bytes):
return {
"$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"),
}
# Does it encode to utf8?
try:
return obj.decode("utf8")
except UnicodeDecodeError:
return {
"$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"),
}
return json.JSONEncoder.default(self, obj)
class WriteJsonValueError(ValueError):
pass
def decode_write_json_cell(value):
if not isinstance(value, dict):
return value
keys = set(value.keys())
if keys == {"$raw"}:
return value["$raw"]
if keys == {"$base64", "encoded"} and value.get("$base64") is True:
encoded = value["encoded"]
if not isinstance(encoded, str):
raise WriteJsonValueError("$base64 encoded value must be a string")
try:
return base64.b64decode(encoded, validate=True)
except binascii.Error as ex:
raise WriteJsonValueError("Invalid $base64 encoded value") from ex
return value
def decode_write_json_row(row):
return {key: decode_write_json_cell(value) for key, value in row.items()}
def decode_write_json_rows(rows):
return [decode_write_json_row(row) for row in rows]
@contextmanager
def sqlite_timelimit(conn, ms):
deadline = time.perf_counter() + (ms / 1000)
@ -636,7 +612,7 @@ def detect_primary_keys(conn, table):
def get_outbound_foreign_keys(conn, table):
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
fks = []
for info in infos:
if info is not None:
@ -1288,20 +1264,10 @@ class StartupError(Exception):
pass
# Comments and string literals, matched in a single pass so that whichever
# construct starts first "wins" - this ensures a comment marker inside a string
# literal (or a quote inside a comment) does not confuse the parameter scan.
_comments_and_strings_re = re.compile(
r"""
--[^\n]* # single line comment
| /\*.*?(?:\*/|\Z) # multi line comment, possibly to end-of-input
| '(?:''|[^'])*' # single quoted string ('' escapes a quote)
| "(?:""|[^"])*" # double quoted identifier ("" escapes a quote)
| \[(?:[^\]])*\] # square-bracket quoted identifier
| `(?:``|[^`])*` # backtick quoted identifier
""",
re.DOTALL | re.VERBOSE,
)
_single_line_comment_re = re.compile(r"--.*")
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
_named_param_re = re.compile(r":(\w+)")
@ -1312,9 +1278,10 @@ def named_parameters(sql: str) -> List[str]:
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
"""
# Strip comments and string literals first so that any ":name" sequences
# inside them are not mistaken for named parameters
sql = _comments_and_strings_re.sub("", sql)
sql = _single_line_comment_re.sub("", sql)
sql = _multi_line_comment_re.sub("", sql)
sql = _single_quote_re.sub("", sql)
sql = _double_quote_re.sub("", sql)
# Extract parameters from what is left
return _named_param_re.findall(sql)
@ -1327,54 +1294,6 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
return named_parameters(sql)
def parse_size_limit(value, default, maximum, name="_size"):
"""
Parse a page-size parameter using the same semantics as the table
view's ?_size=: blank means default, "max" means maximum, integers
must be 0 or greater and no larger than maximum. Raises ValueError
with a message suitable for a 400 response.
"""
if value in (None, ""):
return default
if value == "max":
return maximum
try:
size = int(value)
if size < 0:
raise ValueError
except ValueError:
raise ValueError("{} must be a positive integer".format(name))
if size > maximum:
raise ValueError("{} must be <= {}".format(name, maximum))
return size
UNSTABLE_API_MESSAGE = (
"This API is not part of Datasette's stable interface and may change at any time"
)
def error_body(messages, status):
"""
The canonical JSON error body used by every Datasette JSON error response:
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
"error" is all of the messages joined with "; ", "errors" is the full
list, "status" matches the HTTP status code. Callers may add extra
context keys to the returned dictionary but must not remove these four.
"""
if isinstance(messages, str):
messages = [messages]
messages = [str(message) for message in messages]
return {
"ok": False,
"error": "; ".join(messages),
"errors": messages,
"status": status,
}
def add_cors_headers(headers):
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"

View file

@ -252,62 +252,88 @@ async def _build_single_action_sql(
]
)
# Continue with the cascading logic.
# Aggregate the RULES by cascade level (small), rather than grouping
# base x rules (which scales with the number of resources).
def _agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
f" FROM all_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
# Continue with the cascading logic
query_parts.extend(
["child_agg AS ("]
+ _agg(
"parent, child,",
"parent IS NOT NULL AND child IS NOT NULL",
"parent, child",
)
+ ["),", "parent_agg AS ("]
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "global_agg AS ("]
+ _agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
[
"child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
]
)
# Add anonymous decision logic if needed
if include_is_private:
def _anon_agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
f" FROM anon_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend(
["anon_child_agg AS ("]
+ _anon_agg(
"parent, child,",
"parent IS NOT NULL AND child IS NOT NULL",
"parent, child",
)
+ ["),", "anon_parent_agg AS ("]
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "anon_global_agg AS ("]
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
[
"anon_child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"anon_parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_decisions AS (",
" SELECT",
" b.parent, b.child,",
" CASE",
" WHEN acl.any_deny = 1 THEN 0",
" WHEN acl.any_allow = 1 THEN 1",
" WHEN apl.any_deny = 1 THEN 0",
" WHEN apl.any_allow = 1 THEN 1",
" WHEN agl.any_deny = 1 THEN 0",
" WHEN agl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS anon_is_allowed",
" FROM base b",
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
"),",
]
)
# Final decisions
@ -316,28 +342,31 @@ async def _build_single_action_sql(
"decisions AS (",
" SELECT",
" b.parent, b.child,",
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
" -- Priority order:",
" -- 1. Child-level deny 2. Child-level allow",
" -- 3. Parent-level deny 4. Parent-level allow",
" -- 5. Global-level deny 6. Global-level allow",
" -- 1. Child-level deny (most specific, blocks access)",
" -- 2. Child-level allow (most specific, grants access)",
" -- 3. Parent-level deny (intermediate, blocks access)",
" -- 4. Parent-level allow (intermediate, grants access)",
" -- 5. Global-level deny (least specific, blocks access)",
" -- 6. Global-level allow (least specific, grants access)",
" -- 7. Default deny (no rules match)",
" CASE",
" WHEN ca.any_deny = 1 THEN 0",
" WHEN ca.any_allow = 1 THEN 1",
" WHEN pa.any_deny = 1 THEN 0",
" WHEN pa.any_allow = 1 THEN 1",
" WHEN ga.any_deny = 1 THEN 0",
" WHEN ga.any_allow = 1 THEN 1",
" WHEN cl.any_deny = 1 THEN 0",
" WHEN cl.any_allow = 1 THEN 1",
" WHEN pl.any_deny = 1 THEN 0",
" WHEN pl.any_allow = 1 THEN 1",
" WHEN gl.any_deny = 1 THEN 0",
" WHEN gl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS is_allowed,",
" CASE",
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
" ELSE '[]'",
" END AS reason",
]
@ -345,34 +374,21 @@ async def _build_single_action_sql(
if include_is_private:
query_parts.append(
" , CASE WHEN ("
"CASE"
" WHEN aca.any_deny = 1 THEN 0"
" WHEN aca.any_allow = 1 THEN 1"
" WHEN apa.any_deny = 1 THEN 0"
" WHEN apa.any_allow = 1 THEN 1"
" WHEN aga.any_deny = 1 THEN 0"
" WHEN aga.any_allow = 1 THEN 1"
" ELSE 0 END"
") = 0 THEN 1 ELSE 0 END AS is_private"
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
)
query_parts.extend(
[
" FROM base b",
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
" CROSS JOIN global_agg ga",
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
]
)
if include_is_private:
query_parts.extend(
[
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
" CROSS JOIN anon_global_agg aga",
]
query_parts.append(
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
)
query_parts.append(")")
@ -384,28 +400,8 @@ async def _build_single_action_sql(
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_sqls
)
# Decompose by NULL-pattern so the final filter can use pure-equality
# EXISTS lookups (satisfiable via automatic indexes) instead of a
# correlated OR-scan over the whole list.
query_parts.extend(
[
",",
"restriction_list AS (",
f" {restriction_intersect}",
"),",
"restriction_exact AS (",
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
"),",
"restriction_parent_any AS (",
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
"),",
"restriction_child_any AS (",
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
"),",
"restriction_all AS (",
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
")",
]
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
)
# Final SELECT
@ -420,11 +416,10 @@ async def _build_single_action_sql(
# Add restriction filter if there are restrictions
if restriction_sqls:
query_parts.append("""
AND (
EXISTS (SELECT 1 FROM restriction_all)
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
AND EXISTS (
SELECT 1 FROM restriction_list r
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
AND (r.child = decisions.child OR r.child IS NULL)
)""")
# Add parent filter if specified
@ -678,239 +673,3 @@ async def check_permission_for_resource(
child=child,
)
return results[action]
async def explain_permission_for_resource(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Explain a permission decision for one action and resource.
This is intended for Datasette's permission debugging tools. It uses the
same ``permission_resources_sql`` hook results and the same resolution
rules as :func:`check_permissions_for_actions`, but also returns the
matching rules, actor restriction results and ``also_requires`` chain.
The returned dictionary is part of Datasette's unstable debugging API.
"""
action_obj = datasette.actions.get(action)
if action_obj is None:
raise ValueError(f"Unknown action: {action}")
explanation = await _explain_single_action(
datasette=datasette,
actor=actor,
action=action,
parent=parent,
child=child,
)
required_actions = []
if action_obj.also_requires:
required = await explain_permission_for_resource(
datasette=datasette,
actor=actor,
action=action_obj.also_requires,
parent=parent,
child=child,
)
required_actions.append(required)
explanation["required_actions"] = required_actions
explanation["allowed"] = bool(
explanation["rule_allowed"]
and explanation["restriction_allowed"]
and all(required["allowed"] for required in required_actions)
)
explanation["summary"] = _permission_explanation_summary(explanation)
return explanation
async def _explain_single_action(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Return matching rules and restrictions for a single action."""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette,
actor=actor,
action=action,
)
if permission_sqls is SKIP_PERMISSION_CHECKS:
return {
"action": action,
"rule_allowed": True,
"restriction_allowed": True,
"winning_scope": "global",
"matched_rules": [
{
"scope": "global",
"effect": "allow",
"source": "skip_permission_checks",
"reason": "Permission checks were explicitly skipped",
"decisive": True,
"ignored_because": None,
}
],
"restrictions": [],
}
db = datasette.get_internal_database()
matched_rules = []
restrictions = []
for permission_sql in permission_sqls:
params = dict(permission_sql.params or {})
parent_param = _unused_parameter_name(params, "_explain_parent")
params[parent_param] = parent
child_param = _unused_parameter_name(params, "_explain_child")
params[child_param] = child
if permission_sql.sql:
rows = await db.execute(
f"""
SELECT parent, child, allow, reason
FROM ({permission_sql.sql}) AS permission_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child = :{child_param})
""",
params,
)
for row in rows:
specificity = (
2
if row["child"] is not None
else 1 if row["parent"] is not None else 0
)
matched_rules.append(
{
"scope": ("resource", "parent", "global")[2 - specificity],
"effect": "allow" if row["allow"] else "deny",
"source": permission_sql.source,
"reason": row["reason"],
"_specificity": specificity,
}
)
if permission_sql.restriction_sql:
restriction_row = (
await db.execute(
f"""
SELECT EXISTS(
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child = :{child_param})
) AS resource_is_in_allowlist
""",
params,
)
).first()
restriction_allowed = bool(restriction_row[0])
restrictions.append(
{
"source": permission_sql.source,
"allowed": restriction_allowed,
"reason": params.get("deny")
or (
"Resource is included in this restriction allowlist"
if restriction_allowed
else "Resource is not included in this restriction allowlist"
),
}
)
matched_rules.sort(
key=lambda rule: (
-rule["_specificity"],
0 if rule["effect"] == "deny" else 1,
rule["source"] or "",
rule["reason"] or "",
)
)
if matched_rules:
winning_specificity = matched_rules[0]["_specificity"]
winning_rules = [
rule
for rule in matched_rules
if rule["_specificity"] == winning_specificity
]
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
winning_scope = winning_rules[0]["scope"]
else:
winning_specificity = None
rule_allowed = False
winning_scope = None
for rule in matched_rules:
specificity = rule.pop("_specificity")
if specificity != winning_specificity:
rule["decisive"] = False
rule["ignored_because"] = "A more specific rule matched"
elif not rule_allowed and rule["effect"] == "allow":
rule["decisive"] = False
rule["ignored_because"] = "A deny rule matched at the same scope"
else:
rule["decisive"] = True
rule["ignored_because"] = None
return {
"action": action,
"rule_allowed": rule_allowed,
"restriction_allowed": all(
restriction["allowed"] for restriction in restrictions
),
"winning_scope": winning_scope,
"matched_rules": matched_rules,
"restrictions": restrictions,
}
def _unused_parameter_name(params: dict, preferred: str) -> str:
"""Return a SQL parameter name that is not already in ``params``."""
candidate = preferred
suffix = 2
while candidate in params:
candidate = f"{preferred}_{suffix}"
suffix += 1
return candidate
def _permission_explanation_summary(explanation: dict) -> str:
denied_requirement = next(
(
required
for required in explanation["required_actions"]
if not required["allowed"]
),
None,
)
if denied_requirement:
return (
f"Denied because {explanation['action']} also requires "
f"{denied_requirement['action']}, which was denied."
)
if not explanation["matched_rules"]:
return "Denied because no permission rule matched this actor and resource."
if not explanation["rule_allowed"]:
return (
f"Denied by a {explanation['winning_scope']}-level rule. "
"Deny rules take precedence over allow rules at the same scope."
)
if not explanation["restriction_allowed"]:
return (
"Denied because the resource is not included in the actor's restrictions."
)
return f"Allowed by the matching {explanation['winning_scope']}-level rule."

View file

@ -1,6 +1,6 @@
import json
from typing import Optional
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
from datasette.utils import MultiParams, calculate_etag, sha256_file
from datasette.utils.multipart import (
parse_form_data,
MultipartParseError,
@ -67,25 +67,13 @@ class BadRequest(Base400):
status = 400
class PayloadTooLarge(Base400):
status = 413
SAMESITE_VALUES = ("strict", "lax", "none")
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
# while these bodies are held in RAM and json.loads() can multiply their
# footprint several times over.
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
class Request:
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
def __init__(self, scope, receive):
self.scope = scope
self.receive = receive
self.max_post_body_bytes = max_post_body_bytes
def __repr__(self):
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
@ -153,43 +141,15 @@ class Request:
def actor(self):
return self.scope.get("actor", None)
async def post_body(self, max_bytes=None):
"""
Read the request body fully into memory.
The body is capped at max_bytes - or self.max_post_body_bytes
(default 2MB, set from the max_post_body_bytes setting for requests
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
oversized bodies are rejected as soon as the limit is passed, without
buffering the rest.
"""
if max_bytes is None:
max_bytes = self.max_post_body_bytes
too_large = PayloadTooLarge(
"Request body exceeded maximum size of {} bytes".format(max_bytes)
)
if max_bytes:
# Reject early if the client declares an oversized body
try:
if int(self.headers.get("content-length", "")) > max_bytes:
raise too_large
except ValueError:
# Missing or malformed - the streaming check below still applies
pass
chunks = []
received = 0
async def post_body(self):
body = b""
more_body = True
while more_body:
message = await self.receive()
assert message["type"] == "http.request", message
chunk = message.get("body", b"")
received += len(chunk)
if max_bytes and received > max_bytes:
raise too_large
chunks.append(chunk)
body += message.get("body", b"")
more_body = message.get("more_body", False)
return b"".join(chunks)
return body
async def post_vars(self):
body = await self.post_body()
@ -575,18 +535,6 @@ class Response:
content_type="application/json; charset=utf-8",
)
@classmethod
def error(cls, messages, status=400, headers=None):
"""
A JSON error response using Datasette's standard error format.
messages can be a single string or a list of strings. For errors
that should content-negotiate between JSON and HTML, raise
Forbidden, NotFound, BadRequest or DatasetteError instead and let
Datasette's error handling hooks build the response.
"""
return cls.json(error_body(messages, status), status=status, headers=headers)
@classmethod
def redirect(cls, path, status=302, headers=None):
headers = headers or {}

View file

@ -1,30 +1,9 @@
import textwrap
from datasette.utils import table_column_details
from sqlite_utils import Database as SQLiteUtilsDatabase
from sqlite_utils import Migrations
from datasette.utils import escape_sqlite, table_column_details
INTERNAL_DB_SCHEMA_TABLES = {
"catalog_databases",
"catalog_tables",
"catalog_views",
"catalog_columns",
"catalog_indexes",
"catalog_foreign_keys",
"metadata_instance",
"metadata_databases",
"metadata_resources",
"metadata_columns",
"column_types",
"queries",
}
INTERNAL_DB_SCHEMA_INDEXES = {
"queries_owner_idx",
}
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
async def init_internal_db(db):
create_tables_sql = textwrap.dedent("""
CREATE TABLE IF NOT EXISTS catalog_databases (
database_name TEXT PRIMARY KEY,
path TEXT,
@ -88,101 +67,99 @@ INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
);
CREATE TABLE IF NOT EXISTS metadata_instance (
key text,
value text,
unique(key)
);
CREATE TABLE IF NOT EXISTS metadata_databases (
database_name text,
key text,
value text,
unique(database_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_resources (
database_name text,
resource_name text,
key text,
value text,
unique(database_name, resource_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_columns (
database_name text,
resource_name text,
column_name text,
key text,
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
""").strip()
await db.execute_write_script(create_tables_sql)
await initialize_metadata_tables(db)
internal_migrations = Migrations("datasette_internal")
async def initialize_metadata_tables(db):
await db.execute_write_script(textwrap.dedent("""
CREATE TABLE IF NOT EXISTS metadata_instance (
key text,
value text,
unique(key)
);
CREATE TABLE IF NOT EXISTS metadata_databases (
database_name text,
key text,
value text,
unique(database_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_resources (
database_name text,
resource_name text,
key text,
value text,
unique(database_name, resource_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_columns (
database_name text,
resource_name text,
column_name text,
key text,
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
"""))
def _internal_schema_exists(db):
table_names = set(db.table_names())
if not INTERNAL_DB_SCHEMA_TABLES.issubset(table_names):
return False
index_names = {
row[0]
for row in db.execute("select name from sqlite_master where type = 'index'")
}
return INTERNAL_DB_SCHEMA_INDEXES.issubset(index_names)
@internal_migrations(name="0001_initial")
def initial_internal_schema(db):
if _internal_schema_exists(db):
return
db.executescript(INTERNAL_DB_SCHEMA_SQL)
async def init_internal_db(db):
def apply_migrations(conn):
internal_migrations.apply(SQLiteUtilsDatabase(conn, execute_plugins=False))
await db.execute_write_fn(apply_migrations, transaction=False)
async def populate_schema_tables(internal_db, db, schema_version):
async def populate_schema_tables(internal_db, db):
database_name = db.name
def delete_everything(conn):
conn.execute(
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
[database_name],
)
conn.execute(
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
)
await internal_db.execute_write_fn(delete_everything)
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
@ -213,7 +190,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
for column in columns
)
foreign_keys = conn.execute(
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
f"PRAGMA foreign_key_list([{table_name}])"
).fetchall()
foreign_keys_to_insert.extend(
{
@ -222,9 +199,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
}
for foreign_key in foreign_keys
)
indexes = conn.execute(
f"PRAGMA index_list({escape_sqlite(table_name)})"
).fetchall()
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
indexes_to_insert.extend(
{
**{"database_name": database_name, "table_name": table_name},
@ -248,76 +223,47 @@ async def populate_schema_tables(internal_db, db, schema_version):
indexes_to_insert,
) = await db.execute_fn(collect_info)
def replace_catalog(conn):
# Delete child rows before their catalog_tables parents so this also
# works if a prepare_connection plugin enables foreign key enforcement.
for table in (
"catalog_columns",
"catalog_foreign_keys",
"catalog_indexes",
"catalog_views",
"catalog_tables",
):
conn.execute(
"DELETE FROM {} WHERE database_name = ?".format(table),
[database_name],
)
conn.execute(
"""
INSERT OR REPLACE INTO catalog_databases (
database_name, path, is_memory, schema_version
) VALUES (?, ?, ?, ?)
""",
[
database_name,
str(db.path) if db.path is not None else None,
db.is_memory,
schema_version,
],
await internal_db.execute_write_many(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
)
conn.executemany(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
""",
columns_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
)
conn.executemany(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
""",
foreign_keys_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
)
conn.executemany(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
)
""",
columns_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
)
""",
foreign_keys_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
)
""",
indexes_to_insert,
)
await internal_db.execute_write_fn(replace_catalog)
""",
indexes_to_insert,
)

View file

@ -488,17 +488,17 @@ def analyze_sql_tables(
and key.operation in {"create", "alter", "drop"}
for key in operations
)
dropped_tables_and_views = {
dropped_tables = {
(key.database, key.table)
for key in operations
if key.operation == "drop" and key.target_type in {"table", "view"}
if key.operation == "drop" and key.target_type == "table"
}
def key_is_drop_table_delete(key: OperationKey) -> bool:
return (
key.operation == "delete"
and key.target_type == "table"
and (key.database, key.table) in dropped_tables_and_views
and (key.database, key.table) in dropped_tables
)
has_user_table_access_in_schema_operation = any(

View file

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

View file

@ -28,15 +28,12 @@ class DatasetteError(Exception):
status=500,
template=None,
message_is_html=False,
plain_message=None,
):
self.message = message
self.title = title
self.error_dict = error_dict or {}
self.status = status
self.message_is_html = message_is_html
# Plain text used for JSON error responses when message is HTML
self.plain_message = plain_message
class View:
@ -52,7 +49,9 @@ class View:
request.path.endswith(".json")
or request.headers.get("content-type") == "application/json"
):
response = Response.error("Method not allowed", 405)
response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
else:
response = Response.text("Method not allowed", status=405)
return response
@ -91,7 +90,9 @@ class BaseView:
request.path.endswith(".json")
or request.headers.get("content-type") == "application/json"
):
response = Response.error("Method not allowed", 405)
response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
else:
response = Response.text("Method not allowed", status=405)
return response
@ -179,6 +180,10 @@ class BaseView:
return view
def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)
async def stream_csv(datasette, fetch_data, request, database):
kwargs = {}
stream = request.args.get("_stream")

View file

@ -8,7 +8,7 @@ import markupsafe
import os
import textwrap
from datasette.extras import extra_names_from_request, ExtraScope
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import StoredQuery, stored_query_to_dict
@ -16,7 +16,6 @@ from datasette.write_sql import QueryWriteRejected
from datasette.utils import (
add_cors_headers,
await_me_maybe,
error_body,
call_with_supported_arguments,
named_parameters as derive_named_parameters,
format_bytes,
@ -326,7 +325,7 @@ class DatabaseContext(Context):
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. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
}
)
database_actions: callable = field(
@ -608,7 +607,11 @@ class QueryView(View):
"_json"
):
return Response.json(
dict(error_body([ex.message], 403), redirect=None),
{
"ok": False,
"message": ex.message,
"redirect": None,
},
status=403,
)
datasette.add_message(request, ex.message, datasette.ERROR)
@ -643,15 +646,8 @@ class QueryView(View):
ok = None
redirect_url = None
try:
execute_write_kwargs = {"request": request}
if stored_query.is_trusted:
analysis = await db.analyze_sql(stored_query.sql, params_for_query)
if any(
operation.operation == "vacuum" for operation in analysis.operations
):
execute_write_kwargs["transaction"] = False
cursor = await db.execute_write(
stored_query.sql, params_for_query, **execute_write_kwargs
stored_query.sql, params_for_query, request=request
)
# success message can come from on_success_message or on_success_message_sql
message = None
@ -685,17 +681,12 @@ class QueryView(View):
redirect_url = stored_query.on_error_redirect
ok = False
if should_return_json:
if ok:
return Response.json(
{
"ok": True,
"message": message,
"redirect": redirect_url,
}
)
return Response.json(
dict(error_body([message], 400), redirect=redirect_url),
status=400,
{
"ok": ok,
"message": message,
"redirect": redirect_url,
}
)
else:
datasette.add_message(request, message, message_type)
@ -826,10 +817,6 @@ class QueryView(View):
title="SQL Interrupted",
status=400,
message_is_html=True,
plain_message=(
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
),
)
except sqlite3.DatabaseError as ex:
query_error = str(ex)
@ -862,11 +849,8 @@ class QueryView(View):
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
elif format_ in datasette.renderers.keys():
if not sql:
raise DatasetteError("?sql= is required", status=400)
data = {"ok": True, "rows": rows, "columns": columns}
extras = extra_names_from_request(request)
table_extra_registry.validate_requested(extras, ExtraScope.QUERY)
if extras:
query_extra_context = QueryExtraContext(
datasette=datasette,

View file

@ -2,10 +2,10 @@ import re
from urllib.parse import urlencode
from datasette.resources import DatabaseResource
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
from datasette.utils import sqlite3
from datasette.utils.asgi import Response
from .base import BaseView
from .base import BaseView, _error
from .database import display_rows as display_query_rows
from .query_helpers import (
QueryValidationError,
@ -348,7 +348,7 @@ class ExecuteWriteView(BaseView):
)
if not db.is_mutable:
return _block_framing(
Response.error(
_error(
["Cannot execute write SQL because this database is immutable."],
403,
)
@ -367,10 +367,10 @@ class ExecuteWriteView(BaseView):
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-write-sql"], 403)
_error(["Permission denied: need execute-write-sql"], 403)
)
if not db.is_mutable:
return _block_framing(Response.error(["Database is immutable"], 403))
return _block_framing(_error(["Database is immutable"], 403))
data = {}
is_json = request.headers.get("content-type", "").startswith("application/json")
@ -384,7 +384,7 @@ class ExecuteWriteView(BaseView):
)
except QueryValidationError as ex:
if _wants_json(request, is_json, data):
return _block_framing(Response.error([ex.message], ex.status))
return _block_framing(_error([ex.message], ex.status))
if ex.flash:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
@ -405,7 +405,7 @@ class ExecuteWriteView(BaseView):
except sqlite3.DatabaseError as ex:
message = str(ex)
if wants_json:
return _block_framing(Response.error([message], 400))
return _block_framing(_error([message], 400))
return await self._render_form(
request,
db,
@ -488,18 +488,20 @@ class ExecuteWriteAnalyzeView(BaseView):
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-write-sql"], 403)
_error(["Permission denied: need execute-write-sql"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
Response.error(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
analysis["unstable"] = UNSTABLE_API_MESSAGE
return _block_framing(Response.json(analysis))
return _block_framing(
Response.json(
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
)
)

View file

@ -6,7 +6,6 @@ from datasette.utils import (
await_me_maybe,
make_slot_function,
CustomJSONEncoder,
UNSTABLE_API_MESSAGE,
)
from datasette.utils.asgi import Response
from datasette.version import __version__
@ -152,9 +151,7 @@ class IndexView(BaseView):
return Response(
json.dumps(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"databases": databases,
"databases": {db["name"]: db for db in databases},
"metadata": await self.ds.get_instance_metadata(),
},
cls=CustomJSONEncoder,

View file

@ -13,7 +13,6 @@ from datasette.write_sql import (
operation_is_write,
)
from datasette.utils import (
parse_size_limit,
named_parameters as derive_named_parameters,
escape_sqlite,
path_from_row_pks,
@ -33,6 +32,7 @@ _query_fields = {
"hide_sql",
"fragment",
"parameters",
"params",
"is_private",
"on_success_message",
"on_success_redirect",
@ -94,11 +94,13 @@ def _as_optional_bool(value, name):
raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default, maximum):
def _query_list_limit(value, default=50):
if value in (None, ""):
return default
try:
return parse_size_limit(value, default, maximum)
return min(max(1, int(value)), 1000)
except ValueError as ex:
raise QueryValidationError(str(ex)) from ex
raise QueryValidationError("_size must be an integer") from ex
def _derived_query_parameters(sql):
@ -539,7 +541,7 @@ async def _prepare_query_create(datasette, request, db, data):
raise QueryValidationError("Writable query fields require writable SQL")
parameters = _coerce_query_parameters(
data.get("parameters"),
data.get("parameters", data.get("params")),
derived,
)
return {
@ -584,9 +586,9 @@ async def _prepare_query_update(datasette, request, db, existing: StoredQuery, u
actor=request.actor,
)
if "parameters" in update:
if "parameters" in update or "params" in update:
parameters = _coerce_query_parameters(
update.get("parameters"),
update.get("parameters", update.get("params")),
derived,
)
elif "sql" in update:

View file

@ -8,27 +8,25 @@ from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
from datasette.utils.asgi import NotFound, Forbidden, Response
from datasette.database import QueryInterrupted
from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.resources import TableResource
from .base import BaseView, DatasetteError, stream_csv
from .base import BaseView, DatasetteError, _error, stream_csv
from datasette.utils import (
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
decode_write_json_row,
InvalidSql,
make_slot_function,
path_from_row_pks,
path_with_added_args,
path_with_format,
path_with_removed_args,
to_css_class,
escape_sqlite,
sqlite3,
WriteJsonValueError,
)
from datasette.plugins import pm
from datasette.extras import extra_names_from_request, ExtraScope
@ -200,10 +198,6 @@ class RowView(BaseView):
title="SQL Interrupted",
status=400,
message_is_html=True,
plain_message=(
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
),
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
@ -215,6 +209,18 @@ class RowView(BaseView):
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
# Special case for .jsono extension - redirect to _shape=objects
if format_ == "jsono":
return self.redirect(
request,
path_with_added_args(
request,
{"_shape": "objects"},
path=request.path.rsplit(".jsono", 1)[0] + ".json",
),
forward_querystring=False,
)
if format_ in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
@ -603,9 +609,6 @@ class RowView(BaseView):
}
extras = extra_names_from_request(request)
if request.url_vars.get("format"):
# Data formats reject unknown extras; HTML ignores them
table_extra_registry.validate_requested(extras, ExtraScope.ROW)
# Process extras
row_extra_context = RowExtraContext(
@ -715,13 +718,11 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
try:
resolved = await datasette.resolve_row(request)
except DatabaseNotFound as e:
return False, Response.error(
["Database not found: {}".format(e.database_name)], 404
)
return False, _error(["Database not found: {}".format(e.database_name)], 404)
except TableNotFound as e:
return False, Response.error(["Table not found: {}".format(e.table)], 404)
return False, _error(["Table not found: {}".format(e.table)], 404)
except RowNotFound as e:
return False, Response.error(["Record not found: {}".format(e.pk_values)], 404)
return False, _error(["Record not found: {}".format(e.pk_values)], 404)
# Ensure user has permission to delete this row
if not await datasette.allowed(
@ -729,7 +730,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor,
):
return False, Response.error(["Permission denied"], 403)
return False, _error(["Permission denied"], 403)
return True, resolved
@ -754,7 +755,7 @@ class RowDeleteView(BaseView):
try:
await resolved.db.execute_write_fn(delete_row, request=request)
except Exception as e:
return Response.error([str(e)], 400)
return _error([str(e)], 500)
await self.ds.track_event(
DeleteRowEvent(
@ -793,24 +794,18 @@ class RowUpdateView(BaseView):
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
return _error(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict):
return Response.error(["JSON must be a dictionary"])
return _error(["JSON must be a dictionary"])
if "update" not in data or not isinstance(data["update"], dict):
return Response.error(["JSON must contain an update dictionary"])
return _error(["JSON must contain an update dictionary"])
invalid_keys = set(data.keys()) - {"update", "return", "alter"}
if invalid_keys:
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))])
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
update = data["update"]
try:
update = decode_write_json_row(update)
except WriteJsonValueError as e:
return Response.error([str(e)], 400)
# Validate column types
from datasette.views.table import _validate_column_types
@ -819,7 +814,7 @@ class RowUpdateView(BaseView):
self.ds, resolved.db.name, resolved.table, [update]
)
if ct_errors:
return Response.error(ct_errors, 400)
return _error(ct_errors, 400)
alter = data.get("alter")
if alter and not await self.ds.allowed(
@ -827,7 +822,7 @@ class RowUpdateView(BaseView):
resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor,
):
return Response.error(["Permission denied for alter-table"], 403)
return _error(["Permission denied for alter-table"], 403)
def update_row(conn):
sqlite_utils.Database(conn)[resolved.table].update(
@ -837,7 +832,7 @@ class RowUpdateView(BaseView):
try:
await resolved.db.execute_write_fn(update_row, request=request)
except Exception as e:
return Response.error([str(e)], 400)
return _error([str(e)], 400)
result = {"ok": True}
returned_row = None
@ -846,7 +841,7 @@ class RowUpdateView(BaseView):
resolved.sql, resolved.params, truncate=True
)
returned_row = results.dicts()[0]
result["rows"] = [returned_row]
result["row"] = returned_row
await self.ds.track_event(
UpdateRowEvent(
@ -872,4 +867,4 @@ class RowUpdateView(BaseView):
self.ds.INFO,
)
return Response.json(result, status=200, default=CustomJSONEncoder().default)
return Response.json(result, status=200)

View file

@ -6,12 +6,9 @@ from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import (
UNSTABLE_API_MESSAGE,
actor_matches_allow,
parse_size_limit,
add_cors_headers,
await_me_maybe,
error_body,
tilde_encode,
tilde_decode,
)
@ -55,9 +52,9 @@ class JsonDataView(BaseView):
if self.permission:
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
if self.needs_request:
data = await await_me_maybe(self.data_callback(request))
data = self.data_callback(request)
else:
data = await await_me_maybe(self.data_callback())
data = self.data_callback()
# Return JSON or HTML depending on format parameter
as_format = request.url_vars.get("format")
@ -65,8 +62,6 @@ class JsonDataView(BaseView):
headers = {}
if self.ds.cors:
add_cors_headers(headers)
if isinstance(data, dict):
data = {"ok": True, **data}
return Response.json(data, headers=headers)
else:
context = {
@ -297,12 +292,6 @@ class PermissionsDebugView(BaseView):
response, status = await _check_permission_for_actor(
self.ds, permission, parent, child, actor
)
if response.get("ok"):
response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
**response,
}
return Response.json(response, status=status)
@ -359,32 +348,29 @@ class AllowedResourcesView(BaseView):
async def _allowed_payload(self, request, has_debug_permission):
action = request.args.get("action")
if not action:
return error_body("action parameter is required", 400), 400
return {"error": "action parameter is required"}, 400
if action not in self.ds.actions:
return error_body(f"Unknown action: {action}", 404), 404
return {"error": f"Unknown action: {action}"}, 404
actor = request.actor if isinstance(request.actor, dict) else None
actor_id = actor.get("id") if actor else None
parent_filter = request.args.get("parent")
child_filter = request.args.get("child")
if child_filter and not parent_filter:
return (
error_body("parent must be provided when child is specified", 400),
400,
)
return {"error": "parent must be provided when child is specified"}, 400
try:
page = int(request.args.get("_page", "1"))
if page < 1:
raise ValueError
page = int(request.args.get("page", "1"))
page_size = int(request.args.get("page_size", "50"))
except ValueError:
return error_body("_page must be a positive integer", 400), 400
try:
page_size = parse_size_limit(
request.args.get("_size"), default=50, maximum=200
)
except ValueError as ex:
return error_body(str(ex), 400), 400
return {"error": "page and page_size must be integers"}, 400
if page < 1:
return {"error": "page must be >= 1"}, 400
if page_size < 1:
return {"error": "page_size must be >= 1"}, 400
max_page_size = 200
if page_size > max_page_size:
page_size = max_page_size
offset = (page - 1) * page_size
# Use the simplified allowed_resources method
@ -424,7 +410,6 @@ class AllowedResourcesView(BaseView):
# If catalog tables don't exist yet, return empty results
return (
{
"ok": True,
"action": action,
"actor_id": actor_id,
"page": page,
@ -449,17 +434,16 @@ class AllowedResourcesView(BaseView):
def build_page_url(page_number):
pairs = []
for key in request.args:
if key in {"_page", "_size"}:
if key in {"page", "page_size"}:
continue
for value in request.args.getlist(key):
pairs.append((key, value))
pairs.append(("_page", str(page_number)))
pairs.append(("_size", str(page_size)))
pairs.append(("page", str(page_number)))
pairs.append(("page_size", str(page_size)))
query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}"
response = {
"ok": True,
"action": action,
"actor_id": actor_id,
"page": page,
@ -501,24 +485,26 @@ class PermissionRulesView(BaseView):
# JSON API - action parameter is required
action = request.args.get("action")
if not action:
return Response.error("action parameter is required", 400)
return Response.json({"error": "action parameter is required"}, status=400)
if action not in self.ds.actions:
return Response.error(f"Unknown action: {action}", 404)
return Response.json({"error": f"Unknown action: {action}"}, status=404)
actor = request.actor if isinstance(request.actor, dict) else None
try:
page = int(request.args.get("_page", "1"))
if page < 1:
raise ValueError
page = int(request.args.get("page", "1"))
page_size = int(request.args.get("page_size", "50"))
except ValueError:
return Response.error("_page must be a positive integer", 400)
try:
page_size = parse_size_limit(
request.args.get("_size"), default=50, maximum=200
return Response.json(
{"error": "page and page_size must be integers"}, status=400
)
except ValueError as ex:
return Response.error(str(ex), 400)
if page < 1:
return Response.json({"error": "page must be >= 1"}, status=400)
if page_size < 1:
return Response.json({"error": "page_size must be >= 1"}, status=400)
max_page_size = 200
if page_size > max_page_size:
page_size = max_page_size
offset = (page - 1) * page_size
from datasette.utils.actions_sql import build_permission_rules_sql
@ -569,17 +555,16 @@ class PermissionRulesView(BaseView):
def build_page_url(page_number):
pairs = []
for key in request.args:
if key in {"_page", "_size"}:
if key in {"page", "page_size"}:
continue
for value in request.args.getlist(key):
pairs.append((key, value))
pairs.append(("_page", str(page_number)))
pairs.append(("_size", str(page_size)))
pairs.append(("page", str(page_number)))
pairs.append(("page_size", str(page_size)))
query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}"
response = {
"ok": True,
"action": action,
"actor_id": (actor or {}).get("id") if actor else None,
"page": page,
@ -600,17 +585,17 @@ class PermissionRulesView(BaseView):
async def _check_permission_for_actor(ds, action, parent, child, actor):
"""Shared logic for checking and explaining a permission decision."""
"""Shared logic for checking permissions. Returns a dict with check results."""
if action not in ds.actions:
return error_body(f"Unknown action: {action}", 404), 404
return {"error": f"Unknown action: {action}"}, 404
if child and not parent:
return error_body("parent is required when child is provided", 400), 400
return {"error": "parent is required when child is provided"}, 400
# Use the action's properties to create the appropriate resource object
action_obj = ds.actions.get(action)
if not action_obj:
return error_body(f"Unknown action: {action}", 400), 400
return {"error": f"Unknown action: {action}"}, 400
# Global actions (no resource_class) don't have a resource
if action_obj.resource_class is None:
@ -625,32 +610,18 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
resource_obj = action_obj.resource_class(parent)
else:
# This shouldn't happen given validation in Action.__post_init__
return error_body(f"Invalid action configuration: {action}", 500), 500
return {"error": f"Invalid action configuration: {action}"}, 500
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
from datasette.utils.actions_sql import explain_permission_for_resource
explanation = await explain_permission_for_resource(
datasette=ds,
actor=actor,
action=action,
parent=parent,
child=child,
)
response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"action": action,
"allowed": bool(allowed),
"actor": actor,
"resource": {
"parent": parent,
"child": child,
"path": _resource_path(parent, child),
},
"explanation": explanation,
}
if actor and "id" in actor:
@ -668,25 +639,11 @@ class PermissionCheckView(BaseView):
as_format = request.url_vars.get("format")
if not as_format:
actions = [
{
"name": action.name,
"description": action.description,
"takes_parent": action.takes_parent,
"takes_child": action.takes_child,
"also_requires": action.also_requires,
}
for action in sorted(
self.ds.actions.values(), key=lambda action: action.name
)
]
return await self.render(
["debug_check.html"],
request,
{
"actions": actions,
"actor_json": request.args.get("actor")
or json.dumps(request.actor, indent=2),
"sorted_actions": sorted(self.ds.actions.keys()),
"has_debug_permission": True,
},
)
@ -694,22 +651,13 @@ class PermissionCheckView(BaseView):
# JSON API - action parameter is required
action = request.args.get("action")
if not action:
return Response.error("action parameter is required", 400)
return Response.json({"error": "action parameter is required"}, status=400)
parent = request.args.get("parent")
child = request.args.get("child")
actor = request.actor
actor_json = request.args.get("actor")
if actor_json is not None:
try:
actor = json.loads(actor_json)
except json.JSONDecodeError as ex:
return Response.error(f"Invalid actor JSON: {ex}", 400)
if actor is not None and not isinstance(actor, dict):
return Response.error("actor must be a JSON object or null", 400)
response, status = await _check_permission_for_actor(
self.ds, action, parent, child, actor
self.ds, action, parent, child, request.actor
)
return Response.json(response, status=status)
@ -1250,7 +1198,7 @@ class JumpView(BaseView):
match["display_name"] = row["display_name"]
matches.append(match)
return Response.json({"ok": True, "matches": matches, "truncated": truncated})
return Response.json({"matches": matches, "truncated": truncated})
class SchemaBaseView(BaseView):
@ -1272,7 +1220,7 @@ class SchemaBaseView(BaseView):
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json({"ok": True, **data}, headers=headers)
return Response.json(data, headers=headers)
def format_error_response(self, error_message, format_, status=404):
"""Format error response based on requested format."""
@ -1281,7 +1229,7 @@ class SchemaBaseView(BaseView):
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
error_body(error_message, status), status=status, headers=headers
{"ok": False, "error": error_message}, status=status, headers=headers
)
else:
return Response.text(error_message, status=status)
@ -1357,17 +1305,17 @@ class DatabaseSchemaView(SchemaBaseView):
database_name = request.url_vars["database"]
format_ = request.url_vars.get("format") or "html"
# Permission check comes first, so actors without view-database
# cannot distinguish existing databases from missing ones
# Check if database exists
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Check view-database permission
await self.ds.ensure_permission(
action="view-database",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
schema = await self.get_database_schema(database_name)
if format_ == "json":
@ -1401,9 +1349,6 @@ class TableSchemaView(SchemaBaseView):
actor=request.actor,
)
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Get schema for the table
db = self.ds.databases[database_name]
result = await db.execute(

View file

@ -2,10 +2,10 @@ from urllib.parse import parse_qsl, urlencode
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3, tilde_decode
from datasette.utils import sqlite3, tilde_decode
from datasette.utils.asgi import Response
from .base import BaseView
from .base import BaseView, _error
from .query_helpers import (
QueryValidationError,
_as_bool,
@ -34,14 +34,12 @@ class QueryParametersView(BaseView):
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-sql"], 403)
)
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
Response.error(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
@ -49,16 +47,8 @@ class QueryParametersView(BaseView):
try:
parameters = _derived_query_parameters(request.args.get("sql") or "")
except QueryValidationError as ex:
return _block_framing(Response.error([ex.message], ex.status))
return _block_framing(
Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"parameters": parameters,
}
)
)
return _block_framing(_error([ex.message], ex.status))
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
@ -92,12 +82,11 @@ class QueryListView(BaseView):
limit = _query_list_limit(
request.args.get("_size"),
default=20 if format_ == "html" else 50,
maximum=self.ds.max_returned_rows,
)
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
except QueryValidationError as ex:
return Response.error([ex.message], ex.status)
return _error([ex.message], ex.status)
page = await self.ds.list_queries(
database,
@ -122,9 +111,9 @@ class QueryListView(BaseView):
if key != "_next"
]
pairs.append(("_next", page.next))
next_url = self.ds.absolute_url(
request,
"{}?{}".format(request.path, urlencode(pairs)),
next_url = "{}?{}".format(
query_list_path,
urlencode(pairs),
)
current_filters = {
@ -210,6 +199,7 @@ class QueryListView(BaseView):
"queries": page.queries,
"next": page.next,
"next_url": next_url,
"has_more": page.has_more,
"limit": page.limit,
"show_private_note": any(query.is_private for query in page.queries),
"show_trusted_note": any(query.is_trusted for query in page.queries),
@ -308,30 +298,28 @@ class QueryCreateAnalyzeView(BaseView):
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-sql"], 403)
)
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need store-query"], 403)
)
return _block_framing(_error(["Permission denied: need store-query"], 403))
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
Response.error(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
analysis = await _query_create_analysis_data(self.ds, db, sql, request.actor)
analysis["unstable"] = UNSTABLE_API_MESSAGE
return _block_framing(Response.json(analysis))
return _block_framing(
Response.json(
await _query_create_analysis_data(self.ds, db, sql, request.actor)
)
)
class QueryStoreView(QueryCreateView):
@ -358,13 +346,13 @@ class QueryStoreView(QueryCreateView):
resource=DatabaseResource(db.name),
actor=request.actor,
):
return Response.error(["Permission denied: need execute-sql"], 403)
return _error(["Permission denied: need execute-sql"], 403)
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return Response.error(["Permission denied: need store-query"], 403)
return _error(["Permission denied: need store-query"], 403)
is_json = False
query_data = {}
@ -381,7 +369,7 @@ class QueryStoreView(QueryCreateView):
return await self._error_response(
request, db, query_data, ex.message, ex.status
)
return Response.error([ex.message], ex.status)
return _error([ex.message], ex.status)
prepared.pop("analysis")
name = prepared.pop("name")
@ -390,18 +378,13 @@ class QueryStoreView(QueryCreateView):
except sqlite3.IntegrityError as ex:
if not is_json and isinstance(query_data, dict):
return await self._error_response(request, db, query_data, str(ex), 400)
return Response.error([str(ex)], 400)
return _error([str(ex)], 400)
query = await self.ds.get_query(db.name, name)
assert query is not None
if is_json:
return Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
},
status=201,
{"ok": True, "query": stored_query_to_dict(query)}, status=201
)
self.ds.add_message(request, "Query saved", self.ds.INFO)
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
@ -415,20 +398,14 @@ class QueryDefinitionView(BaseView):
query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name)
if query is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="view-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied"], 403)
return Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
}
)
return _error(["Permission denied"], 403)
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
class QueryUpdateView(BaseView):
@ -439,17 +416,15 @@ class QueryUpdateView(BaseView):
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied: need update-query"], 403)
return _error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be updated using the API"], 403
)
return _error(["Trusted queries cannot be updated using the API"], 403)
try:
data, _ = await _json_or_form_payload(request)
@ -475,7 +450,7 @@ class QueryUpdateView(BaseView):
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
return Response.error([ex.message], ex.status)
return _error([ex.message], ex.status)
await self.ds.update_query(db.name, query_name, **update_kwargs)
if data.get("return"):
@ -532,32 +507,32 @@ class QueryEditView(BaseView):
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return Response.error(["Trusted queries cannot be edited"], 403)
return _error(["Trusted queries cannot be edited"], 403)
return await self._render_form(request, db, existing)
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied: need update-query"], 403)
return _error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return Response.error(["Trusted queries cannot be edited"], 403)
return _error(["Trusted queries cannot be edited"], 403)
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
return Response.error(["Invalid form submission"], 400)
return _error(["Invalid form submission"], 400)
sql = data.get("sql")
sql = existing.sql if sql is None else sql.strip()
title = data.get("title") or ""
@ -629,16 +604,12 @@ class QueryDeleteView(BaseView):
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
return await self.render(
["query_delete.html"],
request,
@ -653,17 +624,13 @@ class QueryDeleteView(BaseView):
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied: need delete-query"], 403)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
return _error(["Permission denied: need delete-query"], 403)
data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name)

View file

@ -22,11 +22,9 @@ from datasette.utils import (
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
append_querystring,
compound_keys_after_sql,
decode_write_json_rows,
format_bytes,
make_slot_function,
tilde_encode,
@ -43,24 +41,16 @@ from datasette.utils import (
urlsafe_components,
value_as_boolean,
InvalidSql,
WriteJsonValueError,
sqlite3,
)
from datasette.utils.asgi import (
BadRequest,
Forbidden,
NotFound,
PayloadTooLarge,
Request,
Response,
)
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
from datasette.filters import Filters
import sqlite_utils
from dataclasses import dataclass, field
from datasette.extras import ExtraScope
from . import Context, from_extra
from .base import BaseView, DatasetteError, stream_csv
from .base import BaseView, DatasetteError, _error, stream_csv
from .database import QueryView
from .table_create_alter import (
ALTER_TABLE_COLUMN_TYPES,
@ -72,7 +62,6 @@ from .table_create_alter import (
from .table_extras import (
TABLE_EXTRA_BUNDLES,
TableExtraContext,
count_is_truncated,
precompute_database_action_permissions,
precompute_table_action_permissions,
resolve_table_extras,
@ -107,6 +96,7 @@ class TableContext(Context):
human_description_en: str = from_extra()
is_view: bool = from_extra()
metadata: dict = from_extra()
next_url: str = from_extra()
primary_keys: list = from_extra()
private: bool = from_extra()
query: dict = from_extra()
@ -123,11 +113,6 @@ class TableContext(Context):
metadata={"help": "True if the data for this page was retrieved without errors"}
)
next: str = field(metadata={"help": "Pagination token for the next page, or None"})
next_url: str = field(
metadata={
"help": "Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`."
}
)
count_truncated: bool = field(
metadata={
"help": "True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit."
@ -220,7 +205,7 @@ class TableContext(Context):
)
table_insert_ui: dict = field(
metadata={
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys."
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
}
)
table_alter_ui: dict = field(
@ -495,15 +480,8 @@ async def _table_insert_ui(
):
return None
can_update = await datasette.allowed(
action="update-row",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
)
column_types_map = await datasette.get_column_types(database_name, table_name)
columns = []
bulk_columns = []
column_details = await db.table_column_details(table_name)
for column in column_details:
if column.hidden:
@ -514,40 +492,32 @@ async def _table_insert_ui(
and len(pks) == 1
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
)
column_type = column_types_map.get(column.name)
column_data = {
"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,
"is_auto_pk": is_auto_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
),
}
bulk_columns.append(column_data)
if is_auto_pk:
continue
columns.append(column_data)
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
),
}
)
data = {
return {
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
"bulkColumns": bulk_columns,
"primaryKeys": pks,
"maxInsertRows": datasette.setting("max_insert_rows"),
}
if can_update:
data["upsertPath"] = "{}/-/upsert".format(
datasette.urls.table(database_name, table_name)
)
return data
async def _table_alter_ui(
@ -955,7 +925,9 @@ class TableInsertView(BaseView):
def _errors(errors):
return None, errors, {}
# The body is parsed as JSON regardless of the Content-Type header
if not request.headers.get("content-type").startswith("application/json"):
# TODO: handle form-encoded data
return _errors(["Invalid content-type, must be application/json"])
try:
data = await request.json()
except json.JSONDecodeError as e:
@ -1039,7 +1011,7 @@ class TableInsertView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return Response.error([e.args[0]], 404)
return _error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
@ -1047,7 +1019,7 @@ class TableInsertView(BaseView):
# Table must exist (may handle table creation in the future)
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
return Response.error(["Table not found: {}".format(table_name)], 404)
return _error(["Table not found: {}".format(table_name)], 404)
if upsert:
# Must have insert-row AND upsert-row permissions
@ -1063,7 +1035,7 @@ class TableInsertView(BaseView):
actor=request.actor,
)
):
return Response.error(
return _error(
["Permission denied: need both insert-row and update-row"], 403
)
else:
@ -1073,32 +1045,25 @@ class TableInsertView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return Response.error(["Permission denied"], 403)
return _error(["Permission denied"], 403)
if not db.is_mutable:
return Response.error(["Database is immutable"], 403)
return _error(["Database is immutable"], 403)
pks = await db.primary_keys(table_name)
try:
rows, errors, extras = await self._validate_data(
request, db, table_name, pks, upsert
)
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
rows, errors, extras = await self._validate_data(
request, db, table_name, pks, upsert
)
if errors:
return Response.error(errors, 400)
try:
rows = decode_write_json_rows(rows)
except WriteJsonValueError as e:
return Response.error([str(e)], 400)
return _error(errors, 400)
# Validate column types
ct_errors = await _validate_column_types(
self.ds, database_name, table_name, rows
)
if ct_errors:
return Response.error(ct_errors, 400)
return _error(ct_errors, 400)
num_rows = len(rows)
@ -1112,16 +1077,14 @@ class TableInsertView(BaseView):
alter = extras.get("alter")
if upsert and (ignore or replace):
return Response.error(["Upsert does not support ignore or replace"], 400)
return _error(["Upsert does not support ignore or replace"], 400)
if replace and not await self.ds.allowed(
action="update-row",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return Response.error(
['Permission denied: need update-row to use "replace"'], 403
)
return _error(['Permission denied: need update-row to use "replace"'], 403)
initial_schema = None
if alter:
@ -1131,7 +1094,7 @@ class TableInsertView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return Response.error(["Permission denied for alter-table"], 403)
return _error(["Permission denied for alter-table"], 403)
# Track initial schema to check if it changed later
initial_schema = await db.execute_fn(
lambda conn: sqlite_utils.Database(conn)[table_name].schema
@ -1171,7 +1134,7 @@ class TableInsertView(BaseView):
try:
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
except Exception as e:
return Response.error([str(e)])
return _error([str(e)])
result = {"ok": True}
if should_return:
if upsert:
@ -1228,11 +1191,7 @@ class TableInsertView(BaseView):
)
)
return Response.json(
result,
status=200 if upsert else 201,
default=CustomJSONEncoder().default,
)
return Response.json(result, status=200 if upsert else 201)
class TableUpsertView(TableInsertView):
@ -1252,7 +1211,7 @@ class TableSetColumnTypeView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return Response.error([e.args[0]], 404)
return _error([e.args[0]], 404)
database_name = resolved.db.name
table_name = resolved.table
@ -1262,39 +1221,41 @@ class TableSetColumnTypeView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return Response.error(["Permission denied"], 403)
return _error(["Permission denied"], 403)
content_type = request.headers.get("content-type") or ""
if not content_type.startswith("application/json"):
return _error(["Invalid content-type, must be application/json"], 400)
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
return _error(["Invalid JSON: {}".format(e)], 400)
if not isinstance(data, dict):
return Response.error(["JSON must be a dictionary"], 400)
return _error(["JSON must be a dictionary"], 400)
invalid_keys = set(data.keys()) - {"column", "column_type"}
if invalid_keys:
return Response.error(
return _error(
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
400,
)
if "column" not in data:
return Response.error(['"column" is required'], 400)
return _error(['"column" is required'], 400)
column = data["column"]
if not isinstance(column, str):
return Response.error(['"column" must be a string'], 400)
return _error(['"column" must be a string'], 400)
if "column_type" not in data:
return Response.error(['"column_type" is required'], 400)
return _error(['"column_type" is required'], 400)
column_details = await self.ds._get_resource_column_details(
database_name, table_name
)
if column not in column_details:
return Response.error(["Column not found: {}".format(column)], 400)
return _error(["Column not found: {}".format(column)], 400)
column_type_data = data["column_type"]
if column_type_data is None:
@ -1311,11 +1272,11 @@ class TableSetColumnTypeView(BaseView):
)
if not isinstance(column_type_data, dict):
return Response.error(['"column_type" must be an object or null'], 400)
return _error(['"column_type" must be an object or null'], 400)
invalid_column_type_keys = set(column_type_data.keys()) - {"type", "config"}
if invalid_column_type_keys:
return Response.error(
return _error(
[
'Invalid column_type parameter: "{}"'.format(
'", "'.join(sorted(invalid_column_type_keys))
@ -1325,24 +1286,24 @@ class TableSetColumnTypeView(BaseView):
)
if "type" not in column_type_data:
return Response.error(['"column_type.type" is required'], 400)
return _error(['"column_type.type" is required'], 400)
column_type = column_type_data["type"]
if not isinstance(column_type, str):
return Response.error(['"column_type.type" must be a string'], 400)
return _error(['"column_type.type" must be a string'], 400)
config = column_type_data.get("config")
if config is not None and not isinstance(config, dict):
return Response.error(['"column_type.config" must be a dictionary'], 400)
return _error(['"column_type.config" must be a dictionary'], 400)
if column_type not in self.ds._column_types:
return Response.error(["Unknown column type: {}".format(column_type)], 400)
return _error(["Unknown column type: {}".format(column_type)], 400)
try:
await self.ds.set_column_type(
database_name, table_name, column, column_type, config
)
except ValueError as e:
return Response.error([str(e)], 400)
return _error([str(e)], 400)
return Response.json(
{
@ -1366,30 +1327,28 @@ class TableDropView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return Response.error([e.args[0]], 404)
return _error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
# Table must exist
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
return Response.error(["Table not found: {}".format(table_name)], 404)
return _error(["Table not found: {}".format(table_name)], 404)
if not await self.ds.allowed(
action="drop-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return Response.error(["Permission denied"], 403)
return _error(["Permission denied"], 403)
if not db.is_mutable:
return Response.error(["Database is immutable"], 403)
return _error(["Database is immutable"], 403)
confirm = False
try:
data = await request.json()
confirm = data.get("confirm")
except json.JSONDecodeError:
pass
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
if not confirm:
return Response.json(
@ -1566,7 +1525,7 @@ class TableAutocompleteView(BaseView):
and value_as_boolean(initial_arg)
)
if not q and not initial:
return Response.json({"ok": True, "rows": []})
return Response.json({"rows": []})
params = {
"q": q,
"like": "%{}%".format(_escape_like(q)),
@ -1629,13 +1588,10 @@ class TableAutocompleteView(BaseView):
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
)
except QueryInterrupted:
return Response.json({"ok": True, "rows": []})
return Response.json({"rows": []})
return Response.json(
{
"ok": True,
"rows": _autocomplete_response_rows(results.rows, pks, label_column),
}
{"rows": _autocomplete_response_rows(results.rows, pks, label_column)}
)
@ -2294,16 +2250,10 @@ async def table_view_data(
# Resolve extras
extras = extra_names_from_request(request)
if not extra_extras:
# Data formats reject unknown extras; the HTML path (which passes
# extra_extras={"_html"}) resolves internal extras of its own
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")):
extras.add("facet_results")
if request.args.get("_shape") == "object":
extras.add("primary_keys")
if "count" in extras:
extras.add("count_truncated")
if extra_extras:
extras.update(extra_extras)
@ -2358,7 +2308,6 @@ async def table_view_data(
data = {
"ok": True,
"next": next_value and str(next_value) or None,
"next_url": next_url,
}
data.update(
await resolve_table_extras(
@ -2383,7 +2332,7 @@ async def table_view_data(
data["rows"] = transformed_rows
if context_for_html_hack:
data["count_truncated"] = count_is_truncated(
data["count_truncated"] = _count_truncated_for_table_page(
datasette, db, database_name, table_name, count_sql, data.get("count")
)
data.update(extra_context_from_filters)
@ -2451,6 +2400,24 @@ async def table_view_data(
return data, rows[:page_size], columns, expanded_columns, sql, next_url
def _count_truncated_for_table_page(
datasette, db, database_name, table_name, count_sql, count
):
if count != db.count_limit + 1:
return False
if (
not db.is_mutable
and datasette.inspect_data
and count_sql == f"select count(*) from {table_name} "
):
try:
datasette.inspect_data[database_name]["tables"][table_name]["count"]
return False
except KeyError:
pass
return True
async def _next_value_and_url(
datasette,
db,

View file

@ -20,16 +20,14 @@ from datasette.column_types import SQLiteType
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils import (
decode_write_json_rows,
escape_sqlite,
get_outbound_foreign_keys,
table_column_details,
WriteJsonValueError,
)
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
from datasette.utils.asgi import NotFound, Response
from datasette.utils.sqlite import sqlite_hidden_table_names
from .base import BaseView
from .base import BaseView, _error
CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"]
CREATE_TABLE_SQLITE_TYPES = {
@ -269,11 +267,6 @@ async def _create_table_ui_context(
"databaseName": database_name,
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
"defaultExpressions": default_expression_options(),
"canInsertRows": await datasette.allowed(
action="insert-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
),
}
can_set_column_type = await datasette.allowed(
action="set-column-type",
@ -805,22 +798,20 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return Response.error(["Permission denied"], 403)
return _error(["Permission denied"], 403)
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
return _error(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict):
return Response.error(["JSON must be an object"])
return _error(["JSON must be an object"])
try:
create_request = CreateTableRequest.model_validate(data)
except ValidationError as e:
return Response.error(_create_table_pydantic_errors(e))
return _error(_create_table_pydantic_errors(e))
ignore = create_request.ignore
replace = create_request.replace
@ -832,7 +823,7 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return Response.error(["Permission denied: need update-row"], 403)
return _error(["Permission denied: need update-row"], 403)
table_name = create_request.table
table_exists = await db.table_exists(table_name)
@ -846,11 +837,7 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return Response.error(["Permission denied: need insert-row"], 403)
try:
rows = decode_write_json_rows(rows)
except WriteJsonValueError as e:
return Response.error([str(e)], 400)
return _error(["Permission denied: need insert-row"], 403)
alter = False
if rows:
@ -865,9 +852,7 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return Response.error(
["Permission denied: need alter-table"], 403
)
return _error(["Permission denied: need alter-table"], 403)
alter = True
pk = create_request.pk
@ -883,7 +868,7 @@ class TableCreateView(BaseView):
elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks):
bad_pks = True
if bad_pks:
return Response.error(["pk cannot be changed for existing table"])
return _error(["pk cannot be changed for existing table"])
pks = actual_pks
initial_schema = None
@ -926,7 +911,7 @@ class TableCreateView(BaseView):
try:
schema = await db.execute_write_fn(create_table, request=request)
except Exception as e:
return Response.error([str(e)])
return _error([str(e)])
if initial_schema is not None and initial_schema != schema:
await self.ds.track_event(
@ -1001,7 +986,7 @@ class DatabaseForeignKeyTargetsView(BaseView):
actor=request.actor,
)
if not (can_create_table or can_alter_table):
return Response.error(["Permission denied: need create-table"], 403)
return _error(["Permission denied: need create-table"], 403)
hidden_tables = await db.execute_fn(
lambda conn: set(sqlite_hidden_table_names(conn))
@ -1030,21 +1015,21 @@ class TableForeignKeySuggestionsView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return Response.error([e.args[0]], 404)
return _error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
if resolved.is_view:
return Response.error(["Cannot suggest foreign keys for a view"], 400)
return _error(["Cannot suggest foreign keys for a view"], 400)
if not await self.ds.allowed(
action="alter-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return Response.error(["Permission denied: need alter-table"], 403)
return _error(["Permission denied: need alter-table"], 403)
source_columns, targets, current_by_column = await db.execute_fn(
lambda conn: _foreign_key_suggestion_metadata(conn, table_name)
@ -1152,7 +1137,7 @@ class TableAlterView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return Response.error([e.args[0]], 404)
return _error([e.args[0]], 404)
db = resolved.db
database_name = db.name
@ -1163,25 +1148,27 @@ class TableAlterView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return Response.error(["Permission denied: need alter-table"], 403)
return _error(["Permission denied: need alter-table"], 403)
if not db.is_mutable:
return Response.error(["Database is immutable"], 403)
return _error(["Database is immutable"], 403)
content_type = request.headers.get("content-type") or ""
if not content_type.startswith("application/json"):
return _error(["Invalid content-type, must be application/json"], 400)
try:
data = await request.json()
except json.JSONDecodeError as e:
return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
return _error(["Invalid JSON: {}".format(e)], 400)
if not isinstance(data, dict):
return Response.error(["JSON must be a dictionary"], 400)
return _error(["JSON must be a dictionary"], 400)
try:
alter_request = AlterTableRequest.model_validate(data)
except ValidationError as e:
return Response.error(_pydantic_errors(e), 400)
return _error(_pydantic_errors(e), 400)
def alter_table(conn):
before_schema = _table_schema_from_conn(conn, table_name)
@ -1330,7 +1317,7 @@ class TableAlterView(BaseView):
alter_table, request=request
)
except Exception as e:
return Response.error([str(e)], 400)
return _error([str(e)], 400)
altered = before_schema != after_schema
if altered:

View file

@ -1,7 +1,6 @@
import itertools
from dataclasses import dataclass
from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted
from datasette.extras import Extra, ExtraExample, ExtraRegistry, ExtraScope, Provider
from datasette.plugins import pm
@ -141,39 +140,6 @@ class CountExtra(Extra):
return count
def count_is_truncated(datasette, db, database_name, table_name, count_sql, count):
if count != db.count_limit + 1:
return False
if (
not db.is_mutable
and datasette.inspect_data
and count_sql == f"select count(*) from {table_name} "
):
try:
datasette.inspect_data[database_name]["tables"][table_name]["count"]
return False
except KeyError:
pass
return True
class CountTruncatedExtra(Extra):
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
scopes = {ExtraScope.TABLE}
expensive = True
async def resolve(self, context, count):
return count_is_truncated(
context.datasette,
context.db,
context.database_name,
context.table_name,
context.count_sql,
count,
)
class FacetInstancesProvider(Provider):
scopes = {ExtraScope.TABLE}
@ -321,6 +287,21 @@ class HumanDescriptionEnExtra(Extra):
return human_description_en
class NextUrlExtra(Extra):
description = "Full URL for the next page of results"
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):
return context.next_url
class ColumnsExtra(Extra):
description = "List of column names returned by this table, row or query."
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
@ -361,55 +342,6 @@ class PrimaryKeysExtra(Extra):
return context.pks
def column_detail_as_json(column):
return {
"type": column.type,
"sqlite_type": SQLiteType.from_declared_type(column.type).value,
"notnull": bool(column.notnull),
"default": column.default_value,
"is_pk": bool(column.is_pk),
"pk_position": column.is_pk,
"hidden": column.hidden,
}
class ColumnDetailsExtra(Extra):
description = (
"SQLite schema details for columns in this table. The dictionary maps "
"column names to objects describing the schema for each column."
)
docs_note = (
"Each object has ``type`` as the declared type string returned by "
'SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the '
"normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, "
"``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` "
'as the raw SQL default expression string, such as ``"42"``, '
"``\"'hello'\"`` or ``\"datetime('now')\"``, or ``null`` if there is "
"no default; ``is_pk`` as a boolean; ``pk_position`` as the integer "
"primary key position reported by SQLite, or ``0`` for columns that "
"are not part of the primary key; and ``hidden`` as the integer value "
"reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for "
"normal columns, ``1`` for hidden virtual table columns, ``2`` for "
"virtual generated columns and ``3`` for stored generated columns."
)
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
examples = {
ExtraScope.ROW: ExtraExample(
"/fixtures/binary_data/1.json?_extra=column_details"
)
}
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context):
column_details = await context.datasette._get_resource_column_details(
context.database_name, context.table_name
)
return {
column_name: column_detail_as_json(column)
for column_name, column in column_details.items()
}
class ActionsExtra(Extra):
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
scopes = {ExtraScope.TABLE}
@ -1235,6 +1167,7 @@ TABLE_EXTRA_BUNDLES = {
"count",
"count_sql",
"human_description_en",
"next_url",
"metadata",
"query",
"columns",
@ -1263,17 +1196,16 @@ TABLE_EXTRA_BUNDLES = {
TABLE_EXTRA_CLASSES = [
CountExtra,
CountTruncatedExtra,
CountSqlExtra,
FacetResultsExtra,
FacetsTimedOutExtra,
SuggestedFacetsExtra,
FacetInstancesProvider,
HumanDescriptionEnExtra,
NextUrlExtra,
ColumnsExtra,
AllColumnsExtra,
PrimaryKeysExtra,
ColumnDetailsExtra,
DisplayColumnsAndRowsProvider,
DisplayColumnsExtra,
DisplayRowsExtra,

View file

@ -138,33 +138,6 @@ def decision_for_write_sql_operation(
),
)
)
if operation.operation == "create" and operation.target_type == "view":
if operation.database is None:
return UnsupportedWriteSqlOperation(unsupported_message)
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="create-view",
resource=DatabaseResource(database=operation.database),
),
)
)
if (
operation.operation == "drop"
and operation.target_type == "view"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="drop-view",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
if (
operation.operation == "alter"
and operation.target_type == "table"

View file

@ -45,12 +45,12 @@ Using the "root" actor
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user starts with **all permissions**: Datasette contributes a global allow rule for every action. More specific deny rules can still override that global rule.
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``)
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``)
* Debug permissions (``permissions-debug``, ``debug-menu``)
* Any custom permissions defined by plugins
@ -84,12 +84,12 @@ Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm t
Permissions
===========
Datasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access.
The key question the permissions system answers is this:
Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
Every permission decision can be understood in terms of those three values. Datasette implements the decisions using SQL, but you do not need to understand the generated SQL to configure or debug permissions.
**Actors** are :ref:`described above <authentication_actor>`.
An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.
@ -138,51 +138,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
How permissions are resolved
----------------------------
Permission rules describe an effect (``allow`` or ``deny``) at one of three levels:
``resource``
A specific child resource, such as the ``analytics/sales`` table.
``parent``
A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources.
``global``
Every resource for that action.
Datasette resolves matching rules from most specific to least specific:
#. Resource rules take precedence over parent and global rules.
#. Parent rules take precedence over global rules.
#. If both allow and deny rules match at the same level, deny takes precedence.
#. If no rule matches, access is denied.
This means a resource-level allow can provide an exception to a parent-level deny. It also means that two plugins which disagree at the same level resolve to deny.
.. list-table:: Permission rule examples
:header-rows: 1
* - Matching rules
- Result
- Explanation
* - Global allow
- Allow
- The global rule is the most specific matching rule.
* - Global allow, parent deny
- Deny
- The parent rule is more specific.
* - Parent deny, resource allow
- Allow
- The resource rule is more specific.
* - Resource allow and resource deny
- Deny
- Deny takes precedence at the same level.
* - No matching rules
- Deny
- Permissions default to deny when no rule applies.
The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules.
Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
@ -193,12 +149,12 @@ resources were allowed or denied. The combined sources are:
* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.
* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.
* The "root" user rule when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active. This is a global allow rule, so a more specific configuration deny can override it.
* The "root" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level.
* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.
Actor restrictions are applied after the allow/deny rules. They act as an additional allowlist: a restriction can remove access but cannot grant access that the actor did not already have. See :ref:`authentication_cli_create_token_restrict`.
Some actions have dependencies on other actions. These are evaluated as an ``AND`` condition. For example, ``execute-sql`` also requires ``view-database``: both decisions must be allowed for the final result to be allowed.
Datasette evaluates the SQL to determine if the requested ``resource`` is
included. Explicit deny rules returned by configuration or plugins will block
access even if other rules allowed it.
.. _authentication_permissions_allow:
@ -1035,9 +991,7 @@ The ``/-/create-token`` page cannot be accessed by actors that are authenticated
Datasette plugins that implement their own form of API token authentication should follow this convention.
If a request presents a token that a token handler recognizes but rejects - an invalid signature, a malformed payload or an expired token - Datasette responds with a ``401`` status, the :ref:`standard JSON error format <json_api_errors>` and a ``WWW-Authenticate: Bearer error="invalid_token"`` header. This means API clients can distinguish "your token needs to be renewed" (``401``) from "your token does not grant this permission" (``403``). A ``Bearer`` token that no registered handler recognizes at all is ignored, since it may be intended for an authentication plugin.
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting. Requests presenting a ``dstok_`` token while the feature is disabled receive a ``401``.
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting.
.. _authentication_cli_create_token:
@ -1189,23 +1143,11 @@ The debug tool at ``/-/permissions`` is available to any actor with the ``permis
datasette -s permissions.permissions-debug true data.db
The permission debug tools answer four different questions:
The page shows the permission checks that have been carried out by the Datasette instance.
Why was this decision allowed or denied?
Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions.
It also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect.
Which resources can the current actor access?
Use :ref:`AllowedResourcesView` to view an access map for a selected action.
Which raw rules did Datasette and its plugins contribute?
Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions.
Which checks has this Datasette instance performed recently?
Use ``/-/permissions`` to view recent permission activity.
These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration.
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
.. _AllowedResourcesView:
@ -1216,7 +1158,7 @@ The ``/-/allowed`` endpoint displays resources that the current actor can access
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead.
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair. Results are paginated: ``?_size=`` sets the page size (default 50, maximum 200, ``max`` for the maximum) and ``?_page=`` selects a page.
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair.
This endpoint is publicly accessible to help users understand their own permissions. The potentially sensitive ``reason`` field is only shown to users with the ``permissions-debug`` permission - it shows the plugins and explanatory reasons that were responsible for each decision.
@ -1229,7 +1171,7 @@ The ``/-/rules`` endpoint displays all permission rules (both allow and deny) fo
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead.
Pass ``?action=`` as a query parameter to specify which action to check. The ``?_size=`` and ``?_page=`` pagination parameters work the same as on ``/-/allowed``.
Pass ``?action=`` as a query parameter to specify which action to check.
This endpoint requires the ``permissions-debug`` permission.
@ -1238,20 +1180,11 @@ This endpoint requires the ``permissions-debug`` permission.
Permission check view
---------------------
The ``/-/check`` endpoint evaluates and explains a single actor, action and resource decision. The explanation includes:
* Every matching allow and deny rule, with its source and reason.
* The winning resource, parent or global scope.
* Rules ignored because a more specific rule matched, or because a deny won at the same scope.
* Actor restriction allowlists that included or excluded the resource.
* Additional actions required by the requested action.
* An explicit default-deny explanation when no rule matched.
The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information.
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. The interactive form also accepts actor JSON, allowing a hypothetical actor to be tested without signing in as that actor. The JSON endpoint accepts the same value using the ``actor`` query string parameter. Use ``actor=null`` to represent an anonymous actor.
This endpoint requires the ``permissions-debug`` permission. The hypothetical actor is used only for the decision being explained; access to the debug tool is checked against the actor who is actually signed in.
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource.
.. _authentication_ds_actor:
@ -1453,16 +1386,6 @@ create-table
Actor is allowed to create a database table.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_create_view:
create-view
-----------
Actor is allowed to create a database view.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
@ -1502,18 +1425,6 @@ Actor is allowed to drop a database table.
``table`` is the name of the table (string)
.. _actions_drop_view:
drop-view
---------
Actor is allowed to drop a database view.
``resource`` - ``datasette.resources.TableResource(database, table)``
``database`` is the name of the database (string)
``table`` is the name of the view (string)
.. _actions_execute_sql:
execute-sql

View file

@ -12,12 +12,7 @@ Datasette includes special handling for these binary values. The Datasette inter
:width: 311px
:alt: Screenshot showing download links next to binary data in the table view
.. _binary_json_format:
Binary values in JSON
---------------------
Binary data is represented in ``.json`` exports using Base64 encoding. Datasette uses this representation for every ``BLOB`` value, including binary values that could also be decoded as UTF-8 text.
Binary data is represented in ``.json`` exports using Base64 encoding.
https://latest.datasette.io/fixtures/binary_data.json?_shape=array
@ -44,48 +39,6 @@ https://latest.datasette.io/fixtures/binary_data.json?_shape=array
}
]
The same format can be used with the :ref:`JSON write API <json_api_write>`.
If a column value in a ``row``, ``rows`` or ``update`` object is a JSON object with exactly ``"$base64"`` set to ``true`` and an ``"encoded"`` string, Datasette will decode that Base64 string and store the resulting bytes:
.. code-block:: json
{
"data": {
"$base64": true,
"encoded": "FRwCx60F/g=="
}
}
This works for inserts, upserts and updates. It also works when creating a table from example ``row`` or ``rows`` data: Datasette decodes the value before inferring the schema, allowing that column to be created as a ``BLOB`` column.
To store a JSON object with that exact shape literally, wrap it in a ``"$raw"`` object:
.. code-block:: json
{
"data": {
"$raw": {
"$base64": true,
"encoded": "FRwCx60F/g=="
}
}
}
``"$raw"`` unwraps exactly one layer. To store a literal ``"$raw"`` object containing a Base64 object, wrap it again:
.. code-block:: json
{
"data": {
"$raw": {
"$raw": {
"$base64": true,
"encoded": "FRwCx60F/g=="
}
}
}
}
.. _binary_linking:
Linking to binary downloads

View file

@ -4,77 +4,6 @@
Changelog
=========
.. _v1_0_a37:
1.0a37 (2026-07-14)
-------------------
Performance improvement for SQL-backed permission checks, plus an improved permission debugging interface.
- SQL used to resolve permission checks now aggregates permission rules before joining them to resources, improving performance on instances with large schemas. (:issue:`2832`)
- The :ref:`PermissionCheckView` permission debugger now explains why a decision was allowed or denied, including the matching rules. The interactive form can also test a hypothetical actor supplied as JSON, and the :ref:`permissions documentation <authentication_permissions_explained>` now describes resolution rules in more detail. (:issue:`2841`)
- :ref:`db.execute_write(sql, ..., transaction=True) <database_execute_write>` has a new ``transaction=`` parameter, which can be set to ``False`` for statements such as ``VACUUM`` that cannot run inside a transaction. Write tasks now start their transactions using ``BEGIN IMMEDIATE``, which also ensures that writes are rolled back if the task fails. (:issue:`2831`)
- Refreshing a database's schema in Datasette's internal catalog is now performed as a single atomic operation. (:issue:`2831`)
- Fixed schema introspection, table pages, facets and table counts for tables with names containing a ``]`` character. Thanks, `TowyTowy <https://github.com/TowyTowy>`__. (:issue:`2431`, :pr:`2846`)
- ``/-/plugins.json`` once again returns a top-level JSON array of plugin objects, reverting the object envelope introduced in 1.0a36. This should fix a large number of trivial test failures in existing plugins. (:issue:`2842`, :pr:`2843`)
.. _v1_0_a36:
1.0a36 (2026-07-07)
-------------------
The signature features of this alpha are new UIs for **inserting multiple rows at once** (from TSV, CSV or JSON) and for **creating a table from rows**, plus a large number of small **JSON API consistency fixes** in preparation for a 1.0 stable release.
- Table pages now offer an "Insert multiple rows" mode in the row insertion dialog. This accepts pasted TSV, CSV or JSON, previews the parsed rows before inserting them, validates unknown columns as data is pasted and displays omitted auto integer primary keys as ``auto`` in the preview. (:pr:`2813`)
- The bulk insert UI can skip rows with existing primary keys, or update existing rows and insert new rows using the existing ``/<database>/<table>/-/upsert`` API when the actor has both :ref:`insert-row <actions_insert_row>` and :ref:`update-row <actions_update_row>` permissions. (:pr:`2813`)
- The "Create table" dialog now includes a "Create table from data" mode. Paste TSV, CSV or JSON rows to preview inferred columns and types, choose the table name and primary key, then create the table and insert those rows in one step. (:pr:`2813`)
- Datasette's JSON APIs now consistently encode every ``BLOB`` value using the documented :ref:`binary value JSON format <binary_json_format>`, even when the bytes could be decoded as UTF-8 text. (:issue:`2806`, :pr:`2822`)
- The insert and edit row dialogs now provide a dedicated control for ``BLOB`` values. Existing binary values are shown by byte size, image values under 10MB are previewed as thumbnails, and replacements can be attached, dropped or pasted into the control. (:issue:`2806`, :pr:`2822`)
- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata.
- POST bodies that Datasette reads fully into memory - such as JSON submitted to the write API - are now capped by the new :ref:`setting_max_post_body_bytes` setting, defaulting to 2MB. Oversized requests are rejected with an HTTP 413 error as soon as the limit is exceeded, protecting smaller servers from memory exhaustion. File uploads are unaffected - ``request.form()`` streams those to disk and has its own separate limits. (:issue:`2823`)
- Row pages for tables with compound primary keys now return a ``400`` error instead of a ``500`` error when the URL row identifier does not contain the correct number of primary key values. Thanks, `Zain Dana Harper <https://github.com/HarperZ9>`__. (:issue:`2811`, :pr:`2815`)
- The :ref:`execute-write-sql <actions_execute_write_sql>` interface now supports ``CREATE VIEW`` and ``DROP VIEW`` statements, gated by the new :ref:`create-view <actions_create_view>` and :ref:`drop-view <actions_drop_view>` permissions. (:issue:`2819`, :pr:`2818`)
- Saved-query SQL analysis now handles recursive CTEs, fixing a bug where storing a valid read-only recursive query could be disabled by SQLite's internal ``SQLITE_RECURSIVE`` authorizer callback. (:issue:`2809`, :pr:`2812`)
- ``named_parameters()`` now correctly ignores SQLite comment markers that appear inside string literals, so query forms no longer drop later ``:named`` parameters from SQL such as ``select '--' || :name``. Thanks, `JSap0914 <https://github.com/JSap0914>`__. (:pr:`2783`)
- Datasette's internal database schema is now managed using `sqlite-utils migrations <https://sqlite-utils.datasette.io/en/stable/python-api.html#migrations>`__, using the new dependency on ``sqlite-utils>=4.0``. (:issue:`2827`)
- ``datasette.utils.CustomJSONEncoder`` is now documented as a public API for plugins that need to serialize Datasette values to JSON. Thanks, `Chris Amico <https://github.com/eyeseast>`__. (:issue:`1983`, :pr:`1996`)
This release also includes the results of a `detailed consistency review <https://github.com/simonw/datasette/pull/2824>`__ of Datasette's JSON API in preparation for the 1.0 stable release. Several of these changes are backwards-incompatible with previous 1.0 alphas. The new :ref:`API stability documentation <json_api_stability>` describes exactly which parts of the JSON API are covered by the 1.0 stability promise.
JSON API: breaking changes
~~~~~~~~~~~~~~~~~~~~~~~~~~
- JSON error responses now use a single canonical format across every endpoint: ``{"ok": false, "error": "...", "errors": [...], "status": 400}``. The ``error`` key joins all error messages together, ``errors`` is the full list of messages and ``status`` always matches the HTTP status code. The legacy ``title`` key is no longer included in JSON errors (it remains available to the HTML error template), and endpoints that previously returned bare ``{"error": ...}`` objects have been updated. See :ref:`json_api_errors`.
- Every JSON object success response now includes ``"ok": true``, including introspection endpoints such as ``/-/versions`` and ``/-/settings``.
- ``/-/plugins.json``, ``/-/databases.json`` and ``/-/actions.json`` now return objects - ``{"ok": true, "plugins": [...]}`` and equivalents - instead of top-level JSON arrays, so these responses can gain additional keys in the future without a breaking change. The ``datasette plugins`` CLI command still outputs a plain array.
- ``/-/databases`` now only lists databases the current actor is allowed to view. It previously listed every attached database, including their filesystem paths, to any actor with ``view-instance``.
- Requests with an invalid or expired ``Authorization: Bearer`` token now receive a ``401`` status with the standard error body and a ``WWW-Authenticate: Bearer error="invalid_token"`` header, instead of being silently treated as unauthenticated. Bearer tokens that no registered token handler recognizes are still ignored, so authentication plugins with their own token formats keep working. Plugin :ref:`token handlers <plugin_hook_register_token_handler>` can raise the new ``datasette.TokenInvalid`` exception to trigger the same behavior.
- Permission errors for JSON requests now return the standard JSON error format with a ``403`` status. The default forbidden handling previously rendered an HTML error page even for ``.json`` requests.
- ``POST`` to a write canned query now returns a ``400`` error when the SQL fails to execute, instead of a ``200`` status with ``"ok": false`` in the body. The error response includes the standard error keys plus a ``"redirect"`` key.
- The :ref:`row update API <RowUpdateView>` with ``"return": true`` now responds with a ``"rows"`` list, matching insert and upsert, instead of a singular ``"row"`` object.
- Row delete write failures - such as a constraint violation raised by a trigger - now return ``400`` instead of ``500``, matching the other write endpoints.
- ``/<database>/-/query.json`` with a missing or blank ``?sql=`` parameter now returns a ``400`` error, as the CSV format already did, instead of a ``200`` with empty rows.
- Unknown ``?_extra=`` names now return a ``400`` error for JSON and other data formats, instead of being silently ignored. HTML pages continue to ignore unknown names.
- Table JSON responses now include ``next_url`` alongside ``next`` by default - both are ``null`` on the final page. The now-redundant ``?_extra=next_url`` parameter has been removed.
- The stored query list JSON no longer includes ``has_more`` - ``"next": null`` is the end-of-results signal across the whole API. This change also uncovered and fixed a bug where the query list ``next_url`` pointed at the HTML page and was a relative path; it is now an absolute URL that preserves the requested format.
- Stored query JSON objects no longer duplicate the list of parameter names as both ``params`` and ``parameters`` - only ``parameters`` remains. The query create and update APIs no longer accept ``params`` as an input alias either; ``params`` is still the documented key for :ref:`queries defined in configuration <queries_named_parameters>`.
- Page size parameters are now consistent across the API: the stored query lists accept ``?_size=max`` and return a ``400`` error for values over the maximum instead of silently clamping them, and the ``/-/allowed`` and ``/-/rules`` permission debug endpoints renamed their ``page`` and ``page_size`` parameters to ``_page`` and ``_size``, matching the underscore grammar used by every other Datasette system parameter.
- ``/-/threads`` now requires the ``permissions-debug`` permission, since it exposes runtime internals such as file paths. It previously only required ``view-instance``.
- Trusted stored queries - those defined in configuration - can no longer be deleted through the JSON API or web interface, matching the existing restriction on editing them.
- The ``/<database>/-/schema`` endpoints now check the ``view-database`` permission before checking whether the database exists, so unauthorized actors can no longer probe for the existence of databases.
- SQL time limit errors in JSON responses are now a plain text message. The error string previously embedded an HTML fragment.
- The undocumented homepage JSON at ``/.json`` now returns ``databases`` as a list of objects rather than an object keyed by database name, matching every other collection in the API.
- The legacy ``.jsono`` format extension, long since superseded by ``?_shape=``, has been removed.
JSON API: other improvements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- The :ref:`write API <json_api_write>` endpoints now parse the request body as JSON regardless of the ``Content-Type`` header, so ``curl -d`` invocations work without remembering to set it. Invalid JSON is a ``400`` error. Cross-site request forgery remains prevented by Datasette's ``Origin`` and ``Sec-Fetch-Site`` checks. This also fixes a ``500`` error from the insert API when the ``Content-Type`` header was missing entirely.
- New ``Response.error(messages, status=400)`` helper for plugins that need to return a JSON error in Datasette's standard format. See :ref:`internals_response`.
- New ``count_truncated`` extra for table JSON, included automatically whenever ``count`` is requested. ``true`` means the count reached Datasette's counting limit and the real number of rows may be higher. See :ref:`json_api_extra`.
- JSON endpoints that are not part of the documented stable API now declare themselves with an ``"unstable"`` key in their responses.
- New documentation covering the grammar for :ref:`boolean query string arguments <json_api_table_arguments>`, the reason :ref:`upsert <TableUpsertView>` returns ``200`` where insert returns ``201``, and advice for plugin authors on :ref:`naming secret configuration keys <plugins_configuration_secret>` so that ``/-/config`` redacts them automatically.
.. _v1_0_a35:
1.0a35 (2026-06-23)

View file

@ -244,9 +244,6 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam
custom query (default=1000)
max_insert_rows Maximum rows that can be inserted at a time using
the bulk insert API (default=100)
max_post_body_bytes Maximum size in bytes for a POST body read into
memory, e.g. JSON API requests - set 0 to disable
this limit (default=2097152)
num_sql_threads Number of threads in the thread pool for
executing SQLite queries (default=3)
sql_time_limit_ms Time limit for a SQL query in milliseconds

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -0,0 +1,144 @@
#!/bin/bash
# Recreate docs/datasette-modal-example.png
#
# Takes a screenshot of the <datasette-modal> example plugin from the
# "Modal dialogs" section of docs/javascript_plugins.rst, running against
# a temporary Datasette instance that this script starts and stops.
#
# Requirements:
# - datasette importable by $PYTHON (e.g. "pip install -e ." in this repo)
# - uv, for "uvx shot-scraper" and the Pillow-based PNG quantization step
# (shot-scraper needs a Playwright browser: "uvx shot-scraper install")
#
# Environment variable overrides:
# PYTHON Python command (default: python3)
# SHOT_SCRAPER shot-scraper command (default: uvx shot-scraper)
# PORT port for the temporary server (default: 8574)
set -euo pipefail
read -r -a PYTHON_CMD <<< "${PYTHON:-python3}"
read -r -a SHOT_SCRAPER_CMD <<< "${SHOT_SCRAPER:-uvx shot-scraper}"
PORT="${PORT:-8574}"
docs_dir="$(cd "$(dirname "$0")" && pwd)"
output="$docs_dir/datasette-modal-example.png"
tmp_dir=$(mktemp -d)
server_pid=""
cleanup() {
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
fi
rm -rf "$tmp_dir"
}
trap cleanup EXIT
# A small demo database for the page shown behind the dialog
"${PYTHON_CMD[@]}" - "$tmp_dir/demo.db" <<'EOF'
import sqlite3
import sys
conn = sqlite3.connect(sys.argv[1])
conn.executescript(
"""
create table plugins (id integer primary key, name text, description text);
insert into plugins (name, description) values
('datasette-cluster-map', 'Renders a map of geographic data'),
('datasette-vega', 'Visualize data with Vega charts'),
('datasette-graphql', 'GraphQL endpoint for Datasette');
"""
)
conn.commit()
EOF
# The example plugin code from the "Modal dialogs" section of
# docs/javascript_plugins.rst, injected via extra_body_script()
mkdir "$tmp_dir/plugins"
cat > "$tmp_dir/plugins/modal_example.py" <<'EOF'
from datasette import hookimpl
@hookimpl
def extra_body_script():
return {
"script": """
document.addEventListener("datasette_init", function (event) {
const manager = event.detail;
const modal = manager.createModal({
id: "my-plugin-dialog",
className: "my-plugin-dialog",
title: "My plugin",
content: `
<p style="padding: 16px 24px">Hello from a plugin!</p>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary my-plugin-save">Save</button>
</div>
`,
});
if (!modal) {
return; // Browser does not support <dialog>
}
// Open it later, for example from a button click:
// modal.showModal({trigger: button});
});
"""
}
EOF
"${PYTHON_CMD[@]}" -m datasette "$tmp_dir/demo.db" \
--plugins-dir "$tmp_dir/plugins" --port "$PORT" &
server_pid=$!
# Wait for the server to start responding
for _ in $(seq 1 50); do
if curl -s -o /dev/null "http://127.0.0.1:$PORT/"; then
break
fi
sleep 0.2
done
# Open the modal with animations disabled, blur the auto-focused Cancel
# button so no focus ring appears, then take a retina viewport shot
"${SHOT_SCRAPER_CMD[@]}" shot "http://127.0.0.1:$PORT/demo/plugins" \
--javascript '
new Promise((resolve) => {
const style = document.createElement("style");
style.textContent =
"dialog, dialog::backdrop { animation: none !important; }";
document.head.appendChild(style);
const modal = document
.getElementById("my-plugin-dialog")
.closest("datasette-modal");
modal.showModal();
if (document.activeElement) {
document.activeElement.blur();
}
setTimeout(resolve, 500);
})' \
--width 760 --height 460 --retina \
--output "$tmp_dir/shot.png" --silent
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
server_pid=""
# Quantize to an 8-bit palette PNG to roughly halve the file size
cat > "$tmp_dir/quantize.py" <<'EOF'
import sys
from PIL import Image
img = Image.open(sys.argv[1]).convert("RGB")
img.quantize(
colors=256,
method=Image.Quantize.MEDIANCUT,
dither=Image.Dither.FLOYDSTEINBERG,
).save(sys.argv[2], optimize=True)
EOF
uv run --no-project --with pillow python \
"$tmp_dir/quantize.py" "$tmp_dir/shot.png" "$output"
echo "Wrote $output"

View file

@ -17,6 +17,13 @@ If you want to start making contributions to the Datasette project by installing
Basic installation
==================
.. _installation_datasette_desktop:
Datasette Desktop for Mac
-------------------------
`Datasette Desktop <https://datasette.io/desktop>`__ is a packaged Mac application which bundles Datasette together with Python and allows you to install and run Datasette directly on your laptop. This is the best option for local installation if you are not comfortable using the command line.
.. _installation_homebrew:
Using Homebrew

View file

@ -52,9 +52,6 @@ The request object is passed to various plugin hooks. It represents an incoming
``.actor`` - dictionary (str -> Any) or None
The currently authenticated actor (see :ref:`actors <authentication_actor>`), or ``None`` if the request is unauthenticated.
``.max_post_body_bytes`` - integer
The maximum number of bytes ``await request.post_body()`` will read into memory, or ``0`` for no limit. Set from the :ref:`setting_max_post_body_bytes` setting (default 2MB) for requests created by Datasette. Can be passed to the ``Request`` constructor as a keyword argument.
The object also has the following awaitable methods:
``await request.form(files=False, ...)`` - FormData
@ -112,11 +109,9 @@ The object also has the following awaitable methods:
``await request.json()`` - Any
Returns the parsed JSON body of a request submitted by ``POST``.
``await request.post_body(max_bytes=None)`` - bytes
``await request.post_body()`` - bytes
Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.
The body is read fully into memory, capped at ``request.max_post_body_bytes`` - which Datasette sets from the :ref:`setting_max_post_body_bytes` setting (default 2MB). Bodies that exceed the limit raise a ``datasette.PayloadTooLarge`` exception, which Datasette turns into an HTTP 413 error response. Pass ``max_bytes=`` to override the limit for a specific call, or ``max_bytes=0`` to disable it. ``request.post_vars()`` and ``request.json()`` read the body through this method, so the same limit applies to them.
And a class method that can be used to create fake request objects for use in tests:
``fake(path_with_query_string, method="GET", scheme="http", url_vars=None)``
@ -284,7 +279,7 @@ For example:
content_type="application/xml; charset=utf-8",
)
The quickest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)``, ``Response.error(...)`` or ``Response.redirect(...)`` helper methods:
The quickest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)`` or ``Response.redirect(...)`` helper methods:
.. code-block:: python
@ -295,8 +290,6 @@ The quickest way to create responses is using the ``Response.text(...)``, ``Resp
text_response = Response.text(
"This will become utf-8 encoded text"
)
# A JSON error in Datasette's standard error format:
error_response = Response.error("Cannot do that", 400)
# Redirects are served as 302, unless you pass status=301:
redirect_response = Response.redirect(
"https://latest.datasette.io/"
@ -306,8 +299,6 @@ Each of these responses will use the correct corresponding content-type - ``text
Each of the helper methods take optional ``status=`` and ``headers=`` arguments, documented above.
``Response.error(messages, status=400)`` returns a JSON error in the :ref:`standard Datasette error format <json_api_errors>`. ``messages`` can be a single string or a list of strings. Use this for JSON-only endpoints; if your error should content-negotiate between JSON and HTML, raise ``Forbidden``, ``NotFound``, ``BadRequest`` or ``DatasetteError`` instead and Datasette's error handling will build the appropriate response.
.. _internals_response_asgi_send:
Returning a response with .asgi_send(send)
@ -2023,8 +2014,8 @@ Example usage:
.. _database_execute_write:
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True)
--------------------------------------------------------------------------------------------------------------------------
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10)
--------------------------------------------------------------------------------------------------------
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
@ -2059,9 +2050,7 @@ If you need to retrieve every row returned by a statement, pass ``return_all=Tru
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
Each call to ``execute_write()`` will be executed inside a transaction. Pass
``transaction=False`` for statements such as ``VACUUM`` that cannot run inside
a transaction.
Each call to ``execute_write()`` will be executed inside a transaction.
.. _database_execute_write_script:
@ -2365,14 +2354,6 @@ The internal database schema is as follows:
.. code-block:: sql
CREATE TABLE "_sqlite_migrations" (
"id" INTEGER PRIMARY KEY,
"migration_set" TEXT,
"name" TEXT,
"applied_at" TEXT
);
CREATE UNIQUE INDEX "idx__sqlite_migrations_migration_set_name"
ON "_sqlite_migrations" ("migration_set", "name");
CREATE TABLE catalog_databases (
database_name TEXT PRIMARY KEY,
path TEXT,

View file

@ -7,10 +7,6 @@ Datasette includes some pages and JSON API endpoints for introspecting the curre
Each of these pages can be viewed in your browser. Add ``.json`` to the URL to get back the contents as JSON.
JSON responses that return an object include an ``"ok": true`` key, consistent with the rest of the :ref:`JSON API <json_api>`.
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, with the exception of the debug endpoints ``/-/threads`` and ``/-/actions``, whose shapes may change in future releases.
.. _JsonDataView_metadata:
/-/metadata
@ -41,7 +37,6 @@ Shows the version of Datasette, Python and SQLite. `Versions example <https://la
.. code-block:: json
{
"ok": true,
"datasette": {
"version": "0.60"
},
@ -102,7 +97,6 @@ Shows the :ref:`settings` for this instance of Datasette. `Settings example <htt
.. code-block:: json
{
"ok": true,
"default_facet_size": 30,
"default_page_size": 100,
"facet_suggest_time_limit_ms": 50,
@ -121,7 +115,6 @@ Shows the :ref:`configuration <configuration>` for this instance of Datasette. T
.. code-block:: json
{
"ok": true,
"settings": {
"template_debug": true,
"trace_debug": true,
@ -136,47 +129,20 @@ Any keys that include the one of the following substrings in their names will be
/-/databases
------------
Shows currently attached databases that the current actor is allowed to view, based on the ``view-database`` permission. `Databases example <https://latest.datasette.io/-/databases>`_:
Shows currently attached databases. `Databases example <https://latest.datasette.io/-/databases>`_:
.. code-block:: json
{
"ok": true,
"databases": [
{
"hash": null,
"is_memory": false,
"is_mutable": true,
"name": "fixtures",
"path": "fixtures.db",
"size": 225280
}
]
}
.. _JsonDataView_actions:
/-/actions
----------
Shows all actions registered with the permission system, including those added by plugins. Requires the ``permissions-debug`` permission.
.. code-block:: json
{
"ok": true,
"actions": [
{
"name": "view-instance",
"abbr": "vi",
"description": "View Datasette instance",
"takes_parent": false,
"takes_child": false,
"resource_class": null,
"also_requires": null
}
]
}
[
{
"hash": null,
"is_memory": false,
"is_mutable": true,
"name": "fixtures",
"path": "fixtures.db",
"size": 225280
}
]
.. _JumpView:
@ -194,7 +160,6 @@ The endpoint supports a ``?q=`` query parameter for filtering items by name.
.. code-block:: json
{
"ok": true,
"matches": [
{
"name": "fixtures",
@ -223,7 +188,6 @@ Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
.. code-block:: json
{
"ok": true,
"matches": [
{
"name": "fixtures: facetable",
@ -251,12 +215,11 @@ Without those query string arguments, the page lists up to five tables with dete
/-/threads
----------
Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``permissions-debug`` permission, since it exposes runtime internals. `Threads example <https://latest.datasette.io/-/threads>`_:
Shows details of threads and ``asyncio`` tasks. `Threads example <https://latest.datasette.io/-/threads>`_:
.. code-block:: json
{
"ok": true,
"num_threads": 2,
"threads": [
{
@ -288,7 +251,6 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti
.. code-block:: json
{
"ok": true,
"actor": {
"id": 1,
"username": "some-user"

View file

@ -49,9 +49,165 @@ The ``datasetteManager`` object
``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.
``createModal(options)``
Creates a :ref:`\<datasette-modal\> <javascript_plugins_datasette_modal>` element, appends it to the page and returns it. Returns ``null`` in browsers without ``<dialog>`` support.
``selectors`` - object
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
.. _javascript_plugins_datasette_modal:
Modal dialogs: the datasette-modal element
------------------------------------------
Datasette provides a ``<datasette-modal>`` Web Component that renders a modal dialog in the same visual style as Datasette's own dialogs - the create/alter table dialogs, the insert/edit/delete row dialogs, the column chooser, the mobile column actions sheet and the ``/`` jump menu are all built on it.
The element, and the ``DatasetteModal`` class exposed as ``window.DatasetteModal``, are a stable public API for plugins. Plugins that need a modal dialog should use this component rather than building their own, so their dialogs automatically match Datasette's styling, keyboard handling and accessibility behavior.
The component wraps a native ``<dialog>`` element and provides:
- Datasette's standard modal frame: sizing, rounded corners, backdrop, open/close animations and an optional header with a title and a "meta" chip
- Close on backdrop click and on the ``Escape`` key
- A ``busy`` property that blocks the user from dismissing the dialog while a save or delete is in flight
- A ``closeGuard`` hook for "Discard unsaved changes?" style confirmation prompts
- Focus restoration to the triggering element when the dialog closes
- ``datasette-modal-open`` and ``datasette-modal-close`` events
Creating a modal
~~~~~~~~~~~~~~~~
The simplest way to create a modal from a plugin is ``datasetteManager.createModal()`` (or the equivalent ``window.DatasetteModal.create()``), which creates the element, appends it to ``document.body`` and returns it:
.. code-block:: javascript
document.addEventListener("datasette_init", function (event) {
const manager = event.detail;
const modal = manager.createModal({
id: "my-plugin-dialog",
className: "my-plugin-dialog",
title: "My plugin",
content: `
<p style="padding: 16px 24px">Hello from a plugin!</p>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary my-plugin-save">Save</button>
</div>
`,
});
if (!modal) {
return; // Browser does not support <dialog>
}
// Open it later, for example from a button click:
// modal.showModal({trigger: button});
});
The ``data-modal-cancel`` attribute on the Cancel button is a declarative shortcut: clicking any element inside the modal that carries this attribute calls ``modal.requestClose("cancel")``, so Cancel buttons need no JavaScript wiring. Like other dismissals it is blocked while the modal is ``busy`` and consults ``closeGuard``, both described below.
Calling ``modal.showModal()`` then displays the dialog:
.. Regenerate this screenshot with docs/generate-datasette-modal-example.sh
.. image:: datasette-modal-example.png
:alt: A modal dialog titled "My plugin" containing the text "Hello from a plugin!" and Cancel and Save buttons, shown over a dimmed and blurred Datasette table page.
:width: 550px
``createModal(options)`` / ``DatasetteModal.create(options)`` accepts:
``id`` - string, optional
``id`` attribute for the inner ``<dialog>`` element.
``className`` - string, optional
``class`` attribute for the inner ``<dialog>``, useful for scoping custom CSS.
``title`` - string, optional
Text for the standard header title. If omitted no header is created and the modal content fills the whole dialog.
``meta`` - string, optional
Text for the small "meta" chip shown on the right of the header, for example ``"3 of 12 selected"``.
``titleId`` - string, optional
``id`` for the generated title element. Defaults to ``"<id>-title"``.
``labelledBy`` / ``describedBy`` - strings, optional
Explicit ``aria-labelledby`` / ``aria-describedby`` values for the dialog. By default the dialog is labelled by the generated title element.
``content`` - string or DOM node, optional
Content placed inside the dialog, after the header. By convention this ends with a ``<div class="modal-footer">`` containing an optional ``<span class="footer-info">`` and buttons using the ``btn`` classes shown above.
``parent`` - element, optional
Element to append the modal to. Defaults to ``document.body``.
The element can also be used declaratively in a template - light DOM children become the dialog content:
.. code-block:: html
<datasette-modal dialog-id="my-dialog" modal-title="My dialog">
<p>Dialog content</p>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary">OK</button>
</div>
</datasette-modal>
The ``dialog-id``, ``dialog-class``, ``modal-title``, ``modal-meta``, ``title-id``, ``labelled-by`` and ``described-by`` attributes correspond to the options above. ``modal-title`` and ``modal-meta`` can be updated at any time and the header will update to match.
Properties, methods and events
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``DatasetteModal.supported`` - boolean, static
True if the browser supports everything the component needs. ``createModal()`` returns ``null`` when this is false.
``modal.dialog`` - ``HTMLDialogElement``
The underlying native dialog element. Query this for elements inside the modal.
``modal.open`` - boolean
True while the modal is open.
``modal.showModal(options)``
Opens the modal. ``options.trigger`` is the element that focus should return to when the modal closes - it defaults to the element that was focused when ``showModal()`` was called.
``modal.close(options)``
Closes the modal unconditionally, skipping ``busy`` and ``closeGuard``. Pass ``{restoreFocus: false}`` to leave focus where it is, for example when the page is about to navigate.
``modal.requestClose(reason)``
Asks the modal to close on the user's behalf, respecting ``busy`` and ``closeGuard``. Returns true if the modal closed. Backdrop clicks and the ``Escape`` key call this internally with reasons ``"backdrop"`` and ``"escape"``, and clicking an element with a ``data-modal-cancel`` attribute calls it with ``"cancel"``. Cancel buttons can either carry that attribute or call this method directly.
``modal.busy`` - boolean
While true, ``Escape``, backdrop clicks and ``requestClose()`` will not close the modal. Set this while an operation is in flight. A ``busy`` attribute is reflected on the element for CSS.
``modal.closeGuard`` - function or null
If set, called with the reason string whenever the user tries to dismiss the modal. Return false to keep the modal open - for example after a ``confirm("Discard unsaved changes?")`` returns false. Not consulted by direct ``close()`` calls.
``modal.setTitle(text)`` / ``modal.setMeta(text)``
Update the header title and meta chip. Setting the meta text to ``""`` hides the chip. ``modal.titleElement`` and ``modal.metaElement`` expose the underlying elements for richer markup.
The element dispatches two bubbling events:
``datasette-modal-open``
Fired when the modal opens.
``datasette-modal-close``
Fired when the modal closes, however that happened. Use this to reset dialog state.
Styling
~~~~~~~
Modal content uses light DOM, so page-level CSS and plugin CSS can style it directly. The component provides the frame plus styles for these conventional class names inside it: ``.modal-header``, ``.modal-title``, ``.modal-meta``, ``.modal-footer``, ``.footer-info`` and the button classes ``.btn``, ``.btn-primary``, ``.btn-ghost`` and ``.btn-danger``.
Sizing can be customized with CSS custom properties on the dialog:
.. code-block:: css
dialog.my-plugin-dialog {
--datasette-modal-width: min(700px, calc(100vw - 32px));
--datasette-modal-max-height: min(600px, calc(100vh - 32px));
}
The dialog frame also respects the page-wide theme properties ``--modal-border-radius``, ``--modal-shadow``, ``--modal-backdrop-bg``, ``--modal-backdrop-blur`` and ``--modal-animation-duration``.
``<datasette-modal>`` also works inside the shadow DOM of other Web Components - Datasette's own ``<column-chooser>`` and ``<navigation-search>`` components use it this way. The shared frame styles are automatically adopted into whichever document or shadow root the element is connected to.
.. _javascript_plugin_objects:
JavaScript plugin objects

View file

@ -9,53 +9,6 @@ through the Datasette user interface can also be accessed as JSON via the API.
To access the API for a page, either click on the ``.json`` link on that page or
edit the URL and add a ``.json`` extension to it.
.. _json_api_stability:
API stability
-------------
Datasette 1.0 makes a stability promise for its JSON API: the endpoints,
parameters and response keys documented here and on the pages this
documentation links to will not change in backwards-incompatible ways for
the duration of the 1.x release series.
Stability means:
- Documented endpoints will keep their URLs, methods, parameters and
permission requirements.
- Documented response keys will keep their names and types. New keys may be
**added** in any release - clients should ignore keys they do not
recognize.
- The documented ``?_extra=`` names, ``?_shape=`` values and
:ref:`column filter operators <table_arguments>` are stable.
- Pagination tokens - the ``"next"`` key and ``?_next=`` parameter - are
**opaque strings**. Pass them back exactly as you received them; their
internal structure is not part of the API and can change at any time.
- The :ref:`standard error format <json_api_errors>` and the
:ref:`API token format and restriction semantics <CreateTokenView>` are
stable, including the action abbreviations stored inside signed tokens.
Some JSON endpoints are **exempt** from this promise:
- Endpoints that are not documented include this marker key in their
responses and can change at any time::
"unstable": "This API is not part of Datasette's stable interface and may change at any time"
This currently covers the instance homepage (``/.json``), the stored
query ``analyze``/``store``/``definition`` endpoints, ``/-/query/parameters``,
``/-/execute-write/analyze`` and the JSON returned by the ``/-/permissions``
debug playground.
- Debug and support endpoints are documented so you can use them, but their
JSON shapes are not frozen: :ref:`/-/threads <JsonDataView_threads>`,
:ref:`/-/actions <JsonDataView_actions>`,
the :ref:`permission debug endpoints <PermissionsDebugView>`
(``/-/allowed``, ``/-/rules``, ``/-/check``) and the
:ref:`table autocomplete endpoint <TableAutocompleteView>`.
- Response keys explicitly labeled as unstable in this documentation, such
as the ``"analysis"`` block returned by :ref:`execute-write <ExecuteWriteView>`
and the ``debug`` and ``request`` extras.
.. _json_api_default:
Default representation
@ -89,49 +42,13 @@ looks like this:
"truncated": false
}
``"ok"`` is always ``true`` if an error did not occur. Every Datasette JSON endpoint that returns an object includes this key on success.
``"ok"`` is always ``true`` if an error did not occur.
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).
For table pages, two additional keys are present: ``"next"``, an opaque token that can be used to retrieve the next page using ``?_next=TOKEN``, and ``"next_url"``, the full URL of that next page. Both are ``null`` on the final page. See :ref:`json_api_pagination`.
.. _json_api_errors:
Error responses
---------------
Every JSON error response from Datasette uses the same format:
.. code-block:: json
{
"ok": false,
"error": "Table not found",
"errors": [
"Table not found"
],
"status": 404
}
- ``"ok"`` is always ``false`` for an error.
- ``"errors"`` is a list of one or more error message strings. Endpoints that
validate multiple things at once - such as the :ref:`insert API <TableInsertView>` -
may return several messages here.
- ``"error"`` is all of those messages joined with ``"; "``, for
convenience when displaying a single string.
- ``"status"`` matches the HTTP status code of the response.
Some endpoints add extra context keys. For example, a SQL error from a
:ref:`custom query <json_api_custom_sql>` also includes the empty
``"rows"`` and ``"truncated"`` keys of the response it was unable to
produce.
Permission errors use the same format: a request that fails a permission
check receives a ``403`` with this JSON error body when the URL ends in
``.json`` or the request sends an ``Accept: application/json`` or
``Content-Type: application/json`` header.
For table pages, an additional key ``"next"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``.
.. _json_api_custom_sql:
@ -175,7 +92,6 @@ options:
{
"ok": true,
"next": null,
"next_url": null,
"rows": [
[3, "Detroit"],
[2, "Los Angeles"],
@ -276,10 +192,6 @@ Here is an example Python function built using `requests <https://requests.readt
Special JSON arguments
----------------------
Boolean query string arguments - such as ``?_labels=`` and
``?_json_infinity=`` - accept ``on``, ``true`` or ``1`` for true and
``off``, ``false`` or ``0`` for false.
Every Datasette endpoint that can return JSON also accepts the following
query string arguments:
@ -333,9 +245,7 @@ These can be repeated or comma-separated:
::
?_extra=columns&_extra=count,count_sql
Requesting an ``_extra`` name that does not exist returns a ``400`` error in the :ref:`standard error format <json_api_errors>`, for example ``{"ok": false, "error": "Unknown _extra: nope", ...}``.
?_extra=columns&_extra=count,next_url
.. [[[cog
from json_api_doc import table_extras
@ -356,15 +266,6 @@ The available table extras are listed below.
15
``count_truncated``
True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count. (May execute additional queries.)
``GET /fixtures/facetable.json?_extra=count,count_truncated``
.. code-block:: json
false
``count_sql``
SQL query string used to calculate the total count for the current table view, including active filters.
@ -437,6 +338,17 @@ The available table extras are listed below.
"where state = \"CA\" sorted by pk"
``next_url``
Full URL for the next page of results
``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"
``columns``
List of column names returned by this table, row or query.
@ -490,25 +402,6 @@ The available table extras are listed below.
"pk"
]
``column_details``
SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.)
``GET /fixtures/binary_data.json?_size=0&_extra=column_details``
.. code-block:: json
{
"data": {
"type": "BLOB",
"sqlite_type": "BLOB",
"notnull": false,
"default": null,
"is_pk": false,
"pk_position": 0,
"hidden": 0
}
}
``display_columns``
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
@ -914,25 +807,6 @@ The following extras are available for row JSON responses.
"id"
]
``column_details``
SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.)
``GET /fixtures/binary_data/1.json?_extra=column_details``
.. code-block:: json
{
"data": {
"type": "BLOB",
"sqlite_type": "BLOB",
"notnull": false,
"default": null,
"is_pk": false,
"pk_position": 0,
"hidden": 0
}
}
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook <plugin_hook_render_cell>` documentation.)
@ -1263,6 +1137,7 @@ The following extras are available for arbitrary SQL query responses and stored,
"description_html": null,
"hide_sql": false,
"fragment": null,
"params": [],
"parameters": [],
"is_write": false,
"is_private": false,
@ -1657,10 +1532,6 @@ The JSON write API
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
The request body is always parsed as JSON, regardless of the request's ``Content-Type`` header - a body that is not valid JSON returns a ``400`` error. Cross-site request forgery is prevented by Datasette's ``Origin`` and ``Sec-Fetch-Site`` header checks rather than by content type requirements.
The row-based write APIs can write :ref:`binary values in JSON <binary_json_format>` using Datasette's Base64 representation for BLOB data.
.. _ExecuteWriteView:
Executing write SQL
@ -1694,7 +1565,7 @@ Unsupported SQL operations are rejected by default. ``VACUUM`` is not allowed in
A successful response includes a message, the SQLite ``rowcount``, a ``"rows"``
list, a ``"truncated"`` flag and a summary of the operations that were executed:
The shape of the ``"analysis"`` block is not part of the :ref:`stable API <json_api_stability>` and may change in future Datasette releases.
The shape of the ``"analysis"`` block is not yet considered a stable API and may change in future Datasette releases.
.. code-block:: json
@ -1754,17 +1625,15 @@ the execute-write returning row limit, which defaults to 10:
]
}
Errors use the :ref:`standard Datasette error format <json_api_errors>`:
Errors use the standard Datasette error format:
.. code-block:: json
{
"ok": false,
"error": "Permission denied: need execute-write-sql",
"errors": [
"Permission denied: need execute-write-sql"
],
"status": 403
]
}
.. _TableInsertView:
@ -1791,8 +1660,6 @@ A single row can be inserted using the ``"row"`` key:
}
}
Column values can use the :ref:`binary value JSON format <binary_json_format>` to write BLOB data.
If successful, this will return a ``201`` status code and the newly inserted row, for example:
.. code-block:: json
@ -1860,11 +1727,9 @@ If any of your rows have a primary key that is already in use, you will get an e
{
"ok": false,
"error": "UNIQUE constraint failed: new_table.id",
"errors": [
"UNIQUE constraint failed: new_table.id"
],
"status": 400
]
}
Pass ``"ignore": true`` to ignore these errors and insert the other rows:
@ -1900,8 +1765,6 @@ An upsert is an insert or update operation. If a row with a matching primary key
The upsert API is mostly the same shape as the :ref:`insert API <TableInsertView>`. It requires both the :ref:`actions_insert_row` and :ref:`actions_update_row` permissions.
It also accepts the same :ref:`binary value JSON format <binary_json_format>`.
::
POST /<database>/<table>/-/upsert
@ -1939,7 +1802,7 @@ The above example will:
Similar to ``/-/insert``, a ``row`` key with an object can be used instead of a ``rows`` array to upsert a single row.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body. This is deliberately different from the ``201`` returned by :ref:`insert <TableInsertView>`: an upsert may update existing rows without creating anything, so it does not claim resource creation.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
Add ``"return": true`` to the request body to return full copies of the affected rows after they have been inserted or updated:
@ -1996,11 +1859,9 @@ When using upsert you must provide the primary key column (or columns if the tab
{
"ok": false,
"error": "Row 0 is missing primary key column(s): \"id\"",
"errors": [
"Row 0 is missing primary key column(s): \"id\""
],
"status": 400
]
}
If your table does not have an explicit primary key you should pass the SQLite ``rowid`` key instead.
@ -2034,8 +1895,6 @@ To update a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/update``.
You only need to pass the columns you want to update. Any other columns will be left unchanged.
Updated values can use the :ref:`binary value JSON format <binary_json_format>`.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
Add ``"return": true`` to the request body to return the updated row:
@ -2055,16 +1914,14 @@ The returned JSON will look like this:
{
"ok": true,
"rows": [
{
"id": 1,
"title": "New title",
"other_column": "Will be present here too"
}
]
"row": {
"id": 1,
"title": "New title",
"other_column": "Will be present here too"
}
}
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
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.
Pass ``"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.
@ -2085,7 +1942,7 @@ To delete a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/delete``.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
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.
.. _TableCreateView:
@ -2241,8 +2098,6 @@ Datasette will create a table with a schema that matches those rows and insert t
"pk": "id"
}
Example rows can use the :ref:`binary value JSON format <binary_json_format>`, allowing Datasette to infer ``BLOB`` columns.
Doing this requires both the :ref:`actions_create_table` and :ref:`actions_insert_row` permissions.
The ``201`` response here will be similar to the ``columns`` form, but will also include the number of rows that were inserted as ``row_count``:
@ -2267,11 +2122,9 @@ If you pass a row to the create endpoint with a primary key that already exists
{
"ok": false,
"error": "UNIQUE constraint failed: creatures.id",
"errors": [
"UNIQUE constraint failed: creatures.id"
],
"status": 400
]
}
You can avoid this error by passing the same ``"ignore": true`` or ``"replace": true`` options to the create endpoint as you can to the :ref:`insert endpoint <TableInsertView>`.
@ -2507,7 +2360,7 @@ A successful response returns the new schema and the previous schema. If the req
"operations_applied": 11
}
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
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:
@ -2571,7 +2424,7 @@ To clear an existing column type assignment, set ``column_type`` to ``null``:
This API stores the assignment in Datasette's internal database, so it can be used with immutable databases as well as mutable ones.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
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.
.. _TableDropView:
@ -2608,4 +2461,4 @@ If you pass the following POST body:
Then the table will be dropped and a status ``200`` response of ``{"ok": true}`` will be returned.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
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.

View file

@ -95,7 +95,7 @@ Use the :ref:`ExecuteWriteView` JSON API to execute writable SQL programmaticall
Stored query browsers
---------------------
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database. The JSON versions accept ``?_size=`` (default 50, ``max`` for the :ref:`setting_max_returned_rows` limit) and a ``?_next=`` pagination token.
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database.
These pages support search, pagination and filters for read-only or writable queries and private or public queries. Adding a ``.json`` extension to either URL returns the same list as JSON.
@ -169,13 +169,11 @@ Use ``/-/schema.json`` to get the same information as JSON, which looks like thi
.. code-block:: json
{
"ok": true,
"schemas": [
{
"database": "content",
"schema": "create table posts ..."
}
]
}
.. _DatabaseSchemaView:
@ -183,11 +181,11 @@ Use ``/-/schema.json`` to get the same information as JSON, which looks like thi
Database schema
---------------
Use ``/database-name/-/schema`` to see the complete schema for a specific database. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"ok"``, ``"database"`` and ``"schema"`` keys.
Use ``/database-name/-/schema`` to see the complete schema for a specific database. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"database"`` and ``"schema"`` keys.
.. _TableSchemaView:
Table schema
------------
Use ``/database-name/table-name/-/schema`` to see the schema for a specific table. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"ok"``, ``"database"``, ``"table"``, and ``"schema"`` keys.
Use ``/database-name/table-name/-/schema`` to see the schema for a specific table. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"database"``, ``"table"``, and ``"schema"`` keys.

View file

@ -1685,8 +1685,6 @@ forbidden(datasette, request, message)
Plugins can use this to customize how Datasette responds when a 403 Forbidden error occurs - usually because a page failed a permission check, see :ref:`authentication_permissions`.
Datasette's default behavior returns the :ref:`standard JSON error format <json_api_errors>` with a 403 status when the request path ends in ``.json`` or the request has an ``Accept: application/json`` or ``Content-Type: application/json`` header; other requests get an HTML error page.
If a plugin hook wishes to react to the error, it should return a :ref:`Response object <internals_response>`.
This example returns a redirect to a ``/-/login`` page:
@ -2546,10 +2544,6 @@ The default ``SignedTokenHandler`` uses itsdangerous signed tokens (``dstok_`` p
async def verify_token(self, datasette, token):
# Look up token in database, return actor dict or None
# if this handler does not recognize the token. Raise
# datasette.TokenInvalid for a token this handler
# recognizes but rejects (revoked, expired) - Datasette
# will respond with a 401 error.
...

View file

@ -459,8 +459,6 @@ Secret configuration values
Some plugins may need configuration that should stay secret - API keys for example. There are two ways in which you can store secret configuration values.
The :ref:`/-/config <JsonDataView_config>` introspection endpoint redacts the values of any configuration keys whose names contain one of these substrings: ``secret``, ``key``, ``password``, ``token``, ``hash`` or ``dsn``. Name your plugin's secret configuration keys accordingly - for example ``api_key`` or ``client_secret`` - so they are automatically redacted there.
**As environment variables**. If your secret lives in an environment variable that is available to the Datasette process, you can indicate that the configuration value should be read from that environment variable like so:
.. [[[cog

View file

@ -125,23 +125,6 @@ You can increase or decrease this limit like so::
datasette mydatabase.db --setting max_insert_rows 1000
.. _setting_max_post_body_bytes:
max_post_body_bytes
~~~~~~~~~~~~~~~~~~~
Maximum size in bytes for a POST body that Datasette reads fully into memory, such as JSON submitted to the :ref:`write API <json_api_write>`. Requests with larger bodies are rejected with an HTTP 413 error. Defaults to 2,097,152 (2MB).
This limit exists to protect against memory exhaustion: unlike file uploads handled by ``request.form()``, which stream to disk, these bodies are held entirely in memory and parsing them as JSON can multiply their memory footprint several times over.
If you increase :ref:`setting_max_insert_rows` to support larger bulk inserts you may need to increase this limit as well::
datasette mydatabase.db --setting max_post_body_bytes 10485760
Set it to 0 to disable the limit entirely::
datasette mydatabase.db --setting max_post_body_bytes 0
.. _setting_num_sql_threads:
num_sql_threads

View file

@ -657,7 +657,7 @@ There are three options for specifying that you would like the response to your
- Include ``?_json=1`` in the URL that you POST to
- Include ``"_json": 1`` in your JSON body, or ``&_json=1`` in your form encoded body
A successful JSON response will look like this:
The JSON response will look like this:
.. code-block:: json
@ -667,21 +667,7 @@ A successful JSON response will look like this:
"redirect": "/data/add_name"
}
If the SQL fails to execute - for example a constraint violation - the response uses the :ref:`standard error format <json_api_errors>` with a ``400`` status, plus the ``"redirect"`` key from the query configuration:
.. code-block:: json
{
"ok": false,
"error": "UNIQUE constraint failed: docs.id",
"errors": [
"UNIQUE constraint failed: docs.id"
],
"status": 400,
"redirect": null
}
The ``"message"``, ``"error"`` and ``"redirect"`` values here take into account ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set.
The ``"message"`` and ``"redirect"`` values here will take into account ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set.
.. _pagination:

View file

@ -98,7 +98,7 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re
The color assigned to the database
``database_page_data`` - ``dict``
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.
``editable`` - ``bool``
Boolean indicating if the database is editable
@ -329,7 +329,7 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
Pagination token for the next page, or None
``next_url`` - ``str``
Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`.
Full URL for the next page of results
``ok`` - ``bool``
True if the data for this page was retrieved without errors
@ -389,7 +389,7 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
SQL definition for this table
``table_insert_ui`` - ``dict``
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys.
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys.
``table_page_data`` - ``dict``
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.

View file

@ -35,7 +35,7 @@ dependencies = [
"PyYAML>=5.3",
"mergedeep>=1.1.1",
"itsdangerous>=1.1",
"sqlite-utils>=4.0",
"sqlite-utils>=3.30,<4.0",
"asyncinject>=0.7",
"setuptools",
"pip",

View file

@ -1,6 +1,5 @@
from datasette.app import Datasette
from datasette.plugins import DEFAULT_PLUGINS
from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode
from datasette.utils.sqlite import sqlite_version
from datasette.version import __version__
from .fixtures import make_app_client, EXPECTED_PLUGINS
@ -27,9 +26,8 @@ async def test_homepage(ds_client):
"title",
]
databases = data.get("databases")
assert isinstance(databases, list)
assert [d["name"] for d in databases] == ["fixtures"]
d = databases[0]
assert databases.keys() == {"fixtures": 0}.keys()
d = databases["fixtures"]
assert d["name"] == "fixtures"
assert isinstance(d["tables_count"], int)
assert isinstance(len(d["tables_and_views_truncated"]), int)
@ -44,7 +42,8 @@ async def test_homepage_sort_by_relationships(ds_client):
response = await ds_client.get("/.json?_sort=relationships")
assert response.status_code == 200
tables = [
t["name"] for t in response.json()["databases"][0]["tables_and_views_truncated"]
t["name"]
for t in response.json()["databases"]["fixtures"]["tables_and_views_truncated"]
]
assert tables == [
"simple_primary_key",
@ -251,10 +250,8 @@ def test_no_files_uses_memory_database(app_client_no_files):
response = app_client_no_files.get("/.json")
assert response.status == 200
assert {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"databases": [
{
"databases": {
"_memory": {
"name": "_memory",
"hash": None,
"color": "a6c7b9",
@ -269,7 +266,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
"views_count": 0,
"private": False,
},
],
},
"metadata": {},
} == response.json
# Try that SQL query
@ -326,15 +323,20 @@ def test_sql_time_limit(app_client_shorter_time_limit):
"/fixtures/-/query.json?sql=select+sleep(0.5)",
)
assert 400 == response.status
expected_message = (
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
)
assert response.json == {
"ok": False,
"error": expected_message,
"errors": [expected_message],
"error": (
"<p>SQL query took too long. The time limit is controlled by the\n"
'<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>\n'
"configuration option.</p>\n"
'<textarea style="width: 90%">select sleep(0.5)</textarea>\n'
"<script>\n"
'let ta = document.querySelector("textarea");\n'
'ta.style.height = ta.scrollHeight + "px";\n'
"</script>"
),
"status": 400,
"title": "SQL Interrupted",
}
@ -348,7 +350,7 @@ async def test_custom_sql_time_limit(ds_client):
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5",
)
assert response.status_code == 400
assert response.json()["error"].startswith("SQL query took too long.")
assert response.json()["title"] == "SQL Interrupted"
@pytest.mark.asyncio
@ -369,40 +371,6 @@ async def test_row(ds_client):
assert response.json()["rows"] == [{"id": 1, "content": "hello"}]
@pytest.mark.asyncio
@pytest.mark.parametrize("suffix", ("", ".json"))
@pytest.mark.parametrize(
"row_path",
(
"a", # too few components for a two-column primary key
"a,b,c", # too many components for a two-column primary key
),
)
async def test_row_pk_arity_mismatch_returns_400(ds_client, row_path, suffix):
# A row URL with the wrong number of comma-separated primary key
# components used to raise an uncaught sqlite3.ProgrammingError (HTTP 500)
# because the SQL had one bind placeholder per PK column but params were
# only bound for the supplied components. It should be a 400 instead,
# mirroring the existing guard in datasette/views/table.py.
response = await ds_client.get(
"/fixtures/compound_primary_key/{}{}".format(row_path, suffix)
)
assert response.status_code == 400
if suffix == ".json":
assert response.json()["ok"] is False
assert response.json()["status"] == 400
@pytest.mark.asyncio
async def test_row_compound_pk_correct_arity(ds_client):
# The valid two-component URL still resolves the row.
response = await ds_client.get(
"/fixtures/compound_primary_key/a,b.json?_shape=objects"
)
assert response.status_code == 200
assert response.json()["rows"] == [{"pk1": "a", "pk2": "b", "content": "c"}]
@pytest.mark.asyncio
async def test_row_strange_table_name(ds_client):
response = await ds_client.get(
@ -461,7 +429,7 @@ async def test_row_foreign_key_tables(ds_client):
@pytest.mark.asyncio
async def test_row_extras(ds_client):
response = await ds_client.get(
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables,column_details"
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables"
)
assert response.status_code == 200
data = response.json()
@ -478,45 +446,6 @@ async def test_row_extras(ds_client):
"format": "json",
}
assert len(data["foreign_key_tables"]) == 5
id_detail = data["column_details"]["id"]
assert id_detail["type"].lower() == "integer"
assert id_detail == {
"type": id_detail["type"],
"sqlite_type": "INTEGER",
"notnull": False,
"default": None,
"is_pk": True,
"pk_position": 1,
"hidden": 0,
}
content_detail = data["column_details"]["content"]
assert content_detail["type"].lower() == "text"
assert content_detail == {
"type": content_detail["type"],
"sqlite_type": "TEXT",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
@pytest.mark.asyncio
async def test_column_details_extra_row_for_null_blob(ds_client):
response = await ds_client.get("/fixtures/binary_data/3.json?_extra=column_details")
assert response.status_code == 200
data_detail = response.json()["column_details"]["data"]
assert data_detail["type"].lower() == "blob"
assert data_detail == {
"type": data_detail["type"],
"sqlite_type": "BLOB",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
@pytest.mark.asyncio
@ -578,7 +507,7 @@ async def test_row_extra_render_cell():
def test_databases_json(app_client_two_attached_databases_one_immutable):
response = app_client_two_attached_databases_one_immutable.get("/-/databases.json")
databases = response.json["databases"]
databases = response.json
assert 2 == len(databases)
extra_database, fixtures_database = databases
assert "extra database" == extra_database["name"]
@ -594,12 +523,8 @@ def test_databases_json(app_client_two_attached_databases_one_immutable):
@pytest.mark.asyncio
async def test_threads_json(ds_client):
ds_client.ds.root_enabled = True
try:
response = await ds_client.get("/-/threads.json", actor={"id": "root"})
finally:
ds_client.ds.root_enabled = False
expected_keys = {"ok", "threads", "num_threads"}
response = await ds_client.get("/-/threads.json")
expected_keys = {"threads", "num_threads"}
if sys.version_info >= (3, 7, 0):
expected_keys.update({"tasks", "num_tasks"})
data = response.json()
@ -652,7 +577,7 @@ async def test_actions_json(ds_client):
try:
ds_client.ds.root_enabled = True
response = await ds_client.get("/-/actions.json", actor={"id": "root"})
data = response.json()["actions"]
data = response.json()
finally:
ds_client.ds.root_enabled = original_root_enabled
assert isinstance(data, list)
@ -684,7 +609,6 @@ async def test_actions_json(ds_client):
async def test_settings_json(ds_client):
response = await ds_client.get("/-/settings.json")
assert response.json() == {
"ok": True,
"default_page_size": 50,
"default_facet_size": 30,
"default_allow_sql": True,
@ -692,7 +616,6 @@ async def test_settings_json(ds_client):
"facet_time_limit_ms": 200,
"max_returned_rows": 100,
"max_insert_rows": 100,
"max_post_body_bytes": 2 * 1024 * 1024,
"sql_time_limit_ms": 200,
"allow_download": True,
"allow_signed_tokens": True,
@ -754,7 +677,7 @@ def test_config_cache_size(app_client_larger_cache_size):
def test_config_force_https_urls():
with make_app_client(settings={"force_https_urls": True}) as client:
response = client.get(
"/fixtures/facetable.json?_size=3&_facet=state&_extra=suggested_facets"
"/fixtures/facetable.json?_size=3&_facet=state&_extra=next_url,suggested_facets"
)
assert response.json["next_url"].startswith("https://")
assert response.json["facet_results"]["results"]["state"]["results"][0][
@ -849,9 +772,7 @@ def test_common_prefix_database_names(app_client_conflicting_database_names):
# https://github.com/simonw/datasette/issues/597
assert ["foo-bar", "foo", "fixtures"] == [
d["name"]
for d in app_client_conflicting_database_names.get("/-/databases.json").json[
"databases"
]
for d in app_client_conflicting_database_names.get("/-/databases.json").json
]
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
data = app_client_conflicting_database_names.get(path).json
@ -922,45 +843,13 @@ async def test_tilde_encoded_database_names(db_name):
ds = Datasette()
ds.add_memory_database(db_name)
response = await ds.client.get("/.json")
databases_by_name = {d["name"]: d for d in response.json()["databases"]}
assert db_name in databases_by_name
path = databases_by_name[db_name]["path"]
assert db_name in response.json()["databases"].keys()
path = response.json()["databases"][db_name]["path"]
# And the JSON for that database
response2 = await ds.client.get(path + ".json")
assert response2.status_code == 200
@pytest.mark.asyncio
@pytest.mark.parametrize("table_name", ("[foo]", "foo]", "[foo]/bar"))
async def test_table_with_reserved_characters_in_name(table_name):
# Table names containing characters such as "]" that cannot be escaped
# using SQLite [bracket] quoting used to break schema introspection and
# the table page - https://github.com/simonw/datasette/issues/2431
ds = Datasette()
db = ds.add_memory_database("test_reserved_table_names")
await db.execute_write(
"create table {} (id integer primary key, name text)".format(
escape_sqlite(table_name)
)
)
await db.execute_write(
"insert into {} (id, name) values (1, 'one')".format(escape_sqlite(table_name))
)
# Schema introspection (populate_schema_tables) must not crash:
db_response = await ds.client.get("/test_reserved_table_names.json")
assert db_response.status_code == 200
tables = {t["name"]: t for t in db_response.json()["tables"]}
assert tables[table_name]["count"] == 1
# And the table page itself must load and return the row:
table_response = await ds.client.get(
"/test_reserved_table_names/{}.json?_shape=array".format(
tilde_encode(table_name)
)
)
assert table_response.status_code == 200
assert table_response.json() == [{"id": 1, "name": "one"}]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"config,expected",
@ -994,7 +883,7 @@ async def test_config_json(config, expected):
"/-/config.json should return redacted configuration"
ds = Datasette(config=config)
response = await ds.client.get("/-/config.json")
assert response.json() == {"ok": True, **expected}
assert response.json() == expected
@pytest.mark.asyncio
@ -1090,7 +979,7 @@ async def test_config_json(config, expected):
async def test_upgrade_metadata(metadata, expected_config, expected_metadata):
ds = Datasette(metadata=metadata)
response = await ds.client.get("/-/config.json")
assert response.json() == {"ok": True, **expected_config}
assert response.json() == expected_config
response2 = await ds.client.get("/-/metadata.json")
assert response2.json() == expected_metadata

View file

@ -1,23 +1,11 @@
from datasette.app import Datasette
from datasette.events import RenameTableEvent
from datasette.utils import error_body, escape_sqlite, sqlite3
from datasette.utils import escape_sqlite, sqlite3
from .utils import last_event
import pytest
import time
def assert_schema_contains(fragment, schema):
assert fragment in schema, "Expected schema to contain {!r}, got {!r}".format(
fragment, schema
)
def assert_schema_not_contains(fragment, schema):
assert (
fragment not in schema
), "Expected schema not to contain {!r}, got {!r}".format(fragment, schema)
@pytest.fixture
def ds_write(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
@ -62,136 +50,6 @@ def _insert_and_fetch_created(conn, table, insert_sql):
).fetchone()
BASE64_WRITE_API_VALUE = {"$base64": True, "encoded": "AAEC/f7/"}
BASE64_WRITE_API_LITERAL = '{"$base64": true, "encoded": "AAEC/f7/"}'
@pytest.mark.asyncio
async def test_base64_write_api_create_table_infers_blob_and_raw_escapes(ds_write):
token = write_token(ds_write)
response = await ds_write.client.post(
"/data/-/create",
json={
"table": "binary_create",
"row": {
"id": 1,
"data": BASE64_WRITE_API_VALUE,
"literal": {"$raw": BASE64_WRITE_API_VALUE},
"double_raw": {"$raw": {"$raw": BASE64_WRITE_API_VALUE}},
},
"pk": "id",
},
headers=_headers(token),
)
assert response.status_code == 201
assert_schema_contains('"data" BLOB', response.json()["schema"])
assert_schema_contains('"literal" TEXT', response.json()["schema"])
rows = (await ds_write.get_database("data").execute("""
select
typeof(data) as data_type,
hex(data) as data_hex,
typeof(literal) as literal_type,
literal,
typeof(double_raw) as double_raw_type,
double_raw
from binary_create
""")).dicts()
assert rows == [
{
"data_type": "blob",
"data_hex": "000102FDFEFF",
"literal_type": "text",
"literal": BASE64_WRITE_API_LITERAL,
"double_raw_type": "text",
"double_raw": '{"$raw": {"$base64": true, "encoded": "AAEC/f7/"}}',
}
]
@pytest.mark.asyncio
async def test_base64_write_api_insert_upsert_update_decode_blobs(ds_write):
token = write_token(ds_write)
db = ds_write.get_database("data")
await db.execute_write(
"create table binary_api (id integer primary key, data blob, literal text)"
)
insert_response = await ds_write.client.post(
"/data/binary_api/-/insert",
json={
"row": {
"id": 1,
"data": BASE64_WRITE_API_VALUE,
"literal": {"$raw": BASE64_WRITE_API_VALUE},
}
},
headers=_headers(token),
)
assert insert_response.status_code == 201
assert insert_response.json()["rows"][0]["data"] == BASE64_WRITE_API_VALUE
upsert_response = await ds_write.client.post(
"/data/binary_api/-/upsert",
json={
"rows": [
{
"id": 2,
"data": BASE64_WRITE_API_VALUE,
"literal": {"$raw": BASE64_WRITE_API_VALUE},
}
]
},
headers=_headers(token),
)
assert upsert_response.status_code == 200
assert upsert_response.json() == {"ok": True}
update_response = await ds_write.client.post(
"/data/binary_api/1/-/update",
json={
"update": {
"data": {"$base64": True, "encoded": "/wAB"},
"literal": {"$raw": {"$raw": BASE64_WRITE_API_VALUE}},
},
"return": True,
},
headers=_headers(token),
)
assert update_response.status_code == 200
assert update_response.json()["rows"][0]["data"] == {
"$base64": True,
"encoded": "/wAB",
}
rows = (await db.execute("""
select
id,
typeof(data) as data_type,
hex(data) as data_hex,
typeof(literal) as literal_type,
literal
from binary_api
order by id
""")).dicts()
assert rows == [
{
"id": 1,
"data_type": "blob",
"data_hex": "FF0001",
"literal_type": "text",
"literal": '{"$raw": {"$base64": true, "encoded": "AAEC/f7/"}}',
},
{
"id": 2,
"data_type": "blob",
"data_hex": "000102FDFEFF",
"literal_type": "text",
"literal": BASE64_WRITE_API_LITERAL,
},
]
@pytest.mark.asyncio
async def test_api_explorer_upsert_example_json(ds_write):
response = await ds_write.client.get("/-/api", actor={"id": "root"})
@ -322,34 +180,6 @@ async def test_insert_rows(ds_write, return_rows):
assert response.json()["rows"] == actual_rows
@pytest.mark.asyncio
async def test_insert_rows_post_body_too_large(tmp_path_factory):
db_path = str(tmp_path_factory.mktemp("dbs") / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette([db_path], settings={"max_post_body_bytes": 100})
ds.root_enabled = True
token = write_token(ds)
response = await ds.client.post(
"/data/docs/-/insert",
json={"rows": [{"title": "x" * 200}]},
headers=_headers(token),
)
assert response.status_code == 413
assert response.json() == error_body(
["Request body exceeded maximum size of 100 bytes"], 413
)
# A small body should still work
response2 = await ds.client.post(
"/data/docs/-/insert",
json={"row": {"title": "hi"}},
headers=_headers(token),
)
assert response2.status_code == 201
ds.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,input,special_case,expected_status,expected_errors",
@ -372,8 +202,8 @@ async def test_insert_rows_post_body_too_large(tmp_path_factory):
"/data/docs/-/insert",
{"rows": [{"title": "Test"} for i in range(10)]},
"bad_token",
401,
["Invalid token signature"],
403,
["Permission denied"],
),
(
"/data/docs/-/insert",
@ -384,6 +214,13 @@ async def test_insert_rows_post_body_too_large(tmp_path_factory):
"Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"
],
),
(
"/data/docs/-/insert",
{},
"invalid_content_type",
400,
["Invalid content-type, must be application/json"],
),
(
"/data/docs/-/insert",
[],
@ -565,17 +402,20 @@ async def test_insert_or_upsert_row_errors(
json=input,
headers={
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
"Content-Type": (
"text/plain"
if special_case == "invalid_content_type"
else "application/json"
),
},
)
if special_case != "bad_token":
actor_response = (
await ds_write.client.get("/-/actor.json", headers=kwargs["headers"])
).json()
assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set(
token_permissions
)
actor_response = (
await ds_write.client.get("/-/actor.json", headers=kwargs["headers"])
).json()
assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set(
token_permissions
)
if special_case == "invalid_json":
del kwargs["json"]
@ -948,12 +788,7 @@ async def test_update_row_invalid_key(ds_write):
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"error": "Invalid keys: bad_key",
"errors": ["Invalid keys: bad_key"],
"status": 400,
}
assert response.json() == {"ok": False, "errors": ["Invalid keys: bad_key"]}
@pytest.mark.asyncio
@ -1206,9 +1041,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
assert response.status_code == 200, response.text
data = response.json()
assert data["operations_applied"] == 2
assert_schema_contains(
'"owner_id" INTEGER REFERENCES "owners"("id")', data["schema"]
)
assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
@ -1219,7 +1052,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
assert_schema_not_contains('"owner_id" INTEGER REFERENCES', data["schema"])
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
@ -1243,9 +1076,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
assert_schema_contains(
'"owner_id" INTEGER REFERENCES "categories"("id")', data["schema"]
)
assert "[owner_id] INTEGER REFERENCES [categories]([id])" in data["schema"]
response = await ds_write.client.post(
"/data/docs/-/alter",
@ -1254,7 +1085,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
assert_schema_not_contains('"owner_id" INTEGER REFERENCES', data["schema"])
assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
@pytest.mark.asyncio
@ -1272,9 +1103,10 @@ async def test_alter_table_foreign_key_requires_fk_table_for_fk_column(ds_write)
headers=_headers(write_token(ds_write, permissions=["at"])),
)
assert response.status_code == 400
assert response.json() == error_body(
["operations.0.add_foreign_key.args: fk_column requires fk_table"], 400
)
assert response.json() == {
"ok": False,
"errors": ["operations.0.add_foreign_key.args: fk_column requires fk_table"],
}
@pytest.mark.asyncio
@ -1298,9 +1130,10 @@ async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_w
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == error_body(
["Could not detect single primary key for table 'accounts'"], 400
)
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
@pytest.mark.asyncio
@ -1366,7 +1199,10 @@ async def test_foreign_key_suggestions_permission_denied(ds_write):
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == error_body(["Permission denied: need alter-table"], 403)
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
@pytest.mark.asyncio
@ -1477,7 +1313,10 @@ async def test_foreign_key_targets_permission_denied(ds_write):
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == error_body(["Permission denied: need create-table"], 403)
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need create-table"],
}
@pytest.mark.asyncio
@ -1500,7 +1339,10 @@ async def test_alter_table_permission_denied(ds_write):
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == error_body(["Permission denied: need alter-table"], 403)
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
@pytest.mark.asyncio
@ -1644,9 +1486,9 @@ async def test_update_row(ds_write, input, expected_errors, use_return):
assert response.json()["ok"] is True
if not use_return:
assert "rows" not in response.json()
assert "row" not in response.json()
else:
returned_row = response.json()["rows"][0]
returned_row = response.json()["row"]
assert returned_row["id"] == pk
for k, v in input.items():
assert returned_row[k] == v
@ -1781,12 +1623,12 @@ async def test_drop_table(ds_write, scenario):
"table_url": "http://localhost/data/one",
"table_api_url": "http://localhost/data/one.json",
"schema": (
'CREATE TABLE "one" (\n'
' "id" INTEGER PRIMARY KEY,\n'
' "title" TEXT,\n'
' "score" INTEGER,\n'
' "weight" REAL,\n'
' "thumbnail" BLOB\n'
"CREATE TABLE [one] (\n"
" [id] INTEGER PRIMARY KEY,\n"
" [title] TEXT,\n"
" [score] INTEGER,\n"
" [weight] FLOAT,\n"
" [thumbnail] BLOB\n"
")"
),
},
@ -1818,10 +1660,10 @@ async def test_drop_table(ds_write, scenario):
"table_url": "http://localhost/data/two",
"table_api_url": "http://localhost/data/two.json",
"schema": (
'CREATE TABLE "two" (\n'
' "id" INTEGER PRIMARY KEY,\n'
' "title" TEXT,\n'
' "score" REAL\n'
"CREATE TABLE [two] (\n"
" [id] INTEGER PRIMARY KEY,\n"
" [title] TEXT,\n"
" [score] FLOAT\n"
")"
),
"row_count": 2,
@ -1847,10 +1689,10 @@ async def test_drop_table(ds_write, scenario):
"table_url": "http://localhost/data/three",
"table_api_url": "http://localhost/data/three.json",
"schema": (
'CREATE TABLE "three" (\n'
' "id" INTEGER PRIMARY KEY,\n'
' "title" TEXT,\n'
' "score" REAL\n'
"CREATE TABLE [three] (\n"
" [id] INTEGER PRIMARY KEY,\n"
" [title] TEXT,\n"
" [score] FLOAT\n"
")"
),
"row_count": 1,
@ -1872,7 +1714,7 @@ async def test_drop_table(ds_write, scenario):
"table": "four",
"table_url": "http://localhost/data/four",
"table_api_url": "http://localhost/data/four.json",
"schema": ('CREATE TABLE "four" (\n' ' "name" TEXT\n' ")"),
"schema": ("CREATE TABLE [four] (\n" " [name] TEXT\n" ")"),
"row_count": 1,
},
["create-table", "insert-rows"],
@ -1892,8 +1734,8 @@ async def test_drop_table(ds_write, scenario):
"table_url": "http://localhost/data/five",
"table_api_url": "http://localhost/data/five.json",
"schema": (
'CREATE TABLE "five" (\n "type" TEXT,\n "key" INTEGER,\n'
' "title" TEXT,\n PRIMARY KEY ("type", "key")\n)'
"CREATE TABLE [five] (\n [type] TEXT,\n [key] INTEGER,\n"
" [title] TEXT,\n PRIMARY KEY ([type], [key])\n)"
),
"row_count": 1,
},
@ -2179,12 +2021,6 @@ async def test_create_table(
)
assert response.status_code == expected_status
data = response.json()
if expected_response.get("ok") is False:
# Error expectations list their messages; derive the canonical envelope
expected_response = error_body(expected_response["errors"], expected_status)
if isinstance(expected_response, dict) and "schema" in expected_response:
assert data.get("schema") == expected_response["schema"]
expected_response = dict(expected_response, schema=data.get("schema"))
assert data == expected_response
# Should have tracked the expected events
events = ds_write._tracked_events
@ -2227,9 +2063,7 @@ async def test_create_table_with_foreign_key(ds_write):
)
assert response.status_code == 201
data = response.json()
assert_schema_contains(
'"owner_id" INTEGER REFERENCES "owners"("id")', data["schema"]
)
assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
@pytest.mark.asyncio
@ -2384,12 +2218,13 @@ async def test_create_table_column_validation(ds_write, column, expected_error):
)
if expected_error:
assert response.status_code == 400
assert response.json() == error_body([expected_error], 400)
assert response.json() == {"ok": False, "errors": [expected_error]}
else:
assert response.status_code == 400
assert response.json() == error_body(
["Could not detect single primary key for table 'owners'"], 400
)
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'owners'"],
}
@pytest.mark.asyncio
@ -2427,9 +2262,10 @@ async def test_create_table_foreign_key_without_fk_column_requires_single_pk(ds_
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == error_body(
["Could not detect single primary key for table 'accounts'"], 400
)
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
@pytest.mark.asyncio
@ -2579,9 +2415,10 @@ async def test_create_table_error_if_pk_changed(ds_write):
headers=_headers(token),
)
assert second_response.status_code == 400
assert second_response.json() == error_body(
["pk cannot be changed for existing table"], 400
)
assert second_response.json() == {
"ok": False,
"errors": ["pk cannot be changed for existing table"],
}
@pytest.mark.asyncio
@ -2605,9 +2442,10 @@ async def test_create_table_error_rows_twice_with_duplicates(ds_write):
headers=_headers(token),
)
assert second_response.status_code == 400
assert second_response.json() == error_body(
["UNIQUE constraint failed: test_create_twice.id"], 400
)
assert second_response.json() == {
"ok": False,
"errors": ["UNIQUE constraint failed: test_create_twice.id"],
}
@pytest.mark.asyncio
@ -2630,8 +2468,6 @@ async def test_method_not_allowed(ds_write, path):
assert response.json() == {
"ok": False,
"error": "Method not allowed",
"errors": ["Method not allowed"],
"status": 405,
}
@ -2699,9 +2535,10 @@ async def test_create_using_alter_against_existing_table(
)
if not has_alter_permission:
assert response2.status_code == 403
assert response2.json() == error_body(
["Permission denied: need alter-table"], 403
)
assert response2.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
else:
assert response2.status_code == 201

View file

@ -236,9 +236,7 @@ def test_auth_create_token(
@pytest.mark.asyncio
async def test_auth_create_token_not_allowed_for_tokens(ds_client):
ds_tok = ds_client.ds.sign(
{"a": "test", "token": "dstok", "t": int(time.time())}, "token"
)
ds_tok = ds_client.ds.sign({"a": "test", "token": "dstok"}, "token")
response = await ds_client.get(
"/-/create-token",
headers={"Authorization": "Bearer dstok_{}".format(ds_tok)},
@ -296,7 +294,7 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
try:
if should_work:
data = response.json()
assert data.keys() == {"ok", "actor"}
assert data.keys() == {"actor"}
actor = data["actor"]
expected_keys = {"id", "token"}
if scenario != "valid_unlimited_token":
@ -306,16 +304,8 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
assert actor["token"] == "dstok"
if scenario != "valid_unlimited_token":
assert isinstance(actor["token_expires"], int)
elif scenario == "no_token":
# No credentials presented - request proceeds as anonymous
assert response.json() == {"ok": True, "actor": None}
else:
# Invalid credentials presented - hard 401
assert response.status_code == 401
data = response.json()
assert data["ok"] is False
assert data["status"] == 401
assert response.headers["www-authenticate"].startswith("Bearer")
assert response.json() == {"actor": None}
finally:
ds_client.ds._settings["allow_signed_tokens"] = True
@ -347,11 +337,10 @@ def test_cli_create_token(app_client, expires):
}
if expires and expires > 0:
expected_actor["token_expires"] = details["t"] + expires
assert response.json == {"ok": True, "actor": expected_actor}
assert response.json == {"actor": expected_actor}
else:
# Expired token - hard 401
assert response.status == 401
assert response.json["ok"] is False
expected_actor = None
assert response.json == {"actor": expected_actor}
@pytest.mark.asyncio

View file

@ -25,14 +25,13 @@ async def test_autocomplete_single_pk_exact_match_and_label_order():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"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"},
],
]
}
@ -53,12 +52,12 @@ async def test_autocomplete_blank_q_returns_no_results():
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete?q=")
assert response.status_code == 200
assert response.json() == {"ok": True, "rows": []}
assert response.json() == {"rows": []}
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete")
assert response.status_code == 200
assert response.json() == {"ok": True, "rows": []}
assert response.json() == {"rows": []}
@pytest.mark.asyncio
@ -82,12 +81,11 @@ async def test_autocomplete_initial_returns_latest_rows():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"id": 3}, "label": "Cleo"},
{"pks": {"id": 2}, "label": "Bob"},
{"pks": {"id": 1}, "label": "Alice"},
],
]
}
response = await ds.client.get(
@ -96,12 +94,11 @@ async def test_autocomplete_initial_returns_latest_rows():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"id": 3}, "label": "Cleo"},
{"pks": {"id": 2}, "label": "Bob"},
{"pks": {"id": 1}, "label": "Alice"},
],
]
}
@ -124,10 +121,9 @@ async def test_autocomplete_escapes_like_characters():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"id": 1}, "label": "100% real"},
],
]
}
@ -153,12 +149,11 @@ async def test_autocomplete_compound_pk_searches_all_pk_columns():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"country": "mx", "code": "ca"}, "label": "Campeche"},
{"pks": {"country": "us", "code": "ca"}, "label": "California"},
{"pks": {"country": "ca", "code": "bc"}, "label": "British Columbia"},
],
]
}
@ -189,10 +184,9 @@ async def test_autocomplete_primary_key_called_label():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"label": "abc"}, "label": "Display value"},
],
]
}
@ -252,9 +246,8 @@ async def test_autocomplete_timeout_uses_prefix_fallback(monkeypatch):
assert timeout_was_simulated
data = response.json()
assert data == {
"ok": True,
"rows": [
{"pks": {"id": f"item-1999{i:02d}"}, "label": f"name 1999{i:02d}"}
for i in range(10)
],
]
}

View file

@ -53,8 +53,6 @@ async def test_get_view():
assert json.loads(post_json_response.body) == {
"ok": False,
"error": "Method not allowed",
"errors": ["Method not allowed"],
"status": 405,
}
assert post_json_response.status == 405

View file

@ -385,9 +385,7 @@ def test_setting_boolean_validation_false_values(value):
)
# Should be forbidden (setting is false)
assert result.exit_code == 1, result.output
error = json.loads(result.output)
assert error["ok"] is False
assert error["status"] == 403
assert "Forbidden" in result.output
@pytest.mark.parametrize("value", ("on", "true", "1"))
@ -427,9 +425,8 @@ def test_setting_default_allow_sql(default_allow_sql):
assert json.loads(result.output)["rows"][0] == {"21": 21}
else:
assert result.exit_code == 1, result.output
error = json.loads(result.output)
assert error["ok"] is False
assert error["status"] == 403
# This isn't JSON at the moment, maybe it should be though
assert "Forbidden" in result.output
def test_sql_errors_logged_to_stderr():
@ -447,7 +444,7 @@ def test_serve_create(tmpdir):
cli, [str(db_path), "--create", "--get", "/-/databases.json"]
)
assert result.exit_code == 0, result.output
databases = json.loads(result.output)["databases"]
databases = json.loads(result.output)
assert {
"name": "does_not_exist_yet",
"is_mutable": True,
@ -496,7 +493,7 @@ def test_serve_duplicate_database_names(tmpdir):
conn.close()
result = runner.invoke(cli, [db_1_path, db_2_path, "--get", "/-/databases.json"])
assert result.exit_code == 0, result.output
databases = json.loads(result.output)["databases"]
databases = json.loads(result.output)
assert {db["name"] for db in databases} == {"db", "db_2"}
@ -588,7 +585,7 @@ def test_duplicate_database_files_error(tmpdir):
cli, ["serve", other_db_path, str(config_dir), "--get", "/-/databases.json"]
)
assert result4.exit_code == 0
databases = json.loads(result4.output)["databases"]
databases = json.loads(result4.output)
assert {db["name"] for db in databases} == {"other", "data"}
# Test that multiple directories raise an error

View file

@ -95,10 +95,7 @@ def test_serve_with_get_and_token():
],
)
assert 0 == result2.exit_code, result2.output
assert json.loads(result2.output) == {
"ok": True,
"actor": {"id": "root", "token": "dstok"},
}
assert json.loads(result2.output) == {"actor": {"id": "root", "token": "dstok"}}
def test_serve_with_get_exit_code_for_error():
@ -133,9 +130,8 @@ def test_serve_get_actor():
)
assert result.exit_code == 0
assert json.loads(result.output) == {
"ok": True,
"actor": {
"id": "root",
"extra": "x",
},
}
}

View file

@ -9,7 +9,7 @@ from datasette.column_types import (
)
from datasette.hookspecs import hookimpl
from datasette.plugins import pm
from datasette.utils import error_body, sqlite3
from datasette.utils import sqlite3
from datasette.utils import StartupError
import markupsafe
import pytest
@ -322,6 +322,12 @@ async def test_clear_column_type_api(ds_ct):
"Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"
],
),
(
{"column": "title", "column_type": {"type": "email"}},
"invalid_content_type",
400,
["Invalid content-type, must be application/json"],
),
(
[],
None,
@ -407,7 +413,11 @@ async def test_set_column_type_api_errors(
kwargs = {
"headers": {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Content-Type": (
"text/plain"
if special_case == "invalid_content_type"
else "application/json"
),
}
}
if special_case == "invalid_json":
@ -416,7 +426,7 @@ async def test_set_column_type_api_errors(
kwargs["json"] = body
response = await ds_ct.client.post("/data/posts/-/set-column-type", **kwargs)
assert response.status_code == expected_status
assert response.json() == error_body(expected_errors, expected_status)
assert response.json() == {"ok": False, "errors": expected_errors}
@pytest.mark.asyncio

View file

@ -109,10 +109,9 @@ def test_settings(config_dir_client):
def test_plugins(config_dir_client):
response = config_dir_client.get("/-/plugins.json")
assert 200 == response.status
plugins = response.json
assert "hooray.py" in {p["name"] for p in plugins}
assert "non_py_file.txt" not in {p["name"] for p in plugins}
assert "mypy_cache" not in {p["name"] for p in plugins}
assert "hooray.py" in {p["name"] for p in response.json}
assert "non_py_file.txt" not in {p["name"] for p in response.json}
assert "mypy_cache" not in {p["name"] for p in response.json}
def test_templates_and_plugin(config_dir_client):
@ -137,7 +136,7 @@ def test_static_directory_browsing_not_allowed(config_dir_client):
def test_databases(config_dir_client):
response = config_dir_client.get("/-/databases.json")
assert 200 == response.status
databases = response.json["databases"]
databases = response.json
assert 4 == len(databases)
databases.sort(key=lambda d: d["name"])
for db, expected_name in zip(databases, ("demo", "immutable", "j", "k")):

View file

@ -248,7 +248,7 @@ async def test_homepage():
async def test_actor_is_null():
ds = Datasette(memory=True)
response = await ds.client.get("/-/actor.json")
assert response.json() == {"ok": True, "actor": None}
assert response.json() == {"actor": None}
# -- end test_actor_is_null --
@ -258,5 +258,5 @@ async def test_signed_cookie_actor():
ds = Datasette(memory=True)
cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})}
response = await ds.client.get("/-/actor.json", cookies=cookies)
assert response.json() == {"ok": True, "actor": {"id": "root"}}
assert response.json() == {"actor": {"id": "root"}}
# -- end test_signed_cookie_actor --

View file

@ -1,749 +0,0 @@
"""
Tests for the canonical JSON error shape.
Every JSON error response from Datasette should use one shape:
{
"ok": false,
"error": "<all messages joined with '; '>",
"errors": ["<message>", ...],
"status": <int matching the HTTP status code>
}
Additional context keys (for example "rows" and "truncated" on SQL errors)
are permitted, but "ok", "error", "errors" and "status" must always be
present and the legacy "title" key must not be.
https://github.com/simonw/datasette/issues - 1.0 API consistency
"""
import pytest
import time
from datasette.app import Datasette
from datasette.utils import sqlite3
def assert_canonical_error(response, expected_status):
assert response.status_code == expected_status
data = response.json()
assert data["ok"] is False
assert isinstance(data["error"], str)
assert data["error"]
assert isinstance(data["errors"], list)
assert data["errors"]
assert all(isinstance(message, str) for message in data["errors"])
assert data["error"] == "; ".join(data["errors"])
assert data["status"] == expected_status
assert "title" not in data
return data
@pytest.fixture
def ds_error_shape(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette([db_path])
ds.root_enabled = True
yield ds
ds.close()
# Shape 1: the exception handler (handle_exception.py)
@pytest.mark.asyncio
async def test_not_found_error_shape(ds_client):
response = await ds_client.get("/fixtures/no_such_table.json")
assert_canonical_error(response, 404)
@pytest.mark.asyncio
async def test_datasette_error_with_title_omits_title_key(ds_client):
# DatasetteError(title="Invalid SQL") previously leaked a "title" key
response = await ds_client.get(
"/fixtures/-/query.json?sql=update+facetable+set+state+=+1"
)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Statement must be a SELECT"]
# Shape 2: the _error() helper (views/base.py) - write API and friends
@pytest.mark.asyncio
async def test_write_api_validation_error_shape(ds_error_shape):
token = "dstok_{}".format(
ds_error_shape.sign(
{"a": "root", "token": "dstok", "t": 0},
namespace="token",
)
)
response = await ds_error_shape.client.post(
"/data/docs/-/insert",
json={"rows": [{"nope": 1}, {"also_nope": 2}]},
headers={
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
},
)
data = assert_canonical_error(response, 400)
# Multiple messages: errors keeps them all, error joins them
assert len(data["errors"]) == 2
assert data["errors"][0].startswith("Row 0")
assert data["errors"][1].startswith("Row 1")
@pytest.mark.asyncio
async def test_write_api_permission_denied_shape(ds_error_shape):
response = await ds_error_shape.client.post(
"/data/docs/-/insert",
json={"rows": [{"title": "hello"}]},
headers={"Content-Type": "application/json"},
)
assert_canonical_error(response, 403)
# Shape 3: the JSON renderer (renderer.py)
@pytest.mark.asyncio
async def test_sql_error_shape_keeps_context_keys(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?sql=select+*+from+no_such_table"
)
data = assert_canonical_error(response, 400)
# Renderer errors keep their context keys
assert data["rows"] == []
assert "truncated" in data
@pytest.mark.asyncio
async def test_invalid_shape_error_shape(ds_client):
response = await ds_client.get("/fixtures/-/query.json?sql=select+1&_shape=bananas")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Invalid _shape: bananas"]
@pytest.mark.asyncio
async def test_shape_object_on_query_is_a_400_error(ds_client):
# Previously returned HTTP 200 with an ok: false body
response = await ds_client.get("/fixtures/-/query.json?sql=select+1&_shape=object")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["_shape=object is only available on tables"]
# Shape 4: bare {"error": ...} from the permission debug endpoints
@pytest.mark.asyncio
async def test_allowed_missing_action_error_shape(ds_client):
response = await ds_client.get("/-/allowed.json")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["action parameter is required"]
@pytest.mark.asyncio
async def test_allowed_unknown_action_error_shape(ds_client):
response = await ds_client.get("/-/allowed.json?action=no_such_action")
assert_canonical_error(response, 404)
@pytest.mark.asyncio
async def test_check_unknown_action_error_shape(ds_error_shape):
response = await ds_error_shape.client.get(
"/-/check.json?action=no_such_action",
actor={"id": "root"},
)
assert_canonical_error(response, 404)
@pytest.mark.asyncio
async def test_rules_missing_action_error_shape(ds_error_shape):
response = await ds_error_shape.client.get(
"/-/rules.json",
actor={"id": "root"},
)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["action parameter is required"]
# Other stragglers
@pytest.mark.asyncio
async def test_method_not_allowed_error_shape(ds_client):
response = await ds_client.post("/fixtures.json")
assert_canonical_error(response, 405)
@pytest.mark.asyncio
async def test_schema_unknown_database_error_shape(ds_client):
response = await ds_client.get("/no_such_db/-/schema.json")
assert_canonical_error(response, 404)
# Forbidden responses (the default forbidden() hook)
@pytest.fixture
def ds_forbidden(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette(
[db_path],
config={"databases": {"data": {"tables": {"docs": {"allow": {"id": "root"}}}}}},
)
ds.root_enabled = True
yield ds
ds.close()
@pytest.mark.asyncio
async def test_forbidden_json_path_returns_canonical_json(ds_forbidden):
response = await ds_forbidden.client.get("/data/docs.json")
data = assert_canonical_error(response, 403)
assert "permission" in data["error"].lower()
@pytest.mark.asyncio
async def test_forbidden_accept_json_returns_canonical_json(ds_forbidden):
response = await ds_forbidden.client.get(
"/data/docs", headers={"Accept": "application/json"}
)
assert_canonical_error(response, 403)
@pytest.mark.asyncio
async def test_forbidden_html_path_still_returns_html(ds_forbidden):
response = await ds_forbidden.client.get("/data/docs")
assert response.status_code == 403
assert response.headers["content-type"].startswith("text/html")
@pytest.mark.asyncio
async def test_forbidden_json_path_allowed_actor_still_works(ds_forbidden):
response = await ds_forbidden.client.get("/data/docs.json", actor={"id": "root"})
assert response.status_code == 200
assert response.json()["ok"] is True
# Write canned queries: SQL failures must not return HTTP 200
@pytest.fixture
def ds_write_query(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette(
[db_path],
config={
"databases": {
"data": {
"queries": {
"add_doc": {
"sql": (
"insert into docs (id, title)" " values (:id, :title)"
),
"write": True,
},
"add_doc_custom_error": {
"sql": (
"insert into docs (id, title)" " values (:id, :title)"
),
"write": True,
"on_error_message": "Custom error message",
"on_error_redirect": "/data",
},
}
}
}
},
)
yield ds
ds.close()
@pytest.mark.asyncio
async def test_write_query_success_returns_200(ds_write_query):
response = await ds_write_query.client.post(
"/data/add_doc",
json={"id": 1, "title": "One"},
headers={"Accept": "application/json"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["message"] == "Query executed, 1 row affected"
assert data["redirect"] is None
@pytest.mark.asyncio
async def test_write_query_sql_failure_returns_400(ds_write_query):
for _ in range(2):
response = await ds_write_query.client.post(
"/data/add_doc",
json={"id": 1, "title": "One"},
headers={"Accept": "application/json"},
)
data = assert_canonical_error(response, 400)
assert "UNIQUE constraint failed" in data["error"]
# The redirect context key from the canned query flow is preserved
assert data["redirect"] is None
@pytest.mark.asyncio
async def test_write_query_failure_uses_on_error_message_and_redirect(
ds_write_query,
):
for _ in range(2):
response = await ds_write_query.client.post(
"/data/add_doc_custom_error",
json={"id": 1, "title": "One"},
headers={"Accept": "application/json"},
)
data = assert_canonical_error(response, 400)
assert data["error"] == "Custom error message"
assert data["redirect"] == "/data"
@pytest.mark.asyncio
async def test_write_query_forbidden_is_canonical_403(ds_write_query):
# An untrusted write query run by an actor without execute-write-sql
# raises Forbidden, handled by the forbidden() hook
await ds_write_query.invoke_startup()
await ds_write_query.add_query(
"data",
name="untrusted_add",
sql="insert into docs (id, title) values (:id, :title)",
is_write=True,
is_trusted=False,
source="user",
owner_id="someone",
)
response = await ds_write_query.client.post(
"/data/untrusted_add",
json={"id": 5, "title": "Five"},
headers={"Accept": "application/json"},
actor={"id": "someone"},
)
assert_canonical_error(response, 403)
@pytest.mark.asyncio
async def test_write_query_rejected_operation_is_canonical_403(ds_write_query):
# A rejected operation (VACUUM) raises QueryWriteRejected, handled by
# the dedicated branch in QueryView.post - root has execute-write-sql
ds_write_query.root_enabled = True
await ds_write_query.invoke_startup()
await ds_write_query.add_query(
"data",
name="vacuum_it",
sql="vacuum",
is_write=True,
is_trusted=False,
source="user",
owner_id="root",
)
response = await ds_write_query.client.post(
"/data/vacuum_it",
json={},
headers={"Accept": "application/json"},
actor={"id": "root"},
)
data = assert_canonical_error(response, 403)
assert data["redirect"] is None
# Row delete write failures must be 400, matching row update
@pytest.mark.asyncio
async def test_row_delete_write_failure_is_400(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.execute("insert into docs (id, title) values (1, 'One')")
conn.execute(
"create trigger no_delete before delete on docs "
"begin select raise(abort, 'deletes are blocked'); end"
)
conn.commit()
conn.close()
ds = Datasette([db_path])
ds.root_enabled = True
try:
response = await ds.client.post(
"/data/docs/1/-/delete",
json={},
headers={"Content-Type": "application/json"},
actor={"id": "root"},
)
data = assert_canonical_error(response, 400)
assert "deletes are blocked" in data["error"]
finally:
ds.close()
# Invalid bearer tokens must produce 401, not silent anonymous access
@pytest.mark.asyncio
async def test_expired_token_returns_401(ds_error_shape):
token = "dstok_{}".format(
ds_error_shape.sign(
{"a": "root", "t": int(time.time()) - 2000, "d": 1000},
namespace="token",
)
)
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
)
data = assert_canonical_error(response, 401)
assert "expired" in data["error"].lower()
assert response.headers["www-authenticate"].startswith("Bearer")
@pytest.mark.asyncio
async def test_bad_signature_token_returns_401(ds_error_shape):
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer dstok_garbage"}
)
assert_canonical_error(response, 401)
assert response.headers["www-authenticate"].startswith("Bearer")
@pytest.mark.asyncio
async def test_unrecognized_token_prefix_stays_anonymous(ds_error_shape):
# No registered handler claims this token - it might belong to a
# plugin's actor_from_request hook, so it must not hard-fail
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer sometoken_abc"}
)
assert response.status_code == 200
assert response.json() == {"ok": True, "actor": None}
@pytest.mark.asyncio
async def test_valid_token_still_authenticates(ds_error_shape):
token = "dstok_{}".format(
ds_error_shape.sign(
{"a": "root", "t": int(time.time())},
namespace="token",
)
)
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
)
assert response.status_code == 200
assert response.json()["actor"]["id"] == "root"
@pytest.mark.asyncio
async def test_bad_token_beats_valid_cookie(ds_error_shape):
# A malformed Authorization header is a hard error even if a valid
# ds_actor cookie is also present
response = await ds_error_shape.client.get(
"/-/actor.json",
headers={"Authorization": "Bearer dstok_garbage"},
cookies={"ds_actor": ds_error_shape.client.actor_cookie({"id": "root"})},
)
assert_canonical_error(response, 401)
@pytest.mark.asyncio
async def test_token_when_signed_tokens_disabled_returns_401(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.close()
ds = Datasette([db_path], settings={"allow_signed_tokens": False})
try:
token = "dstok_{}".format(
ds.sign({"a": "root", "t": int(time.time())}, namespace="token")
)
response = await ds.client.get(
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
)
data = assert_canonical_error(response, 401)
assert "not enabled" in data["error"]
finally:
ds.close()
# GET /db/-/query without SQL: 400 for data formats, HTML editor stays 200
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/fixtures/-/query.json",
"/fixtures/-/query.json?sql=",
),
)
async def test_query_json_without_sql_is_400(ds_client, path):
response = await ds_client.get(path)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["?sql= is required"]
@pytest.mark.asyncio
async def test_query_html_without_sql_is_still_the_editor(ds_client):
response = await ds_client.get("/fixtures/-/query")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
# Write API return:true responses use "rows" consistently
@pytest.mark.asyncio
async def test_row_update_return_uses_rows_list(ds_error_shape):
await ds_error_shape.client.post(
"/data/docs/-/insert",
json={"row": {"id": 1, "title": "One"}},
headers={"Content-Type": "application/json"},
actor={"id": "root"},
)
response = await ds_error_shape.client.post(
"/data/docs/1/-/update",
json={"update": {"title": "Updated"}, "return": True},
headers={"Content-Type": "application/json"},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "row" not in data
assert data["rows"] == [{"id": 1, "title": "Updated"}]
# Schema endpoints: no existence oracle, no 500 on unknown database
@pytest.mark.asyncio
async def test_schema_endpoints_no_existence_oracle(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key)")
conn.close()
ds = Datasette([db_path], default_deny=True)
ds.root_enabled = True
try:
# An actor without view-database cannot distinguish an existing
# database from a missing one
denied_existing = await ds.client.get("/data/-/schema.json")
denied_missing = await ds.client.get("/nope/-/schema.json")
assert denied_existing.status_code == denied_missing.status_code == 403
# An authorized actor sees the real thing
root_existing = await ds.client.get("/data/-/schema.json", actor={"id": "root"})
assert root_existing.status_code == 200
root_missing = await ds.client.get("/nope/-/schema.json", actor={"id": "root"})
assert root_missing.status_code == 404
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_schema_unknown_database_is_404_not_500(ds_client):
response = await ds_client.get("/no_such_db/some_table/-/schema.json")
assert_canonical_error(response, 404)
# Unknown _extra names are a 400, not silently ignored
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/fixtures/facetable.json?_extra=nope",
"/fixtures/facetable.json?_extra=count,nope",
"/fixtures/simple_primary_key/1.json?_extra=nope",
"/fixtures/-/query.json?sql=select+1&_extra=nope",
),
)
async def test_unknown_extra_is_400(ds_client, path):
response = await ds_client.get(path)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Unknown _extra: nope"]
@pytest.mark.asyncio
async def test_html_only_extra_via_json_is_400(ds_client):
# display_rows exists for the HTML view but is not part of the JSON API
response = await ds_client.get("/fixtures/facetable.json?_extra=display_rows")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Unknown _extra: display_rows"]
@pytest.mark.asyncio
async def test_unknown_extra_ignored_on_html_pages(ds_client):
response = await ds_client.get("/fixtures/facetable?_extra=nope")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
# /-/threads exposes runtime internals and requires permissions-debug
@pytest.mark.asyncio
async def test_threads_requires_permissions_debug(ds_error_shape):
denied = await ds_error_shape.client.get("/-/threads.json")
assert_canonical_error(denied, 403)
allowed = await ds_error_shape.client.get("/-/threads.json", actor={"id": "root"})
assert allowed.status_code == 200
assert allowed.json()["ok"] is True
# _size is the one page-size parameter, with uniform validation
@pytest.mark.asyncio
async def test_query_list_size_supports_max_keyword(ds_client):
response = await ds_client.get("/fixtures/-/queries.json?_size=max")
assert response.status_code == 200
# ds_client runs with max_returned_rows=100
assert response.json()["limit"] == 100
@pytest.mark.asyncio
async def test_query_list_size_rejects_out_of_range(ds_client):
response = await ds_client.get("/fixtures/-/queries.json?_size=5000")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["_size must be <= 100"]
@pytest.mark.asyncio
async def test_query_list_size_rejects_non_integer(ds_client):
response = await ds_client.get("/fixtures/-/queries.json?_size=bananas")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["_size must be a positive integer"]
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ("allowed", "rules"))
async def test_debug_endpoints_use_size_and_page_parameters(ds_error_shape, endpoint):
base = "/-/{}.json?action=view-instance".format(endpoint)
ok = await ds_error_shape.client.get(
base + "&_size=1&_page=1", actor={"id": "root"}
)
assert ok.status_code == 200
assert ok.json()["page_size"] == 1
max_size = await ds_error_shape.client.get(
base + "&_size=max", actor={"id": "root"}
)
assert max_size.status_code == 200
assert max_size.json()["page_size"] == 200
too_big = await ds_error_shape.client.get(base + "&_size=500", actor={"id": "root"})
data = assert_canonical_error(too_big, 400)
assert data["errors"] == ["_size must be <= 200"]
bad_page = await ds_error_shape.client.get(base + "&_page=0", actor={"id": "root"})
data = assert_canonical_error(bad_page, 400)
assert data["errors"] == ["_page must be a positive integer"]
# Write endpoints parse the body as JSON regardless of Content-Type
@pytest.mark.asyncio
async def test_insert_works_without_content_type_header(ds_error_shape):
# Previously a 500 AttributeError
response = await ds_error_shape.client.post(
"/data/docs/-/insert",
content='{"row": {"id": 1, "title": "One"}}',
actor={"id": "root"},
)
assert response.status_code == 201
assert response.json()["rows"][0]["title"] == "One"
@pytest.mark.asyncio
async def test_insert_works_with_form_content_type(ds_error_shape):
# Previously 400 "Invalid content-type, must be application/json"
response = await ds_error_shape.client.post(
"/data/docs/-/insert",
content='{"row": {"id": 2, "title": "Two"}}',
headers={"Content-Type": "application/x-www-form-urlencoded"},
actor={"id": "root"},
)
assert response.status_code == 201
@pytest.mark.asyncio
async def test_insert_form_encoded_body_is_invalid_json(ds_error_shape):
response = await ds_error_shape.client.post(
"/data/docs/-/insert",
content="title=Three",
headers={"Content-Type": "application/x-www-form-urlencoded"},
actor={"id": "root"},
)
data = assert_canonical_error(response, 400)
assert data["errors"][0].startswith("Invalid JSON:")
@pytest.mark.asyncio
async def test_alter_and_set_column_type_ignore_content_type(ds_error_shape):
alter = await ds_error_shape.client.post(
"/data/docs/-/alter",
content='{"operations": [{"op": "add_column", "args": {"name": "extra"}}]}',
actor={"id": "root"},
)
assert alter.status_code == 200, alter.text
sct = await ds_error_shape.client.post(
"/data/docs/-/set-column-type",
content='{"column": "title", "column_type": {"type": "textarea"}}',
actor={"id": "root"},
)
assert sct.status_code == 200, sct.text
# SQL Interrupted errors carry plain text in JSON, not an HTML fragment
@pytest.mark.asyncio
async def test_sql_interrupted_json_error_is_plain_text(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5"
)
data = assert_canonical_error(response, 400)
assert "<" not in data["error"]
assert data["error"].startswith("SQL query took too long.")
@pytest.mark.asyncio
async def test_sql_interrupted_html_page_keeps_rich_error(ds_client):
response = await ds_client.get(
"/fixtures/-/query?sql=select+sleep(0.01)&_timelimit=5"
)
assert response.status_code == 400
assert "<textarea" in response.text
@pytest.mark.asyncio
async def test_next_url_extra_no_longer_exists(ds_client):
# next_url is always present in table JSON; the extra was removed
response = await ds_client.get("/fixtures/facetable.json?_extra=next_url")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Unknown _extra: next_url"]

View file

@ -1363,12 +1363,12 @@ async def test_permission_debug_tabs_with_query_string(ds_client):
# Test /-/allowed with query string
response = await ds_client.get(
"/-/allowed?action=view-table&_size=50", actor=actor
"/-/allowed?action=view-table&page_size=50", actor=actor
)
assert response.status_code == 200
# Check that Rules and Check tabs have the query string
assert 'href="/-/rules?action=view-table&amp;_size=50"' in response.text
assert 'href="/-/check?action=view-table&amp;_size=50"' in response.text
assert 'href="/-/rules?action=view-table&amp;page_size=50"' in response.text
assert 'href="/-/check?action=view-table&amp;page_size=50"' in response.text
# Playground and Actions should not have query string
assert 'href="/-/permissions"' in response.text
assert 'href="/-/actions"' in response.text

View file

@ -1,8 +1,5 @@
import pytest
import sqlite3
from datasette.utils import escape_sqlite
from datasette.utils.internal_db import INTERNAL_DB_SCHEMA_SQL
import sqlite_utils
# ensure refresh_schemas() gets called before interacting with internal_db
@ -19,53 +16,6 @@ async def test_internal_databases(ds_client):
assert databases.rows[0]["database_name"] == "fixtures"
@pytest.mark.asyncio
async def test_internal_migrations_recorded(ds_client):
internal_db = await ensure_internal(ds_client)
migrations = await internal_db.execute("""
select migration_set, name
from _sqlite_migrations
order by id
""")
assert [tuple(row) for row in migrations.rows] == [
("datasette_internal", "0001_initial")
]
@pytest.mark.asyncio
async def test_internal_migrations_adopt_existing_internal_db(tmp_path):
from datasette.app import Datasette
internal_db_path = str(tmp_path / "internal.db")
conn = sqlite3.connect(internal_db_path)
conn.executescript(INTERNAL_DB_SCHEMA_SQL)
conn.execute(
"insert into metadata_instance (key, value) values (?, ?)",
("legacy", "preserved"),
)
conn.commit()
conn.close()
ds = Datasette(internal=internal_db_path)
await ds.invoke_startup()
internal_db = ds.get_internal_database()
metadata = await internal_db.execute(
"select key, value from metadata_instance where key = 'legacy'"
)
assert [tuple(row) for row in metadata.rows] == [("legacy", "preserved")]
migrations = await internal_db.execute("""
select migration_set, name
from _sqlite_migrations
order by id
""")
assert [tuple(row) for row in migrations.rows] == [
("datasette_internal", "0001_initial")
]
ds.close()
@pytest.mark.asyncio
async def test_internal_tables(ds_client):
internal_db = await ensure_internal(ds_client)
@ -126,71 +76,19 @@ async def test_internal_foreign_key_references(ds_client):
internal_db = await ensure_internal(ds_client)
def inner(conn):
table_names = [
row[0]
for row in conn.execute(
"select name from sqlite_master where type = 'table'"
).fetchall()
]
def columns_for_table(table_name):
return {
row[1]
for row in conn.execute(
"PRAGMA table_info({})".format(escape_sqlite(table_name))
).fetchall()
}
def primary_keys_for_table(table_name):
return [
name
for _, name in sorted(
(row[5], row[1])
for row in conn.execute(
"PRAGMA table_info({})".format(escape_sqlite(table_name))
).fetchall()
if row[5]
)
]
columns_by_table = {
table_name: columns_for_table(table_name) for table_name in table_names
}
for table_name in table_names:
foreign_key_rows = conn.execute(
"PRAGMA foreign_key_list({})".format(escape_sqlite(table_name))
).fetchall()
foreign_keys_by_id = {}
for foreign_key in foreign_key_rows:
foreign_keys_by_id.setdefault(foreign_key[0], []).append(foreign_key)
for foreign_key_rows in foreign_keys_by_id.values():
foreign_key_rows.sort(key=lambda row: row[1])
other_table = foreign_key_rows[0][2]
other_columns = [row[4] for row in foreign_key_rows]
message = 'Column "{}.{}" references other table "{}" which does not exist'.format(
table_name, foreign_key_rows[0][3], other_table
db = sqlite_utils.Database(conn)
table_names = db.table_names()
for table in db.tables:
for fk in table.foreign_keys:
other_table = fk.other_table
other_column = fk.other_column
message = 'Column "{}.{}" references other column "{}.{}" which does not exist'.format(
table.name, fk.column, other_table, other_column
)
assert other_table in table_names, message + " (bad table)"
if all(other_column is None for other_column in other_columns):
other_columns = primary_keys_for_table(other_table)
length_message = 'Foreign key from "{}" to "{}" has {} columns but references {} columns'.format(
table_name,
other_table,
len(foreign_key_rows),
len(other_columns),
assert other_column in db[other_table].columns_dict, (
message + " (bad column)"
)
assert len(other_columns) == len(foreign_key_rows), length_message
for foreign_key, other_column in zip(foreign_key_rows, other_columns):
column = foreign_key[3]
message = 'Column "{}.{}" references other column "{}.{}" which does not exist'.format(
table_name, column, other_table, other_column
)
assert other_column in columns_by_table[other_table], (
message + " (bad column)"
)
await internal_db.execute_fn(inner)

View file

@ -11,7 +11,6 @@ from datasette.database import _deliver_write_result
from datasette.utils.sqlite import sqlite3, supports_returning
from datasette.utils import Column
import pytest
import sqlite_utils
import time
import uuid
@ -719,41 +718,6 @@ async def test_execute_write_fn_exception(db):
await db.execute_write_fn(write_fn)
@pytest.mark.asyncio
@pytest.mark.parametrize("num_sql_threads", (0, 1))
async def test_execute_write_fn_sqlite_utils_transaction(tmp_path, num_sql_threads):
# A write inside a failing Datasette task must never become visible or
# survive the rollback. Exercise both the synchronous and writer-thread
# paths against a file-backed database so a second connection can observe
# committed state independently.
db_path = tmp_path / "test.db"
sqlite3.connect(db_path).close()
ds = Datasette([str(db_path)], settings={"num_sql_threads": num_sql_threads})
db = ds.get_database("test")
await db.execute_write("create table items (id integer primary key)")
# This reader is used inside the write callback, which may run on another
# thread, but it is never accessed concurrently.
reader = sqlite3.connect(db_path, check_same_thread=False)
def insert_then_fail(conn):
# Datasette must open the outer transaction before sqlite-utils writes.
assert conn.in_transaction
sqlite_utils.Database(conn)["items"].insert({"id": 1})
# If sqlite-utils committed its own transaction, this would return 1.
assert reader.execute("select count(*) from items").fetchone()[0] == 0
# Simulate a later step failing after the sqlite-utils write succeeded.
raise ValueError("deliberate")
try:
with pytest.raises(ValueError, match="deliberate"):
await db.execute_write_fn(insert_then_fail)
# The outer transaction must roll back the sqlite-utils write as well.
assert reader.execute("select count(*) from items").fetchone()[0] == 0
finally:
reader.close()
db.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("param_name", ["conn", "connection", "db", "c"])
async def test_execute_write_fn_accepts_any_single_param_name(db, param_name):

View file

@ -167,7 +167,7 @@ def test_static_rejects_path_traversal(tmp_path, monkeypatch):
@pytest.mark.asyncio
async def test_datasette_constructor():
ds = Datasette()
databases = (await ds.client.get("/-/databases.json")).json()["databases"]
databases = (await ds.client.get("/-/databases.json")).json()
assert databases == [
{
"name": "_memory",
@ -184,12 +184,11 @@ async def test_datasette_constructor():
@pytest.mark.asyncio
async def test_num_sql_threads_zero():
ds = Datasette([], memory=True, settings={"num_sql_threads": 0})
ds.root_enabled = True
db = ds.add_database(Database(ds, memory_name="test_num_sql_threads_zero"))
await db.execute_write("create table t(id integer primary key)")
await db.execute_write("insert into t (id) values (1)")
response = await ds.client.get("/-/threads.json", actor={"id": "root"})
assert response.json() == {"ok": True, "num_threads": 0, "threads": []}
response = await ds.client.get("/-/threads.json")
assert response.json() == {"num_threads": 0, "threads": []}
response2 = await ds.client.get("/test_num_sql_threads_zero/t.json?_shape=array")
assert response2.json() == [{"id": 1}]

View file

@ -318,7 +318,7 @@ async def test_actor_parameter_sets_cookie(datasette):
"""Passing actor= should sign a ds_actor cookie and authenticate the request."""
response = await datasette.client.get("/-/actor.json", actor={"id": "root"})
assert response.status_code == 200
assert response.json() == {"ok": True, "actor": {"id": "root"}}
assert response.json() == {"actor": {"id": "root"}}
@pytest.mark.asyncio
@ -327,7 +327,7 @@ async def test_actor_parameter_works_with_request_method(datasette):
"GET", "/-/actor.json", actor={"id": "root"}
)
assert response.status_code == 200
assert response.json() == {"ok": True, "actor": {"id": "root"}}
assert response.json() == {"actor": {"id": "root"}}
@pytest.mark.asyncio
@ -362,7 +362,7 @@ async def test_actor_parameter_merges_with_other_cookies(datasette):
cookies={"unrelated": "value"},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "actor": {"id": "root"}}
assert response.json() == {"actor": {"id": "root"}}
@pytest.mark.asyncio

View file

@ -1,38 +1,8 @@
from datasette.utils.asgi import PayloadTooLarge, Request
from datasette.utils.asgi import Request
import json
import pytest
def _post_scope(headers=None):
return {
"http_version": "1.1",
"method": "POST",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"scheme": "http",
"type": "http",
"headers": headers or [[b"content-type", b"application/json"]],
}
def _receive_chunks(chunks):
messages = [
{
"type": "http.request",
"body": chunk,
"more_body": i < len(chunks) - 1,
}
for i, chunk in enumerate(chunks)
]
messages.reverse()
async def receive():
return messages.pop()
return receive
@pytest.mark.asyncio
async def test_request_post_vars():
scope = {
@ -136,70 +106,6 @@ async def test_request_json_invalid():
await request.json()
@pytest.mark.asyncio
async def test_request_post_body_multiple_chunks():
request = Request(_post_scope(), _receive_chunks([b"hello ", b"world"]))
assert await request.post_body() == b"hello world"
@pytest.mark.asyncio
async def test_request_post_body_content_length_too_large():
# Should reject based on content-length without reading the body
async def receive():
raise AssertionError("receive() should not be called")
scope = _post_scope(
headers=[
[b"content-type", b"application/json"],
[b"content-length", b"101"],
]
)
request = Request(scope, receive)
with pytest.raises(PayloadTooLarge):
await request.post_body(max_bytes=100)
@pytest.mark.asyncio
async def test_request_post_body_streaming_too_large():
# No content-length header - limit enforced as chunks arrive
chunks = [b"a" * 60, b"b" * 60, b"c" * 60]
request = Request(_post_scope(), _receive_chunks(chunks))
with pytest.raises(PayloadTooLarge):
await request.post_body(max_bytes=100)
@pytest.mark.asyncio
async def test_request_post_body_limit_from_constructor():
request = Request(
_post_scope(), _receive_chunks([b"too much data"]), max_post_body_bytes=5
)
with pytest.raises(PayloadTooLarge):
await request.post_body()
@pytest.mark.asyncio
async def test_request_post_body_limit_disabled():
body = b"a" * (3 * 1024 * 1024)
request = Request(_post_scope(), _receive_chunks([body]), max_post_body_bytes=0)
assert await request.post_body() == body
@pytest.mark.asyncio
async def test_request_post_body_default_limit():
# Bodies over 2MB are rejected by default
request = Request(_post_scope(), _receive_chunks([b"a" * (2 * 1024 * 1024 + 1)]))
with pytest.raises(PayloadTooLarge):
await request.post_body()
@pytest.mark.asyncio
async def test_request_json_too_large():
body = json.dumps({"rows": ["x" * 100]}).encode("utf-8")
request = Request(_post_scope(), _receive_chunks([body]), max_post_body_bytes=50)
with pytest.raises(PayloadTooLarge):
await request.json()
def test_request_args():
request = Request.fake("/foo?multi=1&multi=2&single=3")
assert "1" == request.args.get("multi")

View file

@ -1,5 +1,4 @@
from datasette.utils.asgi import Response
import json
import pytest
@ -53,26 +52,3 @@ async def test_response_set_cookie():
},
{"type": "http.response.body", "body": b""},
] == events
def test_response_error_single_message():
response = Response.error("Method not allowed", 405)
assert response.status == 405
assert response.content_type == "application/json; charset=utf-8"
assert json.loads(response.body) == {
"ok": False,
"error": "Method not allowed",
"errors": ["Method not allowed"],
"status": 405,
}
def test_response_error_message_list_and_default_status():
response = Response.error(["First problem", "Second problem"])
assert response.status == 400
assert json.loads(response.body) == {
"ok": False,
"error": "First problem; Second problem",
"errors": ["First problem", "Second problem"],
"status": 400,
}

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