Compare commits

..

4 commits

Author SHA1 Message Date
Simon Willison
a668168835 Fix bug with compound pks and row panel 2026-03-09 18:18:50 -07:00
Simon Willison
73091472cb Merge main into row-panel
Resolved conflicts:
- pyproject.toml: kept main's dependency-groups structure
- datasette/static/table.js: kept both initRowDetailPanel (row-panel)
  and openColumnChooser (main)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:12:45 -07:00
Simon Willison
472caf4edf Install Playwright in CI 2025-11-08 16:54:59 -08:00
Simon Willison
5e0cfa8b30 Initial prototype of row side panel, refs #2589 2025-11-08 16:43:47 -08:00
215 changed files with 6005 additions and 49551 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

@ -0,0 +1,35 @@
name: Deploy a Datasette branch preview to Vercel
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to deploy"
required: true
type: string
jobs:
deploy-branch-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install datasette-publish-vercel
- name: Deploy the preview
env:
VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }}
run: |
export BRANCH="${{ github.event.inputs.branch }}"
wget https://latest.datasette.io/fixtures.db
datasette publish vercel fixtures.db \
--branch $BRANCH \
--project "datasette-preview-$BRANCH" \
--token $VERCEL_TOKEN \
--scope datasette \
--about "Preview of $BRANCH" \
--about_url "https://github.com/simonw/datasette/tree/$BRANCH"

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
uses: actions/checkout@v7
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -57,7 +57,7 @@ jobs:
db.route = "alternative-route"
' > plugins/alternative_route.py
cp fixtures.db fixtures2.db
- name: And the counters writable stored query demo
- name: And the counters writable canned query demo
run: |
cat > plugins/counters.py <<EOF
from datasette import hookimpl
@ -69,24 +69,23 @@ jobs:
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
for name in ("counter_a", "counter_b", "counter_c"):
await datasette.add_query(
"counters",
"increment_{}".format(name),
"update counters set value = value + 1 where name = '{}'".format(name),
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
is_write=True,
is_trusted=True,
)
await datasette.add_query(
"counters",
"decrement_{}".format(name),
"update counters set value = value - 1 where name = '{}'".format(name),
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
is_write=True,
is_trusted=True,
)
return inner
@hookimpl
def canned_queries(database):
if database == "counters":
queries = {}
for name in ("counter_a", "counter_b", "counter_c"):
queries["increment_{}".format(name)] = {
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
"write": True,
}
queries["decrement_{}".format(name)] = {
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
"write": True,
}
return queries
EOF
# - name: Make some modifications to metadata.json
# run: |
@ -117,7 +116,7 @@ jobs:
--plugins-dir=plugins \
--branch=$GITHUB_SHA \
--version-note=$GITHUB_SHA \
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
--install 'datasette-ephemeral-tables>=0.2.2' \
--service "datasette-latest$SUFFIX" \
--secret $LATEST_DATASETTE_SECRET

View file

@ -0,0 +1,16 @@
name: Read the Docs Pull Request Preview
on:
pull_request_target:
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

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

View file

@ -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@v4
- uses: actions/cache@v4
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@v4
- 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@v4
- 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@v4
- 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@v4
- 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@v2
- 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@v4
- 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@v5
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@v4
- 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@v4
- 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@v4
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@v4
- 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

@ -9,11 +9,10 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -28,6 +27,14 @@ jobs:
run: |
pip install . --group dev
pip freeze
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers
- name: Install Playwright dependencies
run: |
playwright install
- name: Run tests
run: |
pytest -n auto -m "not serial"

View file

@ -10,6 +10,6 @@ jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v2
- 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@v2
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
env:

6
.gitignore vendored
View file

@ -1,12 +1,8 @@
build-metadata.json
datasets.json
.playwright-mcp
scratchpad
ignored/
.vscode
uv.lock
@ -135,4 +131,4 @@ tests/*.dylib
tests/*.so
tests/*.dll
.idea
.idea

View file

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

@ -1,123 +0,0 @@
"""
Pytest plugin that automatically closes any Datasette instances constructed
during a pytest test both in the test body and in function-scoped
fixtures. Instances constructed by session-, module-, class- or package-
scoped fixtures are left alone, because other tests in the session will
still want to use them.
Registered as a pytest11 entry point in pyproject.toml so that downstream
projects using Datasette get the same FD-safety net for their own tests.
Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the
equivalent ini file).
"""
from __future__ import annotations
import contextvars
import weakref
import pytest
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
"datasette_active_instances", default=None
)
_original_init = None
def _install_tracking():
# datasette.app is imported lazily here rather than at module level:
# as a pytest11 entry point this module is imported during pytest
# startup, before pytest-cov starts measuring, so a module-level
# import would drag in all of datasette and make every import-time
# line in the package invisible to coverage
global _original_init
if _original_init is not None:
return
from datasette.app import Datasette
_original_init = Datasette.__init__
def _tracking_init(self, *args, **kwargs):
_original_init(self, *args, **kwargs)
instances = _active_instances.get()
if instances is not None:
instances.append(weakref.ref(self))
Datasette.__init__ = _tracking_init
def pytest_configure(config):
if _enabled(config):
_install_tracking()
def pytest_addoption(parser):
parser.addini(
"datasette_autoclose",
help=(
"Automatically close Datasette instances created inside test "
"bodies and function-scoped fixtures (default: true)."
),
default="true",
)
def _enabled(config) -> bool:
value = config.getini("datasette_autoclose")
if isinstance(value, bool):
return value
return str(value).strip().lower() not in ("false", "0", "no", "off")
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(item, nextitem):
"""Track Datasette instances across setup, call and teardown; close at end."""
if not _enabled(item.config):
yield
return
refs: list[weakref.ref] = []
token = _active_instances.set(refs)
try:
yield
finally:
_active_instances.reset(token)
for ref in reversed(refs):
ds = ref()
if ds is None:
continue
try:
ds.close()
except Exception as e:
item.warn(
pytest.PytestUnraisableExceptionWarning(
f"Error closing Datasette instance: {e!r}"
)
)
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef, request):
"""Exempt instances created by non-function-scoped fixtures.
Session-, module-, class- and package-scoped fixtures produce Datasette
instances that must survive beyond the current test other tests in
the session will still use them. When such a fixture creates one or
more Datasette instances during its setup, we snapshot the tracking
list before the fixture runs and subtract off any instances that were
added during its setup, so they don't get closed at test teardown.
"""
refs = _active_instances.get()
if refs is None:
yield
return
before_ids = {id(ref) for ref in refs}
yield
if fixturedef.scope != "function":
new_refs = [ref for ref in refs if id(ref) not in before_ids]
for new_ref in new_refs:
try:
refs.remove(new_ref)
except ValueError:
pass

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,6 @@ from .app import (
SQLITE_LIMIT_ATTACHED,
pm,
)
from .inspect import inspect_tables
from .utils import (
LoadExtension,
StartupError,
@ -155,14 +154,14 @@ async def inspect_(files, sqlite_extensions):
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
data = {}
for name, database in app.databases.items():
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
counts = await database.table_counts(limit=3600 * 1000)
data[name] = {
"hash": database.hash,
"size": database.size,
"file": database.path,
"tables": {
table_name: {"count": table["count"]}
for table_name, table in tables.items()
table_name: {"count": table_count}
for table_name, table_count in counts.items()
},
}
return data
@ -616,9 +615,7 @@ def serve(
for file in file_paths:
if not pathlib.Path(file).exists():
if create:
conn = sqlite3.connect(file)
conn.execute("vacuum")
conn.close()
sqlite3.connect(file).execute("vacuum")
else:
raise click.ClickException(
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
@ -664,16 +661,15 @@ def serve(
# Private utility mechanism for writing unit tests
return ds
# Run async soundness checks before startup hooks, since invoke_startup
# now populates internal tables which requires querying each database
run_sync(lambda: check_databases(ds))
# Run the "startup" plugin hooks
try:
run_sync(ds.invoke_startup)
except StartupError as e:
raise click.ClickException(e.args[0])
# Run async soundness checks - but only if we're not under pytest
run_sync(lambda: check_databases(ds))
if headers and not get:
raise click.ClickException("--headers can only be used with --get")

View file

@ -1,81 +0,0 @@
from enum import Enum
class SQLiteType(Enum):
TEXT = "TEXT"
INTEGER = "INTEGER"
REAL = "REAL"
BLOB = "BLOB"
NUMERIC = "NUMERIC"
@classmethod
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType":
if declared_type is None:
return cls.BLOB
normalized = declared_type.strip().upper()
if not normalized:
return cls.BLOB
if "INT" in normalized:
return cls.INTEGER
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
return cls.TEXT
if "BLOB" in normalized:
return cls.BLOB
if any(
token in normalized
for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub
):
return cls.REAL
return cls.NUMERIC
class ColumnType:
"""
Base class for column types.
Subclasses must define ``name`` and ``description`` as class attributes:
- ``name``: Unique identifier string. Lowercase, no spaces.
Examples: "markdown", "file", "email", "url", "point", "image".
- ``description``: Human-readable label for admin UI dropdowns.
Examples: "Markdown text", "File reference", "Email address".
- ``sqlite_types``: Optional tuple of SQLiteType values restricting
which SQLite column types this ColumnType can be assigned to.
Instantiate with an optional ``config`` dict to bind per-column
configuration::
ct = MyColumnType(config={"key": "value"})
ct.config # {"key": "value"}
"""
name: str
description: str
sqlite_types: tuple[SQLiteType, ...] | None = None
def __init__(self, config=None):
self.config = config
async def render_cell(self, value, column, table, database, datasette, request):
"""
Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain.
"""
return None
async def validate(self, value, datasette):
"""
Validate a value before it is written. Return None if valid,
or a string error message if invalid.
"""
return None
async def transform_value(self, value, datasette):
"""
Transform a value before it appears in JSON API output.
Return the transformed value. Default: return unchanged.
"""
return value

View file

@ -1,178 +0,0 @@
"""
Header-based CSRF (Cross-Origin) protection.
Datasette uses the Sec-Fetch-Site + Origin header approach described in
Filippo Valsorda's article (https://words.filippo.io/csrf/) and implemented
in Go 1.25's http.CrossOriginProtection. This replaces the previous
token-based asgi-csrf mechanism.
"""
from __future__ import annotations
import secrets
import urllib.parse
from .utils.asgi import asgi_send
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
def _normalize_headers(raw_headers):
"""Lowercase header names; for duplicates, last value wins."""
result = {}
for name, value in raw_headers:
if isinstance(name, str):
name = name.encode("latin-1")
if isinstance(value, str):
value = value.encode("latin-1")
result[name.lower()] = value
return result
def _origin_tuple(value):
"""
Parse an origin-like string into ``(scheme, host, port)`` with default
ports filled in. Raises ``ValueError`` for malformed input.
"""
parsed = urllib.parse.urlsplit(value)
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
if not scheme or not host:
raise ValueError("missing scheme or host in {!r}".format(value))
port = parsed.port # may raise ValueError on bad ports
if port is None:
port = DEFAULT_PORTS.get(scheme)
if port is None:
raise ValueError("unknown default port for scheme {!r}".format(scheme))
return scheme, host, port
def _install_legacy_csrftoken(scope):
"""
Populate ``scope["csrftoken"]`` with a callable returning a per-request
random token. Provided for plugin compatibility only - core no longer
uses this value for CSRF enforcement.
"""
def csrftoken():
if "_datasette_legacy_csrftoken" not in scope:
scope["_datasette_legacy_csrftoken"] = secrets.token_urlsafe(32)
return scope["_datasette_legacy_csrftoken"]
scope["csrftoken"] = csrftoken
class CrossOriginProtectionMiddleware:
"""
Modern CSRF protection using the Sec-Fetch-Site and Origin headers.
Based on Filippo Valsorda's algorithm, as implemented in Go 1.25's
http.CrossOriginProtection. See https://words.filippo.io/csrf/
Unsafe-method requests are allowed through only if they look same-origin.
Non-browser clients (curl, etc.) send neither Sec-Fetch-Site nor Origin
and are passed through unchanged - CSRF is a browser-only attack.
"""
SAFE_METHODS = SAFE_METHODS
def __init__(self, app, datasette):
self.app = app
self.datasette = datasette
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
_install_legacy_csrftoken(scope)
if scope.get("method", "GET") in self.SAFE_METHODS:
await self.app(scope, receive, send)
return
headers = _normalize_headers(scope.get("headers") or [])
authorization = headers.get(b"authorization", b"").decode("latin-1")
cookie_header = headers.get(b"cookie")
# Bearer-token requests are not ambient browser credentials, so they
# are not CSRF-vulnerable. Narrowly exempt them from the header check
# before evaluating Sec-Fetch-Site / Origin. Only "Bearer" is exempt;
# schemes like Basic or Digest can be browser-managed and ambient.
# If the request also carries a Cookie header, ambient cookie auth
# could be in play, so do NOT treat it as exempt.
if authorization and not cookie_header:
parts = authorization.split(None, 1)
if parts and parts[0].lower() == "bearer":
await self.app(scope, receive, send)
return
origin_bytes = headers.get(b"origin")
sec_fetch_site_bytes = headers.get(b"sec-fetch-site")
host_bytes = headers.get(b"host", b"")
origin = origin_bytes.decode("latin-1") if origin_bytes else None
sec_fetch_site = (
sec_fetch_site_bytes.decode("latin-1") if sec_fetch_site_bytes else None
)
host = host_bytes.decode("latin-1")
# Primary defense: Sec-Fetch-Site (set by browsers, unforgeable from JS)
if sec_fetch_site is not None:
if sec_fetch_site in ("same-origin", "none"):
await self.app(scope, receive, send)
return
await self._forbid(
send,
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
sec_fetch_site
),
)
return
# No Sec-Fetch-Site and no Origin -> non-browser client (curl, API, etc.)
if origin is None:
await self.app(scope, receive, send)
return
# Fallback for older browsers: Origin must match the request's own
# scheme + host + port. Compare full origin tuples, not host alone.
request_scheme = self._request_scheme(scope)
try:
origin_tuple = _origin_tuple(origin)
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
except ValueError:
await self._forbid(
send,
"Malformed Origin {!r} or Host {!r}".format(origin, host),
)
return
if origin_tuple == expected_tuple:
await self.app(scope, receive, send)
return
await self._forbid(
send,
"Origin {!r} does not match Host {!r}".format(origin, host),
)
def _request_scheme(self, scope):
if self.datasette is not None:
try:
if self.datasette.setting("force_https_urls"):
return "https"
except Exception:
pass
return scope.get("scheme") or "http"
async def _forbid(self, send, reason):
await asgi_send(
send,
content=await self.datasette.render_template(
"csrf_error.html", {"reason": reason}
),
status=403,
content_type="text/html; charset=utf-8",
)

View file

@ -1,23 +1,18 @@
import asyncio
import atexit
from collections import namedtuple
import inspect
import os
from pathlib import Path
import janus
import queue
import sqlite_utils
import sys
import tempfile
import threading
import uuid
from .tracer import trace
from .utils import (
call_with_supported_arguments,
detect_fts,
detect_primary_keys,
detect_spatialite,
escape_sqlite,
get_all_foreign_keys,
get_outbound_foreign_keys,
md5_not_usedforsecurity,
@ -26,24 +21,14 @@ from .utils import (
table_columns,
table_column_details,
)
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
from .utils.sqlite import sqlite_hidden_table_names
from .utils.sqlite import sqlite_version
from .inspect import inspect_hash
connections = threading.local()
EXECUTE_WRITE_RETURNING_LIMIT = 10
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
class DatasetteClosedError(RuntimeError):
"""Raised when using a Datasette or Database instance after close()."""
_SHUTDOWN = object()
class Database:
# For table counts stop at this many rows:
count_limit = 10000
@ -57,7 +42,6 @@ class Database:
is_memory=False,
memory_name=None,
mode=None,
is_temp_disk=False,
):
self.name = None
self._thread_local_id = f"x{self._thread_local_id_counter}"
@ -68,44 +52,19 @@ class Database:
self.is_mutable = is_mutable
self.is_memory = is_memory
self.memory_name = memory_name
self.is_temp_disk = is_temp_disk
if memory_name is not None:
self.is_memory = True
if is_temp_disk:
fd, temp_path = tempfile.mkstemp(suffix=".db", prefix="datasette_temp_")
os.close(fd)
self.path = temp_path
self.is_mutable = True
self.mode = "rwc"
self._wal_enabled = False
atexit.register(self._cleanup_temp_file)
else:
self._wal_enabled = False
self.cached_hash = None
self.cached_size = None
self._cached_table_counts = None
self._write_thread = None
self._write_queue = None
self._closed = False
self._pending_execute_futures = set()
self._pending_execute_futures_lock = threading.Lock()
# These are used when in non-threaded mode:
self._read_connection = None
self._write_connection = None
# This is used to track all file connections so they can be closed
self._all_file_connections = []
if not is_temp_disk:
self.mode = mode
def _check_not_closed(self):
if self._closed:
raise DatasetteClosedError(
"Database {!r} has been closed".format(self.name)
)
def _remove_pending_execute_future(self, future):
with self._pending_execute_futures_lock:
self._pending_execute_futures.discard(future)
self.mode = mode
@property
def cached_table_counts(self):
@ -126,8 +85,6 @@ class Database:
return md5_not_usedforsecurity(self.name)[:6]
def suggest_name(self):
if self.is_temp_disk:
return "_temp_disk"
if self.path:
return Path(self.path).stem
elif self.memory_name:
@ -166,108 +123,22 @@ class Database:
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
)
self._all_file_connections.append(conn)
if self.is_temp_disk and not self._wal_enabled:
conn.execute("PRAGMA journal_mode=WAL")
self._wal_enabled = True
return conn
def close(self):
"""Release all resources held by this database.
Idempotent. After close() further calls to execute()/execute_fn()/
execute_write()/execute_write_fn() raise DatasetteClosedError.
"""
if self._closed:
return
with self._pending_execute_futures_lock:
if self._closed:
return
self._closed = True
pending_execute_futures = tuple(self._pending_execute_futures)
# Shut down the write thread, if any, via a sentinel. The thread
# drains any writes already queued before the sentinel and then
# closes its own write connection and returns.
write_thread = self._write_thread
if write_thread is not None and self._write_queue is not None:
self._write_queue.put(_SHUTDOWN)
write_thread.join(timeout=10)
if write_thread.is_alive():
sys.stderr.write(
"Datasette: write thread for {!r} did not exit within 10s\n".format(
self.name
)
)
sys.stderr.flush()
for future in pending_execute_futures:
try:
future.result()
except Exception:
pass
# Close anything still tracked in _all_file_connections
# Close all connections - useful to avoid running out of file handles in tests
for connection in self._all_file_connections:
try:
connection.close()
except Exception:
pass
self._all_file_connections = []
# Drop per-thread cached read connections we can reach
try:
delattr(connections, self._thread_local_id)
except AttributeError:
pass
# Close non-threaded-mode cached connections if still open
if self._read_connection is not None:
try:
self._read_connection.close()
except Exception:
pass
self._read_connection = None
if self._write_connection is not None:
try:
self._write_connection.close()
except Exception:
pass
self._write_connection = None
if self.is_temp_disk:
self._cleanup_temp_file()
def _cleanup_temp_file(self):
if self.is_temp_disk and self.path:
for suffix in ("", "-wal", "-shm"):
try:
os.unlink(self.path + suffix)
except OSError:
pass
async def execute_write(
self,
sql,
params=None,
block=True,
request=None,
return_all=False,
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
transaction=True,
):
self._check_not_closed()
if returning_limit < 0:
raise ValueError("returning_limit must be >= 0")
connection.close()
async def execute_write(self, sql, params=None, block=True, request=None):
def _inner(conn):
cursor = conn.execute(sql, params or [])
return ExecuteWriteResult.from_cursor(
cursor, return_all=return_all, returning_limit=returning_limit
)
return conn.execute(sql, params or [])
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):
self._check_not_closed()
def _inner(conn):
return conn.executescript(sql)
@ -278,8 +149,6 @@ class Database:
return results
async def execute_write_many(self, sql, params_seq, block=True, request=None):
self._check_not_closed()
def _inner(conn):
count = 0
@ -301,15 +170,13 @@ class Database:
return results
async def execute_isolated_fn(self, fn):
self._check_not_closed()
# Open a new connection just for the duration of this function,
# Open a new connection just for the duration of this function
# blocking the write queue to avoid any writes occurring during it
write = self.is_mutable
def _run():
isolated_connection = self.connect(write=write)
if self.ds.executor is None:
# non-threaded mode
isolated_connection = self.connect(write=True)
try:
return fn(isolated_connection)
result = fn(isolated_connection)
finally:
isolated_connection.close()
try:
@ -317,34 +184,13 @@ class Database:
except ValueError:
# Was probably a memory connection
pass
if self.ds.executor is None:
# non-threaded mode
return _run()
if not write:
# Immutable database - no writes can ever occur, so there is no
# write queue to block; run against a fresh read-only connection
return await asyncio.get_running_loop().run_in_executor(
self.ds.executor, _run
)
# Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True)
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
self._check_not_closed()
return await self.execute_isolated_fn(
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
)
return result
else:
# Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True)
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
self._check_not_closed()
pending_events = []
def track_event(event):
pending_events.append(event)
fn = self._wrap_fn_with_hooks(fn, request, transaction, track_event)
fn = self._wrap_fn_with_hooks(fn, request, transaction)
if self.ds.executor is None:
# non-threaded mode
if self._write_connection is None:
@ -352,54 +198,17 @@ 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)
return fn(self._write_connection)
else:
result = fn(self._write_connection)
return fn(self._write_connection)
else:
result = await self._send_to_write_thread(
return await self._send_to_write_thread(
fn, block=block, transaction=transaction
)
if block:
for event in pending_events:
await self.ds.track_event(event)
else:
# For non-blocking writes, spawn a background task to
# dispatch events after the write thread completes
task_id, reply_future = result
async def _dispatch_events_after_write():
try:
await reply_future
except Exception:
# if the write failed, don't emit success events
return
for event in pending_events:
await self.ds.track_event(event)
asyncio.ensure_future(_dispatch_events_after_write())
result = task_id
return result
def _wrap_fn_with_hooks(self, fn, request, transaction, track_event):
def _wrap_fn_with_hooks(self, fn, request, transaction):
from .plugins import pm
# Wrap fn so it receives track_event if its signature supports it.
# Historically fn was called positionally, so any single-parameter
# name (conn, connection, db, ...) worked. Preserve that by only
# switching to keyword dependency injection when the callback
# explicitly opts in by declaring a `track_event` parameter.
original_fn = fn
if "track_event" in inspect.signature(original_fn).parameters:
def fn_with_track_event(conn):
return call_with_supported_arguments(
original_fn, conn=conn, track_event=track_event
)
fn = fn_with_track_event
wrappers = pm.hook.write_wrapper(
datasette=self.ds,
database=self.name,
@ -411,9 +220,10 @@ class Database:
return fn
# Build the wrapped fn by nesting context manager generators.
# The first wrapper returned by pluggy is outermost.
original_fn = fn
for wrapper_factory in reversed(wrappers):
fn = _apply_write_wrapper(fn, wrapper_factory, track_event)
return fn
original_fn = _apply_write_wrapper(original_fn, wrapper_factory)
return original_fn
async def _send_to_write_thread(
self, fn, block=True, isolated_connection=False, transaction=True
@ -429,15 +239,18 @@ class Database:
)
self._write_thread.start()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
loop = asyncio.get_running_loop()
reply_future = loop.create_future()
reply_queue = janus.Queue()
self._write_queue.put(
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
WriteTask(fn, task_id, reply_queue, isolated_connection, transaction)
)
if block:
return await reply_future
result = await reply_queue.async_q.get()
if isinstance(result, Exception):
raise result
else:
return result
else:
return task_id, reply_future
return task_id
def _execute_writes(self):
# Infinite looping thread that protects the single write connection
@ -451,22 +264,17 @@ class Database:
conn_exception = e
while True:
task = self._write_queue.get()
if task is _SHUTDOWN:
if conn is not None:
try:
conn.close()
except Exception:
pass
return
exception = None
result = None
if conn_exception is not None:
exception = conn_exception
elif task.isolated_connection:
try:
result = conn_exception
else:
if task.isolated_connection:
isolated_connection = self.connect(write=True)
try:
result = task.fn(isolated_connection)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
result = e
finally:
isolated_connection.close()
try:
@ -474,26 +282,20 @@ class Database:
except ValueError:
# Was probably a memory connection
pass
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
else:
try:
if task.transaction:
with conn:
conn.execute("BEGIN IMMEDIATE")
else:
try:
if task.transaction:
with conn:
result = task.fn(conn)
else:
result = task.fn(conn)
else:
result = task.fn(conn)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
_deliver_write_result(task, result, exception)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
result = e
task.reply_queue.sync_q.put(result)
async def execute_fn(self, fn):
self._check_not_closed()
if self.ds.executor is None:
# non-threaded mode
if self._read_connection is None:
@ -510,12 +312,9 @@ class Database:
setattr(connections, self._thread_local_id, conn)
return fn(conn)
with self._pending_execute_futures_lock:
self._check_not_closed()
future = self.ds.executor.submit(in_thread)
self._pending_execute_futures.add(future)
future.add_done_callback(self._remove_pending_execute_future)
return await asyncio.wrap_future(future)
return await asyncio.get_event_loop().run_in_executor(
self.ds.executor, in_thread
)
async def execute(
self,
@ -527,7 +326,6 @@ class Database:
log_sql_errors=True,
):
"""Executes sql against db_name in a thread"""
self._check_not_closed()
page_size = page_size or self.ds.page_size
def sql_operation_in_thread(conn):
@ -575,7 +373,7 @@ class Database:
def hash(self):
if self.cached_hash is not None:
return self.cached_hash
elif self.is_mutable or self.is_memory or self.is_temp_disk:
elif self.is_mutable or self.is_memory:
return None
elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):
self.cached_hash = self.ds.inspect_data[self.name]["hash"]
@ -609,7 +407,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]
@ -733,7 +531,83 @@ class Database:
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
]
hidden_tables += await self.execute_fn(sqlite_hidden_table_names)
if sqlite_version()[1] >= 37:
hidden_tables += [x[0] for x in await self.execute("""
with shadow_tables as (
select name
from pragma_table_list
where [type] = 'shadow'
order by name
),
core_tables as (
select name
from sqlite_master
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
OR substr(name, 1, 1) == '_'
),
combined as (
select name from shadow_tables
union all
select name from core_tables
)
select name from combined order by 1
""")]
else:
hidden_tables += [x[0] for x in await self.execute("""
WITH base AS (
SELECT name
FROM sqlite_master
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
OR substr(name, 1, 1) == '_'
),
fts_suffixes AS (
SELECT column1 AS suffix
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
),
fts5_names AS (
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
),
fts5_shadow_tables AS (
SELECT
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
FROM fts5_names
JOIN fts_suffixes
),
fts3_suffixes AS (
SELECT column1 AS suffix
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
),
fts3_names AS (
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
),
fts3_shadow_tables AS (
SELECT
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
FROM fts3_names
JOIN fts3_suffixes
),
final AS (
SELECT name FROM base
UNION ALL
SELECT name FROM fts5_shadow_tables
UNION ALL
SELECT name FROM fts3_shadow_tables
)
SELECT name FROM final ORDER BY 1
""")]
# Also hide any FTS tables that have a content= argument
hidden_tables += [x[0] for x in await self.execute("""
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%'
AND sql LIKE '%USING FTS%'
AND sql LIKE '%content=%'
""")]
has_spatialite = await self.execute_fn(detect_spatialite)
if has_spatialite:
@ -798,8 +672,6 @@ class Database:
tags.append("mutable")
if self.is_memory:
tags.append("memory")
if self.is_temp_disk:
tags.append("temp_disk")
if self.hash:
tags.append(f"hash={self.hash}")
if self.size is not None:
@ -810,21 +682,18 @@ class Database:
return f"<Database: {self.name}{tags_str}>"
def _apply_write_wrapper(fn, wrapper_factory, track_event):
def _apply_write_wrapper(fn, wrapper_factory):
"""Apply a single write_wrapper context manager around fn.
``wrapper_factory`` is a callable that takes ``(conn)`` and optionally
``track_event``, and returns a generator that yields exactly once.
Code before the yield runs before ``fn(conn)``, code after the yield
runs after. The result of ``fn(conn)`` is sent into the generator
via ``.send()``, and any exception raised by ``fn(conn)`` is thrown
via ``.throw()``.
``wrapper_factory`` is a callable that takes ``(conn)`` and returns a
generator that yields exactly once. Code before the yield runs before
``fn(conn)``, code after the yield runs after. The result of
``fn(conn)`` is sent into the generator via ``.send()``, and any
exception raised by ``fn(conn)`` is thrown via ``.throw()``.
"""
def wrapped(conn):
gen = call_with_supported_arguments(
wrapper_factory, conn=conn, track_event=track_event
)
gen = wrapper_factory(conn)
# Advance to the yield point (run "before" code)
try:
next(gen)
@ -835,10 +704,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
# Execute the actual write
try:
result = fn(conn)
except Exception as e:
except Exception:
# Throw exception into generator so it can handle it
try:
gen.throw(e)
gen.throw(*sys.exc_info())
except StopIteration:
pass
# Re-raise the original exception
@ -855,45 +724,16 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
class WriteTask:
__slots__ = (
"fn",
"task_id",
"loop",
"reply_future",
"isolated_connection",
"transaction",
)
__slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction")
def __init__(
self, fn, task_id, loop, reply_future, isolated_connection, transaction
):
def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):
self.fn = fn
self.task_id = task_id
self.loop = loop
self.reply_future = reply_future
self.reply_queue = reply_queue
self.isolated_connection = isolated_connection
self.transaction = transaction
def _deliver_write_result(task, result, exception):
# Called from the write thread. Delivers the result back to the
# awaiting coroutine on its event loop via call_soon_threadsafe.
def _set():
if task.reply_future.done():
# Awaiter was cancelled; nothing to do.
return
if exception is not None:
task.reply_future.set_exception(exception)
else:
task.reply_future.set_result(result)
try:
task.loop.call_soon_threadsafe(_set)
except RuntimeError:
# Event loop has been closed; the awaiter is gone.
pass
class QueryInterrupted(Exception):
def __init__(self, e, sql, params):
self.e = e
@ -908,44 +748,6 @@ class MultipleValues(Exception):
pass
class ExecuteWriteResult:
def __init__(self, rowcount, lastrowid, description, rows, truncated):
self.rowcount = rowcount
self.lastrowid = lastrowid
self.description = description
self.truncated = truncated
self._rows = rows
@classmethod
def from_cursor(
cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT
):
rows = []
truncated = False
description = cursor.description
lastrowid = cursor.lastrowid
try:
if description is not None:
if return_all:
rows = cursor.fetchall()
else:
rows = cursor.fetchmany(returning_limit + 1)
if len(rows) > returning_limit:
rows = rows[:returning_limit]
truncated = True
rowcount = cursor.rowcount
finally:
cursor.close()
if description is not None and not return_all and truncated:
rowcount = -1
return cls(rowcount, lastrowid, description, rows, truncated)
def fetchall(self):
rows = self._rows
self._rows = []
return rows
class Results:
def __init__(self, rows, truncated, description):
self.rows = rows

View file

@ -48,32 +48,12 @@ def register_actions():
resource_class=DatabaseResource,
also_requires="view-database",
),
Action(
name="execute-write-sql",
abbr="ews",
description="Execute writable SQL queries",
resource_class=DatabaseResource,
also_requires="view-database",
),
Action(
name="create-table",
abbr="ct",
description="Create tables",
resource_class=DatabaseResource,
),
Action(
name="create-view",
abbr="cv",
description="Create views",
resource_class=DatabaseResource,
),
Action(
name="store-query",
abbr="sq",
description="Create stored queries",
resource_class=DatabaseResource,
also_requires="execute-sql",
),
# Table-level actions (child-level)
Action(
name="view-table",
@ -105,24 +85,12 @@ def register_actions():
description="Alter tables",
resource_class=TableResource,
),
Action(
name="set-column-type",
abbr="sct",
description="Set column type",
resource_class=TableResource,
),
Action(
name="drop-table",
abbr="dt",
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",
@ -130,16 +98,4 @@ def register_actions():
description="View named query results",
resource_class=QueryResource,
),
Action(
name="update-query",
abbr="uq",
description="Update stored queries",
resource_class=QueryResource,
),
Action(
name="delete-query",
abbr="dq",
description="Delete stored queries",
resource_class=QueryResource,
),
)

View file

@ -1,87 +0,0 @@
import json
import re
import markupsafe
from datasette import hookimpl
from datasette.column_types import ColumnType, SQLiteType
class UrlColumnType(ColumnType):
name = "url"
description = "URL"
sqlite_types = (SQLiteType.TEXT,)
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
return "URL must be a string"
if not re.match(r"^https?://\S+$", value.strip()):
return "Invalid URL"
return None
class EmailColumnType(ColumnType):
name = "email"
description = "Email address"
sqlite_types = (SQLiteType.TEXT,)
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'<a href="mailto:{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
return "Email must be a string"
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()):
return "Invalid email address"
return None
class JsonColumnType(ColumnType):
name = "json"
description = "JSON data"
sqlite_types = (SQLiteType.TEXT,)
async def render_cell(self, value, column, table, database, datasette, request):
if value is None:
return None
try:
parsed = json.loads(value) if isinstance(value, str) else value
formatted = json.dumps(parsed, indent=2)
escaped = markupsafe.escape(formatted)
return markupsafe.Markup(f"<pre>{escaped}</pre>")
except (json.JSONDecodeError, TypeError):
return None
async def validate(self, value, datasette):
if value is None or value == "":
return None
if isinstance(value, str):
try:
json.loads(value)
except json.JSONDecodeError:
return "Invalid JSON"
return None
class TextareaColumnType(ColumnType):
name = "textarea"
description = "Multiline text"
sqlite_types = (SQLiteType.TEXT,)
@hookimpl
def register_column_types(datasette):
return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType]

View file

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

View file

@ -1,80 +0,0 @@
from datasette import hookimpl
from datasette.jump import JumpSQL
DEBUG_MENU_ITEMS = (
(
"/-/databases",
"Databases",
"List of databases known to this Datasette instance.",
),
(
"/-/plugins",
"Installed plugins",
"Review loaded plugins, their versions and their registered hooks.",
),
(
"/-/versions",
"Version info",
"Check the Python, SQLite and dependency versions used by this server.",
),
(
"/-/settings",
"Settings",
"Inspect the active Datasette settings and configuration values.",
),
(
"/-/permissions",
"Debug permissions",
"Test permission checks for actors, actions and resources.",
),
(
"/-/messages",
"Debug messages",
"Try out temporary flash messages shown to users.",
),
(
"/-/allow-debug",
"Debug allow rules",
"Explore how allow blocks match actors against permission rules.",
),
(
"/-/debug/autocomplete",
"Debug autocomplete",
"Try out table autocomplete against a detected label column.",
),
(
"/-/threads",
"Debug threads",
"Inspect worker threads and database tasks.",
),
(
"/-/actor",
"Debug actor",
"View the actor object for the current signed-in user.",
),
(
"/-/patterns",
"Pattern portfolio",
"Browse Datasette UI patterns.",
),
)
@hookimpl
def jump_items_sql(datasette, actor, request):
async def inner():
if not await datasette.allowed(action="debug-menu", actor=actor):
return []
return [
JumpSQL.menu_item(
label=label,
url=datasette.urls.path(path),
description=description,
search_text=f"debug {label} {description}",
item_type="debug",
)
for path, label, description in DEBUG_MENU_ITEMS
]
return inner

View file

@ -1,82 +0,0 @@
from datasette import hookimpl
from datasette.jump import JumpSQL
@hookimpl
def jump_items_sql(datasette, actor, request):
async def inner():
database_sql, database_params = await datasette.allowed_resources_sql(
action="view-database", actor=actor
)
table_sql, table_params = await datasette.allowed_resources_sql(
action="view-table", actor=actor
)
query_sql, query_params = await datasette.allowed_resources_sql(
action="view-query", actor=actor
)
return [
JumpSQL(
sql=f"""
WITH allowed_databases AS (
{database_sql}
)
SELECT
'database' AS type,
parent AS label,
NULL AS description,
json_object(
'method', 'database',
'database', parent
) AS url,
parent AS search_text,
NULL AS display_name
FROM allowed_databases
""",
params=database_params,
),
JumpSQL(
sql=f"""
WITH allowed_tables AS (
{table_sql}
)
SELECT
CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type,
allowed_tables.parent || ': ' || allowed_tables.child AS label,
NULL AS description,
json_object(
'method', 'table',
'database', allowed_tables.parent,
'table', allowed_tables.child
) AS url,
allowed_tables.parent || ' ' || allowed_tables.child AS search_text,
NULL AS display_name
FROM allowed_tables
LEFT JOIN catalog_views
ON catalog_views.database_name = allowed_tables.parent
AND catalog_views.view_name = allowed_tables.child
""",
params=table_params,
),
JumpSQL(
sql=f"""
WITH allowed_queries AS (
{query_sql}
)
SELECT
'query' AS type,
allowed_queries.parent || ': ' || allowed_queries.child AS label,
NULL AS description,
json_object(
'method', 'query',
'database', allowed_queries.parent,
'query', allowed_queries.child
) AS url,
allowed_queries.parent || ' ' || allowed_queries.child AS search_text,
NULL AS display_name
FROM allowed_queries
""",
params=query_params,
),
]
return inner

View file

@ -0,0 +1,41 @@
from datasette import hookimpl
@hookimpl
def menu_links(datasette, actor):
async def inner():
if not await datasette.allowed(action="debug-menu", actor=actor):
return []
return [
{"href": datasette.urls.path("/-/databases"), "label": "Databases"},
{
"href": datasette.urls.path("/-/plugins"),
"label": "Installed plugins",
},
{
"href": datasette.urls.path("/-/versions"),
"label": "Version info",
},
{
"href": datasette.urls.path("/-/settings"),
"label": "Settings",
},
{
"href": datasette.urls.path("/-/permissions"),
"label": "Debug permissions",
},
{
"href": datasette.urls.path("/-/messages"),
"label": "Debug messages",
},
{
"href": datasette.urls.path("/-/allow-debug"),
"label": "Debug allow rules",
},
{"href": datasette.urls.path("/-/threads"), "label": "Debug threads"},
{"href": datasette.urls.path("/-/actor"), "label": "Debug actor"},
{"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"},
]
return inner

View file

@ -17,6 +17,13 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql,
@ -26,9 +33,26 @@ from .restrictions import (
from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql,
default_query_permissions_sql as default_query_permissions_sql,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
@hookimpl
def skip_csrf(scope) -> Optional[bool]:
"""Skip CSRF check for JSON content-type requests."""
if scope["type"] == "http":
headers = scope.get("headers") or {}
if dict(headers).get(b"content-type") == b"application/json":
return True
return None
@hookimpl
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
"""Return canned queries defined in datasette.yaml configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

@ -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

@ -67,48 +67,3 @@ async def default_action_permissions_sql(
return PermissionSQL.allow(reason=reason)
return None
@hookimpl(specname="permission_resources_sql")
async def default_query_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[PermissionSQL]:
actor_id = actor.get("id") if isinstance(actor, dict) else None
if action not in {"view-query", "update-query", "delete-query"}:
return None
params = {"query_owner_id": actor_id}
rule_sqls = []
if actor_id is not None:
if action in {"update-query", "delete-query"}:
# Query owner can update/delete query
rule_sqls.append("""
SELECT database_name AS parent, name AS child, 1 AS allow,
'query owner' AS reason
FROM queries
WHERE source = 'user'
AND owner_id = :query_owner_id
""")
else:
# Query owner can view-query
rule_sqls.append("""
SELECT database_name AS parent, name AS child, 1 AS allow,
'query owner' AS reason
FROM queries
WHERE owner_id = :query_owner_id
""")
# restriction_sql enforces private queries ONLY visible/mutable by owner
return PermissionSQL(
sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None,
restriction_sql="""
SELECT database_name AS parent, name AS child
FROM queries
WHERE is_private = 0
OR owner_id = :query_owner_id
""",
params=params,
)

View file

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

View file

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

View file

@ -199,27 +199,6 @@ class UpdateRowEvent(Event):
pks: list
@dataclass
class RenameTableEvent(Event):
"""
Event name: ``rename-table``
A table has been renamed.
:ivar database: The name of the database containing the renamed table.
:type database: str
:ivar old_table: The previous name of the table.
:type old_table: str
:ivar new_table: The new name of the table.
:type new_table: str
"""
name = "rename-table"
database: str
old_table: str
new_table: str
@dataclass
class DeleteRowEvent(Event):
"""
@ -240,42 +219,6 @@ class DeleteRowEvent(Event):
pks: list
@hookimpl
def write_wrapper(datasette, database, request, transaction):
def wrapper(conn, track_event):
# Snapshot rootpage -> name before the write
before = {
row[1]: row[0]
for row in conn.execute(
"select name, rootpage from sqlite_master"
" where type='table' and rootpage != 0"
).fetchall()
}
yield
# Snapshot rootpage -> name after the write
after = {
row[1]: row[0]
for row in conn.execute(
"select name, rootpage from sqlite_master"
" where type='table' and rootpage != 0"
).fetchall()
}
# Detect renames: same rootpage, different name
for rootpage, old_name in before.items():
new_name = after.get(rootpage)
if new_name and new_name != old_name:
track_event(
RenameTableEvent(
actor=request.actor if request else None,
database=database,
old_table=old_name,
new_table=new_name,
)
)
return wrapper
@hookimpl
def register_events():
return [
@ -284,7 +227,6 @@ def register_events():
CreateTableEvent,
CreateTokenEvent,
AlterTableEvent,
RenameTableEvent,
DropTableEvent,
InsertRowsEvent,
UpsertRowsEvent,

View file

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

View file

@ -83,9 +83,9 @@ class Facet:
self.ds = ds
self.request = request
self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries:
# For foreign key expansion. Can be None for e.g. canned SQL queries:
self.table = table
self.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,415 +0,0 @@
from datasette.utils.sqlite import sqlite3
from datasette.utils import documented
import itertools
import random
import string
__all__ = [
"EXTRA_DATABASE_SQL",
"TABLES",
"TABLE_PARAMETERIZED_SQL",
"generate_compound_rows",
"generate_sortable_rows",
"populate_extra_database",
"populate_fixture_database",
"write_extra_database",
"write_fixture_database",
]
def generate_compound_rows(num):
"""Generate rows for the compound_three_primary_keys fixture table."""
for a, b, c in itertools.islice(
itertools.product(string.ascii_lowercase, repeat=3), num
):
yield a, b, c, f"{a}-{b}-{c}"
def generate_sortable_rows(num):
"""Generate rows for the sortable fixture table."""
rand = random.Random(42)
for a, b in itertools.islice(
itertools.product(string.ascii_lowercase, repeat=2), num
):
yield {
"pk1": a,
"pk2": b,
"content": f"{a}-{b}",
"sortable": rand.randint(-100, 100),
"sortable_with_nulls": rand.choice([None, rand.random(), rand.random()]),
"sortable_with_nulls_2": rand.choice([None, rand.random(), rand.random()]),
"text": rand.choice(["$null", "$blah"]),
}
TABLES = (
"""
CREATE TABLE simple_primary_key (
id integer primary key,
content text
);
CREATE TABLE primary_key_multiple_columns (
id varchar(30) primary key,
content text,
content2 text
);
CREATE TABLE primary_key_multiple_columns_explicit_label (
id varchar(30) primary key,
content text,
content2 text
);
CREATE TABLE compound_primary_key (
pk1 varchar(30),
pk2 varchar(30),
content text,
PRIMARY KEY (pk1, pk2)
);
INSERT INTO compound_primary_key VALUES ('a', 'b', 'c');
INSERT INTO compound_primary_key VALUES ('a/b', '.c-d', 'c');
INSERT INTO compound_primary_key VALUES ('d', 'e', 'RENDER_CELL_DEMO');
CREATE TABLE compound_three_primary_keys (
pk1 varchar(30),
pk2 varchar(30),
pk3 varchar(30),
content text,
PRIMARY KEY (pk1, pk2, pk3)
);
CREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content);
CREATE TABLE foreign_key_references (
pk varchar(30) primary key,
foreign_key_with_label integer,
foreign_key_with_blank_label integer,
foreign_key_with_no_label varchar(30),
foreign_key_compound_pk1 varchar(30),
foreign_key_compound_pk2 varchar(30),
FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id),
FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id),
FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id)
FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2)
);
CREATE TABLE sortable (
pk1 varchar(30),
pk2 varchar(30),
content text,
sortable integer,
sortable_with_nulls real,
sortable_with_nulls_2 real,
text text,
PRIMARY KEY (pk1, pk2)
);
CREATE TABLE no_primary_key (
content text,
a text,
b text,
c text
);
CREATE TABLE [123_starts_with_digits] (
content text
);
CREATE VIEW paginated_view AS
SELECT
content,
'- ' || content || ' -' AS content_extra
FROM no_primary_key;
CREATE TABLE "Table With Space In Name" (
pk varchar(30) primary key,
content text
);
CREATE TABLE "table/with/slashes.csv" (
pk varchar(30) primary key,
content text
);
CREATE TABLE "complex_foreign_keys" (
pk varchar(30) primary key,
f1 integer,
f2 integer,
f3 integer,
FOREIGN KEY ("f1") REFERENCES [simple_primary_key](id),
FOREIGN KEY ("f2") REFERENCES [simple_primary_key](id),
FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id)
);
CREATE TABLE "custom_foreign_key_label" (
pk varchar(30) primary key,
foreign_key_with_custom_label text,
FOREIGN KEY ("foreign_key_with_custom_label") REFERENCES [primary_key_multiple_columns_explicit_label](id)
);
CREATE TABLE tags (
tag TEXT PRIMARY KEY
);
CREATE TABLE searchable (
pk integer primary key,
text1 text,
text2 text,
[name with . and spaces] text
);
CREATE TABLE searchable_tags (
searchable_id integer,
tag text,
PRIMARY KEY (searchable_id, tag),
FOREIGN KEY (searchable_id) REFERENCES searchable(pk),
FOREIGN KEY (tag) REFERENCES tags(tag)
);
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther');
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma');
INSERT INTO tags VALUES ("canine");
INSERT INTO tags VALUES ("feline");
INSERT INTO searchable_tags (searchable_id, tag) VALUES
(1, "feline"),
(2, "canine")
;
CREATE VIRTUAL TABLE "searchable_fts"
USING FTS5 (text1, text2, [name with . and spaces], content="searchable", content_rowid="pk");
INSERT INTO "searchable_fts" (searchable_fts) VALUES ('rebuild');
CREATE TABLE [select] (
[group] text,
[having] text,
[and] text,
[json] text
);
INSERT INTO [select] VALUES ('group', 'having', 'and',
'{"href": "http://example.com/", "label":"Example"}'
);
CREATE TABLE infinity (
value REAL
);
INSERT INTO infinity VALUES
(1e999),
(-1e999),
(1.5)
;
CREATE TABLE facet_cities (
id integer primary key,
name text
);
INSERT INTO facet_cities (id, name) VALUES
(1, 'San Francisco'),
(2, 'Los Angeles'),
(3, 'Detroit'),
(4, 'Memnonia')
;
CREATE TABLE facetable (
pk integer primary key,
created text,
planet_int integer,
on_earth integer,
state text,
_city_id integer,
_neighborhood text,
tags text,
complex_array text,
distinct_some_null,
n text,
FOREIGN KEY ("_city_id") REFERENCES [facet_cities](id)
);
INSERT INTO facetable
(created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n)
VALUES
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Mission', '["tag1", "tag2"]', '[{"foo": "bar"}]', 'one', 'n1'),
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Dogpatch', '["tag1", "tag3"]', '[]', 'two', 'n2'),
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'SOMA', '[]', '[]', null, null),
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Tenderloin', '[]', '[]', null, null),
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Bernal Heights', '[]', '[]', null, null),
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Hayes Valley', '[]', '[]', null, null),
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Hollywood', '[]', '[]', null, null),
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Downtown', '[]', '[]', null, null),
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Los Feliz', '[]', '[]', null, null),
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Koreatown', '[]', '[]', null, null),
("2019-01-16 08:00:00", 1, 1, 'MI', 3, 'Downtown', '[]', '[]', null, null),
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Greektown', '[]', '[]', null, null),
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Corktown', '[]', '[]', null, null),
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Mexicantown', '[]', '[]', null, null),
("2019-01-17 08:00:00", 2, 0, 'MC', 4, 'Arcadia Planitia', '[]', '[]', null, null)
;
CREATE TABLE binary_data (
data BLOB
);
-- Many 2 Many demo: roadside attractions!
CREATE TABLE roadside_attractions (
pk integer primary key,
name text,
address text,
url text,
latitude real,
longitude real
);
INSERT INTO roadside_attractions VALUES (
1, "The Mystery Spot", "465 Mystery Spot Road, Santa Cruz, CA 95065", "https://www.mysteryspot.com/",
37.0167, -122.0024
);
INSERT INTO roadside_attractions VALUES (
2, "Winchester Mystery House", "525 South Winchester Boulevard, San Jose, CA 95128", "https://winchestermysteryhouse.com/",
37.3184, -121.9511
);
INSERT INTO roadside_attractions VALUES (
3, "Burlingame Museum of PEZ Memorabilia", "214 California Drive, Burlingame, CA 94010", null,
37.5793, -122.3442
);
INSERT INTO roadside_attractions VALUES (
4, "Bigfoot Discovery Museum", "5497 Highway 9, Felton, CA 95018", "https://www.bigfootdiscoveryproject.com/",
37.0414, -122.0725
);
CREATE TABLE attraction_characteristic (
pk integer primary key,
name text
);
INSERT INTO attraction_characteristic VALUES (
1, "Museum"
);
INSERT INTO attraction_characteristic VALUES (
2, "Paranormal"
);
CREATE TABLE roadside_attraction_characteristics (
attraction_id INTEGER REFERENCES roadside_attractions(pk),
characteristic_id INTEGER REFERENCES attraction_characteristic(pk)
);
INSERT INTO roadside_attraction_characteristics VALUES (
1, 2
);
INSERT INTO roadside_attraction_characteristics VALUES (
2, 2
);
INSERT INTO roadside_attraction_characteristics VALUES (
4, 2
);
INSERT INTO roadside_attraction_characteristics VALUES (
3, 1
);
INSERT INTO roadside_attraction_characteristics VALUES (
4, 1
);
INSERT INTO simple_primary_key VALUES (1, 'hello');
INSERT INTO simple_primary_key VALUES (2, 'world');
INSERT INTO simple_primary_key VALUES (3, '');
INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO');
INSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC');
INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world');
INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2');
INSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b');
INSERT INTO foreign_key_references VALUES (2, null, null, null, null, null);
INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1);
INSERT INTO custom_foreign_key_label VALUES (1, 1);
INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');
CREATE VIEW simple_view AS
SELECT content, upper(content) AS upper_content FROM simple_primary_key;
CREATE VIEW searchable_view AS
SELECT * from searchable;
CREATE VIEW searchable_view_configured_by_metadata AS
SELECT * from searchable;
"""
+ "\n".join(
[
'INSERT INTO no_primary_key VALUES ({i}, "a{i}", "b{i}", "c{i}");'.format(
i=i + 1
)
for i in range(201)
]
)
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
+ "\n".join(
[
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
a=a, b=b, c=c, content=content
)
for a, b, c, content in generate_compound_rows(1001)
]
)
+ "\n".join(["""INSERT INTO sortable VALUES (
"{pk1}", "{pk2}", "{content}", {sortable},
{sortable_with_nulls}, {sortable_with_nulls_2}, "{text}");
""".format(**row).replace("None", "null") for row in generate_sortable_rows(201)])
)
TABLE_PARAMETERIZED_SQL = [
("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]),
("insert into binary_data (data) values (?);", [b"\x15\x1c\x03\xc7\xad\x05\xfe"]),
("insert into binary_data (data) values (null);", []),
]
EXTRA_DATABASE_SQL = """
CREATE TABLE searchable (
pk integer primary key,
text1 text,
text2 text
);
CREATE VIEW searchable_view AS SELECT * FROM searchable;
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog');
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel');
CREATE VIRTUAL TABLE "searchable_fts"
USING FTS3 (text1, text2, content="searchable");
INSERT INTO "searchable_fts" (rowid, text1, text2)
SELECT rowid, text1, text2 FROM searchable;
"""
@documented(label="datasette_fixtures_populate_fixture_database")
def populate_fixture_database(conn):
"""Populate a SQLite connection with Datasette's test fixture tables."""
conn.executescript(TABLES)
for sql, params in TABLE_PARAMETERIZED_SQL:
with conn:
conn.execute(sql, params)
def populate_extra_database(conn):
"""Populate a SQLite connection with the extra database used in tests."""
conn.executescript(EXTRA_DATABASE_SQL)
def write_fixture_database(db_filename):
"""Write Datasette's test fixture tables to a SQLite database file."""
conn = sqlite3.connect(db_filename)
try:
populate_fixture_database(conn)
finally:
conn.close()
def write_extra_database(db_filename):
"""Write the extra test database tables to a SQLite database file."""
conn = sqlite3.connect(db_filename)
try:
populate_extra_database(conn)
finally:
conn.close()

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,25 @@ 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,
app_css_hash=datasette.app_css_hash(),
menu_links=lambda: [],
)
),
status=status,
headers=headers,
)
return inner

View file

@ -55,17 +55,7 @@ def publish_subcommand(publish):
@hookspec
def render_cell(
row,
value,
column,
table,
pks,
database,
datasette,
request,
column_type,
):
def render_cell(row, value, column, table, pks, database, datasette, request):
"""Customize rendering of HTML table cell values"""
@ -84,11 +74,6 @@ def register_actions(datasette):
"""Register actions: returns a list of datasette.permission.Action objects"""
@hookspec
def register_column_types(datasette):
"""Return a list of ColumnType subclasses"""
@hookspec
def register_routes(datasette):
"""Register URL routes: return a list of (regex, view_function) pairs"""
@ -137,6 +122,11 @@ def permission_resources_sql(datasette, actor, action):
"""
@hookspec
def canned_queries(datasette, database, actor):
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
@hookspec
def register_magic_parameters(datasette):
"""Return a list of (name, function) magic parameter functions"""
@ -152,39 +142,39 @@ def menu_links(datasette, actor, request):
"""Links for the navigation menu"""
@hookspec
def jump_items_sql(datasette, actor, request):
"""SQL fragments for extra items in the jump menu"""
@hookspec
def row_actions(datasette, actor, request, database, table, row):
"""Items for the row actions menu"""
"""Links for the row actions menu"""
@hookspec
def table_actions(datasette, actor, database, table, request):
"""Items for the table actions menu"""
"""Links for the table actions menu"""
@hookspec
def view_actions(datasette, actor, database, view, request):
"""Items for the view actions menu"""
"""Links for the view actions menu"""
@hookspec
def query_actions(datasette, actor, database, query_name, request, sql, params):
"""Items for the query and stored query actions menu"""
"""Links for the query and canned query actions menu"""
@hookspec
def database_actions(datasette, actor, database, request):
"""Items for the database actions menu"""
"""Links for the database actions menu"""
@hookspec
def homepage_actions(datasette, actor, request):
"""Items for the homepage actions menu"""
"""Links for the homepage actions menu"""
@hookspec
def skip_csrf(datasette, scope):
"""Mechanism for skipping CSRF checks for certain requests"""
@hookspec
@ -228,8 +218,8 @@ def top_query(datasette, request, database, sql):
@hookspec
def top_stored_query(datasette, request, database, query_name):
"""HTML to include at the top of the stored query page"""
def top_canned_query(datasette, request, database, query_name):
"""HTML to include at the top of the canned query page"""
@hookspec
@ -241,18 +231,12 @@ def register_token_handler(datasette):
def write_wrapper(datasette, database, request, transaction):
"""Called when a write function is about to execute.
Return a generator function that accepts a ``conn`` argument and
optionally a ``track_event`` argument. The generator should
``yield`` exactly once: code before the ``yield`` runs before
the write, code after the ``yield`` runs after the write
completes. The result of the write is sent back through the
``yield``, so you can capture it with ``result = yield``.
If your generator accepts ``track_event``, you can call
``track_event(event)`` to queue an event that will be dispatched
via ``datasette.track_event()`` after the write commits
successfully. Events are discarded if the write raises an
exception.
Return a generator function that accepts a ``conn`` argument.
The generator should ``yield`` exactly once: code before the
``yield`` runs before the write, code after the ``yield`` runs
after the write completes. The result of the write is sent
back through the ``yield``, so you can capture it with
``result = yield``.
If the write raises an exception, it is thrown into the generator
so you can handle it with a try/except around the ``yield``.

View file

@ -1,68 +0,0 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
@dataclass
class JumpSQL:
sql: str
params: dict[str, Any] | None = None
database: str | None = None
@classmethod
def menu_item(
cls,
*,
label: str,
url: str,
description: str = "Menu item",
search_text: str | None = None,
display_name: str | None = None,
item_type: str = "menu",
) -> "JumpSQL":
if search_text is None:
search_text = " ".join(
text for text in (label, display_name, description) if text is not None
)
return cls(
sql="""
SELECT
:type AS type,
:label AS label,
:description AS description,
:url AS url,
:search_text AS search_text,
:display_name AS display_name
""",
params={
"type": item_type,
"label": label,
"description": description,
"url": url,
"search_text": search_text,
"display_name": display_name,
},
)
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
"""Rename named SQL parameters so UNION query parameters cannot collide."""
if not params:
return sql, {}
renamed = {key: f"{prefix}_{key}" for key in params}
def replace(match):
key = match.group(1)
if key not in renamed:
return match.group(0)
return f":{renamed[key]}"
return _PARAM_RE.sub(replace, sql), {
renamed[key]: value for key, value in params.items()
}

View file

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

View file

@ -25,14 +25,9 @@ DEFAULT_PLUGINS = (
"datasette.default_permissions",
"datasette.default_permissions.tokens",
"datasette.default_actions",
"datasette.default_column_types",
"datasette.default_magic_parameters",
"datasette.blob_renderer",
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.default_table_actions",
"datasette.default_query_actions",
"datasette.default_menu_links",
"datasette.handle_exception",
"datasette.forbidden",
"datasette.events",

View file

@ -1,7 +1,5 @@
import json
from datasette.extras import extra_names_from_request
from datasette.utils import (
error_body,
value_as_boolean,
remove_infinites,
CustomJSONEncoder,
@ -53,7 +51,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 +86,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,11 +99,16 @@ 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
if isinstance(data, dict) and "columns" not in extra_names_from_request(request):
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
data.pop("columns", None)
# Handle _nl option for _shape=array

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -82,48 +82,6 @@ const datasetteManager = {
return columnActions;
},
/**
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
* for specific Datasette column types.
*
* The first plugin to return a control object wins. Returning null or
* undefined means "I do not handle this field".
*/
makeColumnField: (context) => {
for (const [pluginName, plugin] of datasetteManager.plugins) {
if (!plugin.makeColumnField) {
continue;
}
let control = null;
try {
control = plugin.makeColumnField(context);
} catch (error) {
console.error(
`Error in makeColumnField() for plugin ${pluginName}`,
error,
);
continue;
}
if (control) {
return Object.assign({ pluginName }, control);
}
}
return null;
},
makeJumpSections: (context) => {
let jumpSections = [];
datasetteManager.plugins.forEach((plugin) => {
if (plugin.makeJumpSections) {
const sections = plugin.makeJumpSections(context) || [];
jumpSections.push(...sections);
}
});
return jumpSections;
},
/**
* In MVP, each plugin can only have 1 instance.
* In future, panels could be repeated. We omit that for now since so many plugins depend on
@ -234,6 +192,7 @@ const initializeDatasette = () => {
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
window.__DATASETTE__ = datasetteManager;
console.debug("Datasette Manager Created!");
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
detail: datasetteManager,

File diff suppressed because it is too large Load diff

View file

@ -1,318 +0,0 @@
var MOBILE_COLUMN_BREAKPOINT = 576;
var MOBILE_COLUMN_DIALOG_ID = "mobile-column-actions-dialog";
var MOBILE_COLUMN_DIALOG_TITLE_ID = "mobile-column-actions-title";
function mobileColumnHeaders(manager) {
return Array.from(
document.querySelectorAll(manager.selectors.tableHeaders),
).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1");
}
function mobileColumnMetaText(th) {
var parts = [];
if (th.dataset.columnType) {
parts.push(th.dataset.columnType);
}
if (th.dataset.isPk === "1") {
parts.push("pk");
}
if (th.dataset.columnNotNull === "1") {
parts.push("not null");
}
return parts.join(", ");
}
function createMobileColumnActionNode(itemConfig, closeDialog) {
var actionNode;
if (itemConfig.href) {
actionNode = document.createElement("a");
actionNode.href = itemConfig.href;
} else {
actionNode = document.createElement("button");
actionNode.type = "button";
}
actionNode.textContent = itemConfig.label;
if (itemConfig.onClick) {
actionNode.addEventListener("click", function (ev) {
try {
itemConfig.onClick.call(actionNode, ev);
} finally {
closeDialog({ restoreFocus: false });
}
});
}
return actionNode;
}
function initMobileColumnActions(manager) {
var triggerButton = document.querySelector(".column-actions-mobile");
if (!triggerButton) {
return;
}
if (
!window.URLSearchParams ||
!window.HTMLDialogElement ||
!manager.columnActions
) {
triggerButton.style.display = "none";
return;
}
if (!mobileColumnHeaders(manager).length) {
triggerButton.style.display = "none";
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>
<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);
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) => {
var controlsId = button.getAttribute("aria-controls");
var actionList = dialog.querySelector("#" + controlsId);
var isExpanded = controlsId === expandedSectionId;
button.setAttribute("aria-expanded", isExpanded ? "true" : "false");
actionList.hidden = !isExpanded;
actionList.classList.toggle("expanded", isExpanded);
});
}
function scrollExpandedSectionIntoView(section) {
var sectionTop = section.offsetTop;
var sectionBottom = sectionTop + section.offsetHeight;
var visibleTop = listWrap.scrollTop;
var visibleBottom = visibleTop + listWrap.clientHeight;
var sectionHeight = section.offsetHeight;
if (sectionTop < visibleTop) {
listWrap.scrollTop = sectionTop;
return;
}
if (sectionBottom <= visibleBottom) {
return;
}
if (sectionHeight <= listWrap.clientHeight) {
listWrap.scrollTop = sectionBottom - listWrap.clientHeight;
} else {
listWrap.scrollTop = sectionTop;
}
}
function closeDialog(options) {
options = options || {};
shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
} else {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
}
}
function renderDialog() {
var headers = mobileColumnHeaders(manager);
if (!headers.length) {
closeDialog({ restoreFocus: false });
triggerButton.style.display = "none";
return false;
}
if (
!headers.some(
(_th, index) => `mobile-column-actions-${index}` === expandedSectionId,
)
) {
expandedSectionId = null;
}
countEl.textContent = `${headers.length} column${
headers.length === 1 ? "" : "s"
}`;
listWrap.innerHTML = "";
if (manager.columnActions.shouldShowShowAllColumns()) {
var topActions = document.createElement("div");
topActions.className = "mobile-column-top-actions";
var showAllColumns = document.createElement("a");
showAllColumns.className = "btn btn-ghost mobile-column-top-action";
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
showAllColumns.textContent = "Show all columns";
topActions.appendChild(showAllColumns);
listWrap.appendChild(topActions);
}
headers.forEach((th, index) => {
var sectionId = `mobile-column-actions-${index}`;
var actionState = manager.columnActions.buildColumnActionState(th, {
includeChooseColumns: false,
includeShowAllColumns: false,
});
var section = document.createElement("section");
section.className = "mobile-column-section";
var headerButton = document.createElement("button");
headerButton.type = "button";
headerButton.className = "col-header";
headerButton.setAttribute("aria-controls", sectionId);
headerButton.setAttribute("aria-expanded", "false");
var headerText = document.createElement("span");
headerText.className = "mobile-column-header-text";
var name = document.createElement("span");
name.className = "mobile-column-name";
name.textContent = th.dataset.column;
headerText.appendChild(name);
var metaText = mobileColumnMetaText(th);
if (metaText) {
var meta = document.createElement("span");
meta.className = "mobile-column-meta";
meta.textContent = metaText;
headerText.appendChild(meta);
}
var chevron = document.createElement("span");
chevron.className = "mobile-column-chevron";
chevron.setAttribute("aria-hidden", "true");
chevron.textContent = "▾";
headerButton.appendChild(headerText);
headerButton.appendChild(chevron);
headerButton.addEventListener("click", function () {
expandedSectionId = expandedSectionId === sectionId ? null : sectionId;
updateExpandedSection();
if (expandedSectionId === sectionId) {
scrollExpandedSectionIntoView(section);
}
});
var actionContainer = document.createElement("div");
actionContainer.id = sectionId;
actionContainer.className = "col-actions";
actionContainer.hidden = true;
if (actionState.columnDescription) {
var description = document.createElement("p");
description.className = "mobile-column-description";
description.textContent = actionState.columnDescription;
actionContainer.appendChild(description);
}
if (actionState.actionItems.length) {
var actionList = document.createElement("ul");
actionState.actionItems.forEach((itemConfig) => {
var actionItem = document.createElement("li");
actionItem.appendChild(
createMobileColumnActionNode(itemConfig, closeDialog),
);
actionList.appendChild(actionItem);
});
actionContainer.appendChild(actionList);
} else {
var noActions = document.createElement("p");
noActions.className = "mobile-column-no-actions";
noActions.textContent = "No actions available";
actionContainer.appendChild(noActions);
}
section.appendChild(headerButton);
section.appendChild(actionContainer);
listWrap.appendChild(section);
});
updateExpandedSection();
return true;
}
function openDialog() {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT) {
return;
}
if (!renderDialog()) {
return;
}
if (!dialog.open) {
dialog.showModal();
}
triggerButton.setAttribute("aria-expanded", "true");
var focusTarget =
dialog.querySelector(".mobile-column-top-action") ||
dialog.querySelector(".col-header") ||
doneButton;
focusTarget.focus();
}
triggerButton.addEventListener("click", function () {
if (dialog.open) {
closeDialog();
} else {
openDialog();
}
});
doneButton.addEventListener("click", function () {
closeDialog();
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeDialog();
}
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
closeDialog();
});
dialog.addEventListener("close", function () {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
});
window.addEventListener("resize", function () {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) {
closeDialog({ restoreFocus: false });
}
});
}
document.addEventListener("datasette_init", function (evt) {
initMobileColumnActions(evt.detail);
});

View file

@ -1,22 +1,10 @@
let navigationSearchInstanceCounter = 0;
class NavigationSearch extends HTMLElement {
constructor() {
super();
this.instanceId = ++navigationSearchInstanceCounter;
this.inputId = `navigation-search-input-${this.instanceId}`;
this.instructionsId = `navigation-search-instructions-${this.instanceId}`;
this.listboxId = `navigation-search-results-${this.instanceId}`;
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
this.statusId = `navigation-search-status-${this.instanceId}`;
this.titleId = `navigation-search-title-${this.instanceId}`;
this.attachShadow({ mode: "open" });
this.selectedIndex = -1;
this.matches = [];
this.renderedMatches = [];
this.debounceTimer = null;
this.restoreFocusTarget = null;
this.shouldRestoreFocus = true;
this.render();
this.setupEventListeners();
@ -66,20 +54,16 @@ class NavigationSearch extends HTMLElement {
.search-container {
display: flex;
flex-direction: column;
height: 100%;
}
.search-input-wrapper {
padding: 1.25rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 0.5rem;
align-items: center;
}
.search-input {
width: 100%;
flex: 1;
min-width: 0;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e5e7eb;
@ -93,36 +77,12 @@ class NavigationSearch extends HTMLElement {
border-color: #2563eb;
}
.close-search {
background: transparent;
border: 1px solid transparent;
border-radius: 0.375rem;
color: #4b5563;
cursor: pointer;
flex: 0 0 auto;
font: inherit;
font-size: 1.5rem;
height: 2.75rem;
line-height: 1;
width: 2.75rem;
}
.close-search:hover,
.close-search:focus {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.results-container {
overflow-y: auto;
height: calc(80vh - 180px);
padding: 0.5rem;
}
.results-list:empty {
display: none;
}
.result-item {
padding: 0.875rem 1rem;
cursor: pointer;
@ -141,81 +101,16 @@ class NavigationSearch extends HTMLElement {
background-color: #dbeafe;
}
.result-item > div {
flex: 1;
min-width: 0;
}
.jump-start-content {
border-bottom: 1px solid #e5e7eb;
margin-bottom: 0.5rem;
padding: 0.5rem 0.5rem 1rem;
}
.jump-start-content:empty {
display: none;
}
.result-name {
font-weight: 500;
color: #111827;
}
.result-label {
font-size: 0.875rem;
color: #4b5563;
}
.result-type {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.result-url {
font-size: 0.875rem;
color: #6b7280;
}
.result-description {
color: #374151;
display: -webkit-box;
font-size: 0.8125rem;
line-height: 1.35;
margin-top: 0.35rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.results-heading {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0;
padding: 0.5rem 1rem 0.25rem;
text-transform: uppercase;
}
.recent-actions {
padding: 0.25rem 1rem 0.75rem;
}
.clear-recent {
background: transparent;
border: 0;
color: #2563eb;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
padding: 0;
}
.clear-recent:hover {
text-decoration: underline;
}
.no-results {
padding: 2rem;
text-align: center;
@ -241,18 +136,6 @@ class NavigationSearch extends HTMLElement {
font-family: monospace;
}
.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
/* Mobile optimizations */
@media (max-width: 640px) {
dialog {
@ -280,29 +163,19 @@ class NavigationSearch extends HTMLElement {
}
</style>
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
<dialog>
<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>
<div id="${this.statusId}" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
<div class="search-input-wrapper">
<input
id="${this.inputId}"
type="text"
class="search-input"
placeholder="Jump to..."
aria-label="Jump to"
aria-describedby="${this.instructionsId}"
role="combobox"
aria-autocomplete="list"
aria-controls="${this.listboxId}"
aria-expanded="false"
placeholder="Search..."
aria-label="Search navigation"
autocomplete="off"
spellcheck="false"
>
<button type="button" class="close-search" aria-label="Close jump menu">&times;</button>
</div>
<div class="results-container"></div>
<div class="results-container" role="listbox"></div>
<div class="hint-text">
<span><kbd></kbd> <kbd></kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
@ -316,7 +189,6 @@ class NavigationSearch extends HTMLElement {
setupEventListeners() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
const closeButton = this.shadowRoot.querySelector(".close-search");
const resultsContainer =
this.shadowRoot.querySelector(".results-container");
@ -328,17 +200,6 @@ class NavigationSearch extends HTMLElement {
}
});
document.addEventListener("click", (e) => {
const trigger = e.target.closest("[data-navigation-search-open]");
if (trigger) {
e.preventDefault();
const details = trigger.closest("details");
const restoreTarget = details?.querySelector("summary") || trigger;
details?.removeAttribute("open");
this.openMenu(restoreTarget);
}
});
// Input event
input.addEventListener("input", (e) => {
this.handleSearch(e.target.value);
@ -360,19 +221,8 @@ class NavigationSearch extends HTMLElement {
}
});
closeButton.addEventListener("click", () => {
this.closeMenu();
});
// Click on result item
resultsContainer.addEventListener("click", (e) => {
const clearRecent = e.target.closest("[data-clear-recent-items]");
if (clearRecent) {
e.preventDefault();
this.clearRecentItems();
return;
}
const item = e.target.closest(".result-item");
if (item) {
const index = parseInt(item.dataset.index);
@ -387,15 +237,6 @@ class NavigationSearch extends HTMLElement {
}
});
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this.closeMenu();
});
dialog.addEventListener("close", () => {
this.onMenuClosed();
});
// Initial load
this.loadInitialData();
}
@ -410,106 +251,6 @@ class NavigationSearch extends HTMLElement {
);
}
setElementAttribute(element, name, value) {
if (!element) {
return;
}
if (typeof element.setAttribute === "function") {
element.setAttribute(name, value);
} else {
element[name] = String(value);
}
}
removeElementAttribute(element, name) {
if (!element) {
return;
}
if (typeof element.removeAttribute === "function") {
element.removeAttribute(name);
} else {
delete element[name];
}
}
focusRestoreTarget(trigger) {
if (trigger && typeof trigger.focus === "function") {
return trigger;
}
if (
document.activeElement &&
typeof document.activeElement.focus === "function"
) {
return document.activeElement;
}
return null;
}
setNavigationTriggersExpanded(expanded) {
if (typeof document.querySelectorAll !== "function") {
return;
}
document
.querySelectorAll("[data-navigation-search-open]")
.forEach((trigger) => {
this.setElementAttribute(
trigger,
"aria-expanded",
expanded ? "true" : "false",
);
});
}
resultOptionId(index) {
return `${this.listboxId}-option-${index}`;
}
updateComboboxState() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
const matches = this.renderedMatches || [];
this.setElementAttribute(
input,
"aria-expanded",
dialog && dialog.open && matches.length > 0 ? "true" : "false",
);
if (
dialog &&
dialog.open &&
this.selectedIndex >= 0 &&
this.selectedIndex < matches.length
) {
this.setElementAttribute(
input,
"aria-activedescendant",
this.resultOptionId(this.selectedIndex),
);
} else {
this.removeElementAttribute(input, "aria-activedescendant");
}
}
setStatus(message) {
const status = this.shadowRoot.querySelector(`#${this.statusId}`);
if (status) {
status.textContent = message || "";
}
}
resultsStatus(count, truncated) {
if (truncated) {
return "More than 100 results. Keep typing to narrow the list.";
}
if (count === 0) {
return "No results found.";
}
if (count === 1) {
return "1 result.";
}
return `${count} results.`;
}
loadInitialData() {
const itemsAttr = this.getAttribute("items");
if (itemsAttr) {
@ -526,11 +267,6 @@ class NavigationSearch extends HTMLElement {
handleSearch(query) {
clearTimeout(this.debounceTimer);
if (query.trim()) {
this.setStatus("Searching...");
} else {
this.setStatus("");
}
this.debounceTimer = setTimeout(() => {
const url = this.getAttribute("url");
@ -553,262 +289,65 @@ class NavigationSearch extends HTMLElement {
this.matches = data.matches || [];
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults();
if (query.trim()) {
this.setStatus(this.resultsStatus(this.matches.length, data.truncated));
} else {
this.setStatus("");
}
} catch (e) {
console.error("Failed to fetch search results:", e);
this.matches = [];
this.renderResults();
this.setStatus("Search failed.");
}
}
filterLocalItems(query) {
if (!query.trim()) {
this.matches = this.allItems || [];
this.matches = [];
} else {
const lowerQuery = query.toLowerCase();
this.matches = (this.allItems || []).filter(
(item) =>
item.name.toLowerCase().includes(lowerQuery) ||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
item.url.toLowerCase().includes(lowerQuery),
);
}
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults();
if (query.trim()) {
this.setStatus(this.resultsStatus(this.matches.length, false));
} else {
this.setStatus("");
}
}
recentItemsStorageKey() {
return "datasette.navigationSearch.recentItems";
}
loadRecentItems() {
if (typeof localStorage === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(this.recentItemsStorageKey());
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter((item) => item && item.name && item.url)
.map((item) => ({
name: String(item.name),
display_name: item.display_name ? String(item.display_name) : "",
url: String(item.url),
type: item.type ? String(item.type) : "",
description: item.description ? String(item.description) : "",
}))
.slice(0, 5);
} catch (e) {
return [];
}
}
saveRecentItem(match) {
if (
typeof localStorage === "undefined" ||
!match ||
!match.name ||
!match.url
) {
return;
}
try {
const item = {
name: String(match.name),
display_name: match.display_name ? String(match.display_name) : "",
url: String(match.url),
type: match.type ? String(match.type) : "",
description: match.description ? String(match.description) : "",
};
const recentItems = this.loadRecentItems().filter(
(recentItem) => recentItem.url !== item.url,
);
localStorage.setItem(
this.recentItemsStorageKey(),
JSON.stringify([item, ...recentItems].slice(0, 5)),
);
} catch (e) {
// localStorage may be unavailable, full, or disabled.
}
}
clearRecentItems() {
if (typeof localStorage === "undefined") {
return;
}
try {
localStorage.removeItem(this.recentItemsStorageKey());
} catch (e) {
localStorage.setItem(this.recentItemsStorageKey(), "[]");
}
this.renderResults();
this.setStatus("Recent items cleared.");
}
jumpSections() {
const manager = window.__DATASETTE__;
if (!manager || typeof manager.makeJumpSections !== "function") {
return [];
}
const sections = manager.makeJumpSections({
navigationSearch: this,
});
return Array.isArray(sections)
? sections.filter(
(section) => section && typeof section.render === "function",
)
: [];
}
jumpSectionsHtml(jumpSections) {
return jumpSections
.map((section, index) => {
const id = section.id
? ` data-jump-section-id="${this.escapeHtml(section.id)}"`
: "";
return `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
})
.join("");
}
renderJumpSections(container, jumpSections) {
jumpSections.forEach((section, index) => {
const node = container.querySelector(
`[data-jump-section-index="${index}"]`,
);
if (!node) {
return;
}
section.render(node, {
navigationSearch: this,
container,
input: this.shadowRoot.querySelector(".search-input"),
});
});
}
resultItemHtml(match, index) {
const displayName = match.display_name || match.name;
const label =
match.display_name && match.display_name !== match.name
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
: "";
const type = match.type
? `<div class="result-type">${this.escapeHtml(match.type)}</div>`
: "";
const description = match.description
? `<div class="result-description">${this.escapeHtml(
match.description,
)}</div>`
: "";
return `
<div
id="${this.resultOptionId(index)}"
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
data-index="${index}"
role="option"
aria-selected="${index === this.selectedIndex}"
>
<div>
${type}
<div class="result-name">${this.escapeHtml(displayName)}</div>
${label}
<div class="result-url">${this.escapeHtml(match.url)}</div>
${description}
</div>
</div>
`;
}
renderResults() {
const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input");
const showStartContent = !input.value.trim();
const jumpSections = showStartContent ? this.jumpSections() : [];
const startBlock = showStartContent
? this.jumpSectionsHtml(jumpSections)
: "";
const recentItems = showStartContent ? this.loadRecentItems() : [];
const defaultMatches = showStartContent ? [] : this.matches;
const renderedMatches = [...recentItems, ...defaultMatches];
this.renderedMatches = renderedMatches;
const emptyListbox = `<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results"></div>`;
if (renderedMatches.length) {
if (
this.selectedIndex < 0 ||
this.selectedIndex >= renderedMatches.length
) {
this.selectedIndex = 0;
}
} else {
this.selectedIndex = -1;
}
if (renderedMatches.length === 0) {
if (startBlock) {
container.innerHTML = startBlock + emptyListbox;
this.renderJumpSections(container, jumpSections);
} else if (showStartContent) {
container.innerHTML = emptyListbox;
} else {
const message = input.value.trim()
? "No results found"
: "Start typing to search...";
container.innerHTML = `${emptyListbox}<div class="no-results">${message}</div>`;
}
this.updateComboboxState();
if (this.matches.length === 0) {
const message = input.value.trim()
? "No results found"
: "Start typing to search...";
container.innerHTML = `<div class="no-results">${message}</div>`;
return;
}
const recentHeading = recentItems.length
? `<div class="results-heading" id="${this.recentHeadingId}">Recent</div>`
: "";
const recentGroup = recentItems.length
? `<div role="group" aria-labelledby="${this.recentHeadingId}">${recentItems
.map((match, index) => this.resultItemHtml(match, index))
.join("")}</div>`
: "";
const recentActions = recentItems.length
? `<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
: "";
const defaultHtml = defaultMatches
.map((match, index) =>
this.resultItemHtml(match, recentItems.length + index),
container.innerHTML = this.matches
.map(
(match, index) => `
<div
class="result-item ${
index === this.selectedIndex ? "selected" : ""
}"
data-index="${index}"
role="option"
aria-selected="${index === this.selectedIndex}"
>
<div>
<div class="result-name">${this.escapeHtml(
match.name,
)}</div>
<div class="result-url">${this.escapeHtml(match.url)}</div>
</div>
</div>
`,
)
.join("");
container.innerHTML =
startBlock +
recentHeading +
`<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results">${recentGroup}${defaultHtml}</div>` +
recentActions;
this.renderJumpSections(container, jumpSections);
this.updateComboboxState();
// Scroll selected item into view
if (this.selectedIndex >= 0) {
const selectedItem = container.querySelector(
`.result-item[data-index="${this.selectedIndex}"]`,
);
const selectedItem = container.children[this.selectedIndex];
if (selectedItem) {
selectedItem.scrollIntoView({ block: "nearest" });
}
@ -816,27 +355,22 @@ class NavigationSearch extends HTMLElement {
}
moveSelection(direction) {
const matches = this.renderedMatches || this.matches;
const newIndex = this.selectedIndex + direction;
if (newIndex >= 0 && newIndex < matches.length) {
if (newIndex >= 0 && newIndex < this.matches.length) {
this.selectedIndex = newIndex;
this.renderResults();
}
}
selectCurrentItem() {
const matches = this.renderedMatches || this.matches;
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
this.selectItem(this.selectedIndex);
}
}
selectItem(index) {
const matches = this.renderedMatches || this.matches;
const match = matches[index];
const match = this.matches[index];
if (match) {
this.saveRecentItem(match);
// Dispatch custom event
this.dispatchEvent(
new CustomEvent("select", {
@ -849,59 +383,32 @@ class NavigationSearch extends HTMLElement {
// Navigate to URL
window.location.href = match.url;
this.closeMenu({ restoreFocus: false });
this.closeMenu();
}
}
openMenu(trigger) {
openMenu() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
this.shouldRestoreFocus = true;
if (!dialog.open) {
dialog.showModal();
}
this.setNavigationTriggersExpanded(true);
dialog.showModal();
input.value = "";
input.focus();
// Reset state, then populate the default jump list.
// Reset state - start with no items shown
this.matches = [];
this.selectedIndex = -1;
this.renderResults();
this.setStatus("");
}
closeMenu(options = {}) {
closeMenu() {
const dialog = this.shadowRoot.querySelector("dialog");
this.shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
} else {
this.onMenuClosed();
}
}
onMenuClosed() {
const input = this.shadowRoot.querySelector(".search-input");
this.setElementAttribute(input, "aria-expanded", "false");
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;
dialog.close();
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : text;
div.textContent = text;
return div.innerHTML;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,579 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Iterable
from .utils import tilde_encode, urlsafe_components
UNCHANGED = object()
QUERY_OPTION_FIELDS = (
"hide_sql",
"fragment",
"on_success_message",
"on_success_message_sql",
"on_success_redirect",
"on_error_message",
"on_error_redirect",
)
@dataclass
class StoredQuery:
database: str
name: str
sql: str
title: str | None
description: str | None
description_html: str | None
hide_sql: bool
fragment: str | None
parameters: list[str]
is_write: bool
is_private: bool
is_trusted: bool
source: str
owner_id: str | None
on_success_message: str | None
on_success_message_sql: str | None
on_success_redirect: str | None
on_error_message: str | None
on_error_redirect: str | None
private: bool | None = None
@dataclass
class StoredQueryPage:
queries: list[StoredQuery]
next: str | None
has_more: bool
limit: int
def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
data = {
"database": query.database,
"name": query.name,
"sql": query.sql,
"title": query.title,
"description": query.description,
"description_html": query.description_html,
"hide_sql": query.hide_sql,
"fragment": query.fragment,
"parameters": list(query.parameters),
"is_write": query.is_write,
"is_private": query.is_private,
"is_trusted": query.is_trusted,
"source": query.source,
"owner_id": query.owner_id,
"on_success_message": query.on_success_message,
"on_success_message_sql": query.on_success_message_sql,
"on_success_redirect": query.on_success_redirect,
"on_error_message": query.on_error_message,
"on_error_redirect": query.on_error_redirect,
}
if query.private is not None:
data["private"] = query.private
return data
def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
return {
"queries": [stored_query_to_dict(query) for query in page.queries],
"next": page.next,
"limit": page.limit,
}
async def save_queries_from_config(datasette: Any) -> None:
# Apply configured query entries from datasette.yaml to the internal table.
await datasette.get_internal_database().execute_write(
"DELETE FROM queries WHERE source = 'config'"
)
for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items():
for query_name, query_config in (db_config.get("queries") or {}).items():
if not isinstance(query_config, dict):
query_config = {"sql": query_config}
await datasette.add_query(
dbname,
query_name,
query_config["sql"],
title=query_config.get("title"),
description=query_config.get("description"),
description_html=query_config.get("description_html"),
hide_sql=bool(query_config.get("hide_sql")),
fragment=query_config.get("fragment"),
parameters=query_config.get("params"),
is_write=bool(query_config.get("write")),
is_trusted=bool(query_config.get("is_trusted", True)),
source="config",
on_success_message=query_config.get("on_success_message"),
on_success_message_sql=query_config.get("on_success_message_sql"),
on_success_redirect=query_config.get("on_success_redirect"),
on_error_message=query_config.get("on_error_message"),
on_error_redirect=query_config.get("on_error_redirect"),
)
def query_row_to_stored_query(
row: Any, private: bool | None = None
) -> StoredQuery | None:
if row is None:
return None
parameters = json.loads(row["parameters"] or "[]")
options = json.loads(row["options"] or "{}")
return StoredQuery(
database=row["database_name"],
name=row["name"],
sql=row["sql"],
title=row["title"],
description=row["description"],
description_html=row["description_html"],
hide_sql=bool(options.get("hide_sql")),
fragment=options.get("fragment"),
parameters=parameters,
is_write=bool(row["is_write"]),
is_private=bool(row["is_private"]),
is_trusted=bool(row["is_trusted"]),
source=row["source"],
owner_id=row["owner_id"],
on_success_message=options.get("on_success_message"),
on_success_message_sql=options.get("on_success_message_sql"),
on_success_redirect=options.get("on_success_redirect"),
on_error_message=options.get("on_error_message"),
on_error_redirect=options.get("on_error_redirect"),
private=private,
)
def query_options_json(options: dict[str, Any]) -> str:
options_dict = {}
for field in QUERY_OPTION_FIELDS:
value = options.get(field)
if field == "hide_sql":
if value:
options_dict[field] = True
elif value is not None:
options_dict[field] = value
return json.dumps(options_dict, sort_keys=True)
async def add_query(
datasette: Any,
database: str,
name: str,
sql: str,
*,
title: str | None = None,
description: str | None = None,
description_html: str | None = None,
hide_sql: bool = False,
fragment: str | None = None,
parameters: Iterable[str] | None = None,
is_write: bool = False,
is_private: bool = False,
is_trusted: bool = False,
source: str = "plugin",
owner_id: str | None = None,
on_success_message: str | None = None,
on_success_message_sql: str | None = None,
on_success_redirect: str | None = None,
on_error_message: str | None = None,
on_error_redirect: str | None = None,
replace: bool = True,
) -> None:
parameters_json = json.dumps(list(parameters or []))
options_json = query_options_json(
{
"hide_sql": hide_sql,
"fragment": fragment,
"on_success_message": on_success_message,
"on_success_message_sql": on_success_message_sql,
"on_success_redirect": on_success_redirect,
"on_error_message": on_error_message,
"on_error_redirect": on_error_redirect,
}
)
sql_statement = """
INSERT INTO queries (
database_name, name, sql, title, description, description_html,
options, parameters, is_write, is_private, is_trusted, source, owner_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
if replace:
sql_statement += """
ON CONFLICT(database_name, name) DO UPDATE SET
sql = excluded.sql,
title = excluded.title,
description = excluded.description,
description_html = excluded.description_html,
options = excluded.options,
parameters = excluded.parameters,
is_write = excluded.is_write,
is_private = excluded.is_private,
is_trusted = excluded.is_trusted,
source = excluded.source,
owner_id = excluded.owner_id,
updated_at = CURRENT_TIMESTAMP
"""
await datasette.get_internal_database().execute_write(
sql_statement,
[
database,
name,
sql,
title,
description,
description_html,
options_json,
parameters_json,
int(bool(is_write)),
int(bool(is_private)),
int(bool(is_trusted)),
source,
owner_id,
],
)
async def update_query(
datasette: Any,
database: str,
name: str,
*,
sql=UNCHANGED,
title=UNCHANGED,
description=UNCHANGED,
description_html=UNCHANGED,
hide_sql=UNCHANGED,
fragment=UNCHANGED,
parameters=UNCHANGED,
is_write=UNCHANGED,
is_private=UNCHANGED,
is_trusted=UNCHANGED,
source=UNCHANGED,
owner_id=UNCHANGED,
on_success_message=UNCHANGED,
on_success_message_sql=UNCHANGED,
on_success_redirect=UNCHANGED,
on_error_message=UNCHANGED,
on_error_redirect=UNCHANGED,
) -> None:
fields = {
"sql": sql,
"title": title,
"description": description,
"description_html": description_html,
"parameters": parameters,
"is_write": is_write,
"is_private": is_private,
"is_trusted": is_trusted,
"source": source,
"owner_id": owner_id,
}
option_fields = {
"hide_sql": hide_sql,
"fragment": fragment,
"on_success_message": on_success_message,
"on_success_message_sql": on_success_message_sql,
"on_success_redirect": on_success_redirect,
"on_error_message": on_error_message,
"on_error_redirect": on_error_redirect,
}
updates = []
params = []
for field, value in fields.items():
if value is UNCHANGED:
continue
if field in {"is_write", "is_private", "is_trusted"}:
value = int(bool(value))
elif field == "parameters":
value = json.dumps(list(value or []))
updates.append(f"{field} = ?")
params.append(value)
changed_options = {
field: value for field, value in option_fields.items() if value is not UNCHANGED
}
if changed_options:
rows = await datasette.get_internal_database().execute(
"""
SELECT options FROM queries
WHERE database_name = ? AND name = ?
""",
[database, name],
)
row = rows.first()
options = json.loads(row["options"] or "{}") if row is not None else {}
for field, value in changed_options.items():
if field == "hide_sql":
if value:
options[field] = True
else:
options.pop(field, None)
elif value is None:
options.pop(field, None)
else:
options[field] = value
updates.append("options = ?")
params.append(json.dumps(options, sort_keys=True))
if not updates:
return
updates.append("updated_at = CURRENT_TIMESTAMP")
params.extend([database, name])
await datasette.get_internal_database().execute_write(
"""
UPDATE queries
SET {}
WHERE database_name = ? AND name = ?
""".format(", ".join(updates)),
params,
)
async def remove_query(
datasette: Any, database: str, name: str, source: str | None = None
) -> None:
sql = "DELETE FROM queries WHERE database_name = ? AND name = ?"
params = [database, name]
if source is not None:
sql += " AND source = ?"
params.append(source)
await datasette.get_internal_database().execute_write(sql, params)
async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None:
rows = await datasette.get_internal_database().execute(
"""
SELECT * FROM queries
WHERE database_name = ? AND name = ?
""",
[database, name],
)
return query_row_to_stored_query(rows.first())
async def count_queries(
datasette: Any,
database: str | None = None,
*,
actor: dict[str, Any] | None = None,
q: str | None = None,
is_write: bool | None = None,
is_private: bool | None = None,
is_trusted: bool | None = None,
source: str | None = None,
owner_id: str | None = None,
) -> int:
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
action="view-query",
actor=actor,
parent=database,
)
params = dict(allowed_params)
where_clauses = []
if database is not None:
params["query_database"] = database
where_clauses.append("q.database_name = :query_database")
if q:
where_clauses.append("""
(
q.name LIKE :query_search
OR q.title LIKE :query_search
OR q.description LIKE :query_search
OR q.sql LIKE :query_search
)
""")
params["query_search"] = "%{}%".format(q)
if is_write is not None:
where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write))
if is_private is not None:
where_clauses.append("q.is_private = :query_is_private")
params["query_is_private"] = int(bool(is_private))
if is_trusted is not None:
where_clauses.append("q.is_trusted = :query_is_trusted")
params["query_is_trusted"] = int(bool(is_trusted))
if source is not None:
where_clauses.append("q.source = :query_source")
params["query_source"] = source
if owner_id is not None:
where_clauses.append("q.owner_id = :query_owner_id")
params["query_owner_id"] = owner_id
row = (
await datasette.get_internal_database().execute(
"""
SELECT count(*) AS count
FROM queries q
JOIN (
{allowed_sql}
) allowed
ON allowed.parent = q.database_name
AND allowed.child = q.name
WHERE {where}
""".format(
allowed_sql=allowed_sql,
where=" AND ".join(where_clauses) or "1 = 1",
),
params,
)
).first()
return row["count"]
async def list_queries(
datasette: Any,
database: str | None = None,
*,
actor: dict[str, Any] | None = None,
limit: int = 50,
cursor: str | None = None,
q: str | None = None,
is_write: bool | None = None,
is_private: bool | None = None,
is_trusted: bool | None = None,
source: str | None = None,
owner_id: str | None = None,
include_private: bool = False,
) -> StoredQueryPage:
limit = min(max(1, int(limit)), 1000)
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
action="view-query",
actor=actor,
parent=database,
include_is_private=include_private,
)
params = dict(allowed_params)
params.update({"limit": limit + 1})
sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))"
where_clauses = []
order_by = "q.database_name, sort_key, q.name"
if database is not None:
params["query_database"] = database
where_clauses.append("q.database_name = :query_database")
order_by = "sort_key, q.name"
if cursor:
try:
components = urlsafe_components(cursor)
except ValueError:
components = []
if database is None and len(components) == 3:
where_clauses.append("""
(
q.database_name > :cursor_database
OR (
q.database_name = :cursor_database
AND (
{sort_key_sql} > :cursor_sort_key
OR (
{sort_key_sql} = :cursor_sort_key
AND q.name > :cursor_name
)
)
)
)
""".format(sort_key_sql=sort_key_sql))
params["cursor_database"] = components[0]
params["cursor_sort_key"] = components[1]
params["cursor_name"] = components[2]
elif database is not None and len(components) == 2:
where_clauses.append("""
(
{sort_key_sql} > :cursor_sort_key
OR (
{sort_key_sql} = :cursor_sort_key
AND q.name > :cursor_name
)
)
""".format(sort_key_sql=sort_key_sql))
params["cursor_sort_key"] = components[0]
params["cursor_name"] = components[1]
if q:
where_clauses.append("""
(
q.name LIKE :query_search
OR q.title LIKE :query_search
OR q.description LIKE :query_search
OR q.sql LIKE :query_search
)
""")
params["query_search"] = "%{}%".format(q)
if is_write is not None:
where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write))
if is_private is not None:
where_clauses.append("q.is_private = :query_is_private")
params["query_is_private"] = int(bool(is_private))
if is_trusted is not None:
where_clauses.append("q.is_trusted = :query_is_trusted")
params["query_is_trusted"] = int(bool(is_trusted))
if source is not None:
where_clauses.append("q.source = :query_source")
params["query_source"] = source
if owner_id is not None:
where_clauses.append("q.owner_id = :query_owner_id")
params["query_owner_id"] = owner_id
private_select = ", allowed.is_private AS private" if include_private else ""
rows = list(
(
await datasette.get_internal_database().execute(
"""
SELECT q.*, {sort_key_sql} AS sort_key{private_select}
FROM queries q
JOIN (
{allowed_sql}
) allowed
ON allowed.parent = q.database_name
AND allowed.child = q.name
WHERE {where}
ORDER BY {order_by}
LIMIT :limit
""".format(
allowed_sql=allowed_sql,
private_select=private_select,
sort_key_sql=sort_key_sql,
where=" AND ".join(where_clauses) or "1 = 1",
order_by=order_by,
),
params,
)
).rows
)
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
queries = []
for row in rows:
query = query_row_to_stored_query(
row, private=bool(row["private"]) if include_private else None
)
assert query is not None
queries.append(query)
next_token = None
if has_more and rows:
last_row = rows[-1]
if database is None:
next_token = "{},{},{}".format(
tilde_encode(last_row["database_name"]),
tilde_encode(last_row["sort_key"]),
tilde_encode(last_row["name"]),
)
else:
next_token = "{},{}".format(
tilde_encode(last_row["sort_key"]),
tilde_encode(last_row["name"]),
)
return StoredQueryPage(
queries=queries,
next=next_token,
has_more=has_more,
limit=limit,
)

View file

@ -1,40 +0,0 @@
"""
Index of the documented template contexts for Datasette's core HTML pages.
This module deliberately contains no documentation strings of its own -
the documentation lives next to the code it describes:
- Every page renders a Context dataclass defined in its view module
(DatabaseContext, QueryContext in views/database.py, TableContext in
views/table.py, RowContext in views/row.py). Fields added by view code
carry ``help`` metadata; fields declared with from_extra() take their
documentation from the description on the matching Extra class in
views/table_extras.py.
- The keys render_template() adds to every page are documented in
TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds
them.
The contract tests in tests/test_template_context.py assert that the real
rendered context for each page exactly matches what is documented, and
docs/template_context_doc.py generates docs/template_context.rst from the
same classes.
"""
from datasette.app import TEMPLATE_BASE_CONTEXT
from datasette.views.database import DatabaseContext, QueryContext
from datasette.views.row import RowContext
from datasette.views.table import TableContext
PAGES = {
"database": DatabaseContext,
"query": QueryContext,
"table": TableContext,
"row": RowContext,
}
def documented_context_keys(page_name):
"Set of every documented key for the named page, including base context keys"
return set(TEMPLATE_BASE_CONTEXT) | {
f.name for f in PAGES[page_name].documented_fields()
}

View file

@ -1,7 +1,7 @@
{% if action_links %}
<div class="page-action-menu">
<details class="actions-menu-links details-menu">
<summary aria-haspopup="menu" aria-expanded="false">
<summary>
<div class="icon-text">
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">{{ action_title }}</title>
@ -13,24 +13,16 @@
</summary>
<div class="dropdown-menu">
<div class="hook"></div>
<ul role="menu">
<ul>
{% for link in action_links %}
<li role="none">
{% if link.get("type") == "button" %}
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
{% if link.description %}
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</button>
{% else %}
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
{% if link.description %}
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</a>
{% endif %}
<li><a href="{{ link.href }}">{{ link.label }}
{% if link.description %}
<p class="dropdown-description">{{ link.description }}</p>
{% endif %}</a>
</li>
{% endfor %}
</ul>
</div>
</details>
</div>
{% endif %}
{% endif %}

View file

@ -13,50 +13,4 @@ document.body.addEventListener('click', (ev) => {
(details) => details.open && details != detailsClickedWithin
).forEach(details => details.open = false);
});
/* Sync aria-expanded and add keyboard navigation for details-menu elements */
document.querySelectorAll('details.details-menu').forEach(function(details) {
var summary = details.querySelector('summary');
details.addEventListener('toggle', function() {
if (summary) {
summary.setAttribute('aria-expanded', details.open ? 'true' : 'false');
}
if (details.open) {
/* Focus first menu item when menu opens */
var firstItem = details.querySelector('[role="menuitem"]');
if (firstItem) { firstItem.focus(); }
}
});
});
document.body.addEventListener('keydown', function(ev) {
/* Keyboard navigation for open details-menu elements */
var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]'));
if (!openDetails.length) { return; }
if (ev.key === 'Escape') {
openDetails.forEach(function(details) {
details.open = false;
var summary = details.querySelector('summary');
if (summary) { summary.focus(); }
});
return;
}
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
var focused = document.activeElement;
openDetails.forEach(function(details) {
var items = Array.from(details.querySelectorAll('[role="menuitem"]'));
if (!items.length) { return; }
var idx = items.indexOf(focused);
if (idx === -1) { return; }
ev.preventDefault();
if (ev.key === 'ArrowDown') {
items[(idx + 1) % items.length].focus();
} else {
items[(idx - 1 + items.length) % items.length].focus();
}
});
}
});
</script>

View file

@ -1,5 +1,5 @@
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
<style>
.cm-editor {
resize: both;

View file

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

View file

@ -1,41 +0,0 @@
<style>
.execute-write-analysis {
border-collapse: collapse;
font-size: 0.9rem;
margin: 0.25rem 0 1rem;
min-width: 44rem;
}
.execute-write-analysis th,
.execute-write-analysis td {
border-bottom: 1px solid #d7dde5;
padding: 0.45rem 0.7rem;
text-align: left;
vertical-align: top;
}
.execute-write-analysis th {
background-color: #edf6fb;
border-top: 1px solid #d7dde5;
color: #39445a;
font-weight: 700;
}
.execute-write-analysis tbody tr:nth-child(even) {
background-color: rgba(39, 104, 144, 0.05);
}
.execute-write-analysis code {
background: transparent;
font-size: 0.9em;
white-space: nowrap;
}
.execute-write-analysis-allowed {
color: #267a3e;
font-weight: 700;
}
.execute-write-analysis-denied {
color: #b00020;
font-weight: 700;
}
.execute-write-analysis-na {
color: #687386;
font-style: italic;
}
</style>

View file

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

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

@ -1,138 +0,0 @@
<style>
.query-create-page {
max-width: 64rem;
}
.query-create-form {
--query-create-label-width: clamp(7rem, 18vw, 10rem);
--query-create-column-gap: 0.8rem;
--query-create-control-width: minmax(16rem, 1fr);
}
.query-create-fields {
margin: 0 0 0.85rem;
max-width: 52rem;
}
.query-create-field {
align-items: start;
column-gap: var(--query-create-column-gap);
display: grid;
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
margin: 0 0 0.65rem;
}
.query-create-field label {
padding-top: 0.55rem;
width: auto;
}
.query-create-field input[type=text],
.query-create-field textarea {
box-sizing: border-box;
width: 100%;
}
form.sql .query-create-field textarea {
width: 100%;
}
.query-create-url-control {
align-items: center;
box-sizing: border-box;
display: grid;
gap: 0.35rem;
grid-template-columns: max-content minmax(12rem, 1fr);
width: 100%;
}
.query-create-url-prefix {
color: #4f5b6d;
font-family: var(--font-monospace, monospace);
white-space: nowrap;
}
.query-create-url-control input[type=text] {
border: 1px solid #ccc;
border-radius: 3px;
}
.query-create-url-static {
color: #39445a;
font-family: var(--font-monospace, monospace);
word-break: break-all;
}
.query-create-field textarea {
border: 1px solid #ccc;
border-radius: 3px;
display: block;
font-family: Helvetica, sans-serif;
font-size: 1em;
min-height: 5rem;
padding: 9px 4px;
resize: vertical;
}
form.sql .query-create-sql {
column-gap: var(--query-create-column-gap);
display: grid;
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
margin: 0.9rem 0 0.75rem;
max-width: 52rem;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
grid-column: 2;
width: 100%;
}
.query-create-options {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.8rem 1.4rem;
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
}
.query-create-options label {
align-items: center;
display: inline-flex;
gap: 0.35rem;
width: auto;
}
.query-create-options input[type=checkbox] {
margin: 0;
}
.query-create-option-note,
.query-create-analysis-note {
color: #4f5b6d;
flex-basis: 100%;
font-size: 0.82rem;
}
.query-create-option-note {
margin: -0.45rem 0 0;
}
.query-create-analysis-note {
margin: 0;
}
.query-create-analysis {
margin-top: 0.8rem;
}
.query-create-submit {
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
margin-bottom: 0.9rem;
margin-top: 1rem;
}
@media (max-width: 560px) {
.query-create-form {
--query-create-label-width: 1fr;
--query-create-column-gap: 0;
}
.query-create-field {
grid-template-columns: 1fr;
row-gap: 0.25rem;
}
.query-create-field label {
padding-top: 0;
}
form.sql .query-create-sql {
grid-template-columns: 1fr;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
grid-column: 1;
}
.query-create-options,
.query-create-submit {
margin-left: 0;
}
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
<div class="above-table-panel"> </div>
{% if display_columns %}
{% if display_rows %}
<div class="table-wrapper">
<table class="rows-and-columns">
<thead>
<tr>
{% for column in display_columns %}
<th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}"{% if column.is_special_link_column %} data-is-link-column="1"{% endif %}>
<th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}">
{% if not column.sortable %}
{{ column.name }}
{% else %}
@ -22,7 +22,7 @@
</thead>
<tbody>
{% for row in display_rows %}
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}>
<tr class="table-row-clickable" data-row-index="{{ loop.index0 }}">
{% for cell in row %}
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
{% endfor %}
@ -31,7 +31,194 @@
</tbody>
</table>
</div>
{% endif %}
{% if not display_rows %}
{% else %}
<p class="zero-results">0 records</p>
{% endif %}
<!-- Row detail side panel dialog -->
<dialog id="rowDetailPanel" class="row-detail-panel">
<div class="dialog-content">
<div class="dialog-header">
<h2>Row details</h2>
<button class="close-button" id="closeRowDetail" aria-label="Close">×</button>
</div>
<div class="dialog-navigation">
<button class="nav-button" id="prevRowButton" aria-label="Previous row">← Previous</button>
<span class="row-position" id="rowPosition"></span>
<button class="nav-button" id="nextRowButton" aria-label="Next row">Next →</button>
</div>
<div id="rowDetailContent" class="row-detail-content">
<p class="loading">Loading...</p>
</div>
</div>
</dialog>
<style>
/* Row detail side panel styles */
.table-row-clickable {
cursor: pointer;
}
.table-row-clickable:hover {
background-color: #f5f5f5;
}
.row-detail-panel {
position: fixed;
margin: 0;
padding: 0;
border: none;
width: 90%;
max-width: 600px;
height: 100vh;
top: 0;
right: 0;
left: auto;
background-color: white;
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
transform: translateX(100%);
transition: transform 0.1s cubic-bezier(0.2, 0, 0.38, 0.9);
max-height: none;
}
@media only screen and (min-width: 768px) {
.row-detail-panel {
width: 50%;
max-width: 800px;
}
}
.row-detail-panel::backdrop {
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.1s cubic-bezier(0.2, 0, 0.38, 0.9);
}
.row-detail-panel[open]::backdrop {
opacity: 1;
}
.dialog-content {
padding: 24px;
height: 100%;
overflow-y: auto;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h2 {
margin: 0;
font-size: 1.5em;
}
.close-button {
background: none;
border: none;
font-size: 32px;
cursor: pointer;
padding: 8px;
margin: -8px;
color: #555;
line-height: 1;
}
.close-button:hover {
color: #000;
}
.dialog-navigation {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
gap: 12px;
}
.nav-button {
background-color: #4a6cf7;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.nav-button:hover:not(:disabled) {
background-color: #3a5ce6;
}
.nav-button:disabled {
background-color: #ccc;
cursor: not-allowed;
opacity: 0.6;
}
.row-position {
font-size: 14px;
color: #666;
text-align: center;
flex: 1;
}
.row-detail-content {
font-size: 14px;
}
.row-detail-content .loading {
text-align: center;
color: #666;
padding: 40px;
}
.row-detail-content dl {
margin: 0;
}
.row-detail-content dt {
font-weight: bold;
margin-top: 16px;
margin-bottom: 4px;
color: #333;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.row-detail-content dt:first-child {
margin-top: 0;
}
.row-detail-content dd {
margin: 0;
margin-bottom: 12px;
padding: 12px;
background-color: #f9f9f9;
border-radius: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.row-detail-content dd.null-value {
color: #999;
font-style: italic;
}
.row-detail-content .error {
color: #c00;
padding: 20px;
background-color: #fee;
border-radius: 4px;
}
</style>

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

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

View file

@ -2,13 +2,13 @@
<html lang="en">
<head>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ static('app.css') }}">
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ static('datasette-manager.js') }}" defer></script>
<script src="{{ urls.static('datasette-manager.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 %}
@ -20,7 +20,7 @@
<body class="{% block body_class %}{% endblock %}">
<div class="not-footer">
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
{% set links = menu_links() %}
{% set links = menu_links() %}{% if links or show_logout %}
<details class="nav-menu details-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
@ -29,18 +29,20 @@
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
</svg></summary>
<div class="nav-menu-inner">
{% if links %}
<ul>
<li><button type="button" class="button-as-link" data-navigation-search-open aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="/">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true" title="Keyboard shortcut: press / to open Jump to">/</kbd></button></li>
{% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if show_logout %}
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<button class="button-as-link">Log out</button>
</form>{% endif %}
</div>
</details>
</details>{% endif %}
{% if actor %}
<div class="actor">
<strong>{{ display_actor(actor) }}</strong>
@ -70,7 +72,7 @@
{% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ static('navigation-search.js') }}" defer></script>
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="/-/tables"></navigation-search>
</body>
</html>

View file

@ -50,6 +50,7 @@
</select>
</div>
<input type="text" name="expire_duration" style="width: 10%">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Create token">
<details style="margin-top: 1em" id="restrict-permissions">

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}CSRF check failed{% endblock %}
{% block title %}CSRF check failed){% endblock %}
{% block content %}
<h1>Form origin check failed</h1>
@ -7,7 +7,7 @@
<details><summary>Technical details</summary>
<p>Developers: consult Datasette's <a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection">CSRF protection documentation</a>.</p>
<p>Reason: {{ reason }}</p>
<p>Error code is {{ message_name }}.</p>
</details>
{% endblock %}

View file

@ -5,11 +5,6 @@
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% if database_page_data.createTable %}
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
<script src="{{ static('edit-tools.js') }}" defer></script>
{% endif %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
@ -30,13 +25,9 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if allow_execute_sql %}
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
<h3>Custom SQL query</h3>
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
{% set parameter_names = [] %}
{% set parameter_values = {} %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
<p>
<button id="sql-format" type="button" hidden>Format SQL</button>
<input type="submit" value="Run SQL">
@ -62,9 +53,6 @@
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
{% endfor %}
</ul>
{% if queries_more %}
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
{% endif %}
{% endif %}
{% if tables %}
@ -76,7 +64,7 @@
<div class="db-table">
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}&gt;{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
</div>
{% endif %}
{% endfor %}
@ -99,11 +87,5 @@
{% endif %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %}

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

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

View file

@ -1,9 +1,9 @@
{% 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>
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
<style>
@ -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,61 @@
.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>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<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 +126,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

@ -3,7 +3,7 @@
{% block title %}Permission Rules{% endblock %}
{% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
{% endblock %}
@ -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

@ -1,337 +0,0 @@
{% extends "base.html" %}
{% block title %}Write to this database{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
<style>
.execute-write-template-menu {
margin: 0.9rem 0 0.8rem;
max-width: 52rem;
}
.execute-write-template-menu summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 0.35rem;
}
.execute-write-template-controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.4rem 0 0.7rem;
}
.execute-write-template-menu .execute-write-template-controls label {
margin-right: 0.25rem;
width: auto;
}
.execute-write-template-controls select,
.execute-write-template-controls button[type=button] {
box-sizing: border-box;
font-size: 0.78rem;
height: 2rem;
line-height: 1.1;
padding: 0.35rem 0.55rem;
}
.execute-write-template-controls select {
background-color: #fff;
border: 1px solid #777;
border-radius: 0.25rem;
min-width: 13rem;
}
.execute-write-submit-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.75rem;
}
.execute-write-submit-row [hidden] {
display: none;
}
form.sql.core input[data-execute-write-submit]:disabled {
background: #d0d7de;
border-color: #b6c0cc;
color: #5f6975;
cursor: not-allowed;
opacity: 1;
}
.execute-write form.sql .sql-editor-min-lines .cm-content,
.execute-write form.sql .sql-editor-min-lines .cm-gutter {
/* Four visible editor lines without adding blank lines to the SQL value. */
min-height: calc(5.6em + 8px);
}
.execute-write-disabled-reason {
color: #4f5b6d;
font-size: 0.85rem;
}
</style>
{% include "_execute_write_analysis_styles.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %}
{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Write to this database</h1>
<p>Execute SQL to insert, update or delete rows in this database.</p>
{% if execution_message %}
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
{% endif %}
{% if execute_write_returns_rows %}
<h2>Returned rows</h2>
{% if execute_write_truncated %}
<p class="message-warning">Only the first {{ "{:,}".format(execute_write_display_rows|length) }} returned rows are shown.</p>
{% endif %}
{% set columns = execute_write_columns %}
{% set display_rows = execute_write_display_rows %}
{% set show_zero_results = true %}
{% include "_query_results.html" %}
{% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
{% if write_create_table_template_sql or write_template_tables %}
<div class="execute-write-template-menu">
<details>
<summary>Start with a template</summary>
<p class="execute-write-template-controls">
{% if write_create_table_template_sql %}
<button type="button" data-sql-template="create" data-template-sql="{{ write_create_table_template_sql }}">Create table</button>
{% endif %}
{% if write_template_tables %}
<label for="execute-write-template-table">{% if write_create_table_template_sql %}or table:{% else %}Table{% endif %}</label>
<select id="execute-write-template-table">
{% for table_name, table in write_template_tables|dictsort %}
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
{% endfor %}
</select>
{% for operation in write_template_operations %}
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
{% endfor %}
{% endif %}
</p>
</details>
</div>
{% else %}
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
{% endif %}
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
{% set sql_parameters_allow_expand = true %}
{% include "_sql_parameters.html" %}
<div id="execute-write-analysis-section">
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
</div>
<p class="execute-write-submit-row"{% if save_query_base_url %} data-save-query-base-url="{{ save_query_base_url }}"{% endif %}>
<input type="submit" value="Execute" data-execute-write-submit aria-describedby="execute-write-disabled-reason"{% if execute_disabled %} disabled{% endif %}>
<span id="execute-write-disabled-reason" class="execute-write-disabled-reason" data-execute-write-disabled-reason aria-live="polite"{% if not execute_disabled_reason %} hidden{% endif %}>{{ execute_disabled_reason or "" }}</span>
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query" data-save-query-link>Save this query</a>{% endif %}
</p>
</form>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#execute-write-analysis-section");
const submitButton = form
? form.querySelector("[data-execute-write-submit]")
: null;
const submitDisabledReason = form
? form.querySelector("[data-execute-write-disabled-reason]")
: null;
const submitRow = form
? form.querySelector(".execute-write-submit-row")
: null;
let saveQueryLink = form
? form.querySelector("[data-save-query-link]")
: null;
function updateSubmitState(data) {
if (submitButton) {
submitButton.disabled = data.execute_disabled;
}
if (!submitDisabledReason) {
return;
}
const reason = data.execute_disabled_reason || "";
submitDisabledReason.textContent = reason;
submitDisabledReason.hidden = !reason;
}
function updateSaveQueryLink(data) {
if (!submitRow || !submitRow.dataset.saveQueryBaseUrl) {
return;
}
const sql = window.editor
? window.editor.state.doc.toString()
: executeWriteSqlInput.value;
if (!sql.trim() || !data.ok || data.execute_disabled) {
if (saveQueryLink) {
saveQueryLink.remove();
saveQueryLink = null;
}
return;
}
if (!saveQueryLink) {
saveQueryLink = document.createElement("a");
saveQueryLink.className = "save-query";
saveQueryLink.setAttribute("data-save-query-link", "");
saveQueryLink.textContent = "Save this query";
submitRow.appendChild(saveQueryLink);
}
const url = new URL(
submitRow.dataset.saveQueryBaseUrl,
window.location.href
);
url.searchParams.set("sql", sql);
saveQueryLink.href = url.pathname + url.search + url.hash;
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
allowExpand: true,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
updateSubmitState(data);
updateSaveQueryLink(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
updateSubmitState({
execute_disabled: true,
execute_disabled_reason: error.message,
});
updateSaveQueryLink({ ok: false, execute_disabled: true });
},
});
});
</script>
{% if write_create_table_template_sql or write_template_tables %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const tableSelect = document.querySelector("#execute-write-template-table");
const templateButtons = document.querySelectorAll("[data-sql-template]");
const sqlInput = document.querySelector("textarea#sql-editor");
function dataKey(operation) {
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
}
function selectedOption() {
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
}
function templateSql(button) {
if (button.dataset.templateSql) {
return button.dataset.templateSql;
}
const operation = button.dataset.sqlTemplate;
const option = selectedOption();
return option ? option.dataset[dataKey(operation)] || "" : "";
}
function updateTemplateButtons() {
templateButtons.forEach((button) => {
button.hidden = !templateSql(button);
});
}
function updateSqlUrl(sql) {
if (!window.history || !window.history.replaceState) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.history.replaceState(null, "", url.toString());
}
function setEditorSql(sql) {
if (window.editor) {
window.editor.dispatch({
changes: {
from: 0,
to: window.editor.state.doc.length,
insert: sql,
},
selection: { anchor: sql.length },
});
window.editor.focus();
if (sqlInput) {
sqlInput.value = sql;
}
} else if (sqlInput) {
sqlInput.value = sql;
sqlInput.dispatchEvent(new Event("input", { bubbles: true }));
sqlInput.focus();
}
updateSqlUrl(sql);
}
templateButtons.forEach((button) => {
button.addEventListener("click", () => {
const sql = templateSql(button);
if (!sql) {
return;
}
setEditorSql(sql);
});
});
if (tableSelect) {
tableSelect.addEventListener("change", updateTemplateButtons);
}
updateTemplateButtons();
});
</script>
{% endif %}
{% endblock %}

View file

@ -10,6 +10,7 @@
<form class="core" action="{{ urls.logout() }}" method="post">
<div>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Log out">
</div>
</form>

View file

@ -19,6 +19,7 @@
<option>all</option>
</select>
</div>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Add message">
</div>
</form>

View file

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

View file

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

View file

@ -1,163 +0,0 @@
{% extends "base.html" %}
{% block title %}Create query{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
{% include "_query_form_styles.html" %}
{% endblock %}
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-create-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/store" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
<div class="query-create-fields">
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
</div>
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="query-create-options">
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
<input type="hidden" name="is_private" value="0">
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
</p>
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
{% if has_sql %}
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
{% endif %}
</div>
</form>
</div>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const titleInput = document.querySelector("#query-title");
const urlInput = document.querySelector("#query-url-slug");
let urlEdited = Boolean(urlInput && urlInput.value);
function slugify(value) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
if (titleInput && urlInput) {
titleInput.addEventListener("input", () => {
if (!urlEdited) {
urlInput.value = slugify(titleInput.value);
}
});
urlInput.addEventListener("input", () => {
urlEdited = true;
});
}
});
</script>
<script>
window.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#query-create-analysis-section");
const submitButton = form
? form.querySelector("[data-query-create-submit]")
: null;
const analysisNote = form
? form.querySelector("[data-query-create-analysis-note]")
: null;
function updateAnalysisNote(data) {
if (!analysisNote) {
return;
}
if (data.analysis_error) {
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
} else if (data.has_sql === false) {
analysisNote.textContent = "Enter SQL to analyze this query.";
} else if (data.analysis_is_write) {
analysisNote.textContent = "This query updates data in the database.";
} else {
analysisNote.textContent = "This is a read-only query.";
}
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
renderParameters: false,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
if (submitButton) {
submitButton.disabled = data.save_disabled;
}
updateAnalysisNote(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
if (submitButton) {
submitButton.disabled = true;
}
updateAnalysisNote({ analysis_error: error.message });
},
});
});
</script>
{% endblock %}

View file

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

View file

@ -1,133 +0,0 @@
{% extends "base.html" %}
{% block title %}Edit query: {{ name }}{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
{% include "_query_form_styles.html" %}
{% endblock %}
{% block body_class %}query-edit db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-create-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Edit query: {{ title or name }}</h1>
<form class="sql core query-create-form" action="{{ query_url }}/-/edit" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
<div class="query-create-fields">
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
<p class="query-create-field"><label>URL</label> <span class="query-create-url-static">{{ query_url }}</span></p>
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
</div>
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="query-create-options">
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
<input type="hidden" name="is_private" value="0">
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
<span class="query-create-option-note">Queries marked private can only be seen and edited by you, their owner.</span>
</p>
<p class="query-create-submit"><input type="submit" value="Save changes" data-query-create-submit{% if save_disabled %} disabled{% endif %}> <a href="{{ query_url }}">Cancel</a></p>
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
{% if has_sql %}
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
{% endif %}
</div>
</form>
</div>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#query-create-analysis-section");
const submitButton = form
? form.querySelector("[data-query-create-submit]")
: null;
const analysisNote = form
? form.querySelector("[data-query-create-analysis-note]")
: null;
function updateAnalysisNote(data) {
if (!analysisNote) {
return;
}
if (data.analysis_error) {
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
} else if (data.has_sql === false) {
analysisNote.textContent = "Enter SQL to analyze this query.";
} else if (data.analysis_is_write) {
analysisNote.textContent = "This query updates data in the database.";
} else {
analysisNote.textContent = "This is a read-only query.";
}
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
renderParameters: false,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
if (submitButton) {
submitButton.disabled = data.save_disabled;
}
updateAnalysisNote(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
if (submitButton) {
submitButton.disabled = true;
}
updateAnalysisNote({ analysis_error: error.message });
},
});
});
</script>
{% endblock %}

View file

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

View file

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

View file

@ -1,17 +1,11 @@
{% extends "base.html" %}
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}&gt;{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block extra_head %}
{{- super() -}}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
<script src="{{ static('column-chooser.js') }}" defer></script>
{% if table_page_data.foreignKeys %}
<script src="{{ static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ static('edit-tools.js') }}" defer></script>
<script src="{{ static('table.js') }}" defer></script>
<script src="{{ static('mobile-column-actions.js') }}" defer></script>
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
<script src="{{ urls.static('table.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style>
@media only screen and (max-width: 576px) {
@ -48,19 +42,19 @@
{% if count or human_description_en %}
<h3>
{% if count_truncated %}&gt;{{ "{:,}".format(count - 1) }} rows
{% if count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
{% if human_description_en %}{{ human_description_en }}{% endif %}
</h3>
{% endif %}
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
{% if supports_search %}
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %}
{% for column, lookup, value in filters.selections() %}
<div class="filter-row filter-controls-row">
<div class="filter-row">
<div class="select-wrapper">
<select name="_filter_column_{{ loop.index }}">
<option value="">- remove filter -</option>
@ -77,7 +71,7 @@
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
</div>
{% endfor %}
<div class="filter-row filter-controls-row">
<div class="filter-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
@ -93,9 +87,9 @@
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row filter-actions-row">
<div class="filter-row">
{% if is_sortable %}
<div class="select-wrapper">
<div class="select-wrapper small-screen-only">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
{% for column in display_columns %}
@ -105,12 +99,12 @@
{% endfor %}
</select>
</div>
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
{% endif %}
{% for key, value in form_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<input type="submit" value="Apply filters" tabindex="0">
<input type="submit" value="Apply">
</div>
</form>
@ -146,35 +140,10 @@
{% if all_columns %}
<column-chooser></column-chooser>
<button class="choose-columns-mobile small-screen-only" onclick="openColumnChooser()">Choose columns</button>
<button type="button" class="column-actions-mobile small-screen-only">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Column actions</span>
</button>
<script>
window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }};
</script>
{% endif %}
{% if set_column_type_ui %}
<script>
window._setColumnTypeData = {{ set_column_type_ui|tojson }};
</script>
{% endif %}
{% if table_insert_ui %}
<div class="table-row-toolbar">
<button type="button" class="core table-insert-row" data-table-action="insert-row">
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M8 12h8"></path>
<path d="M12 8v8"></path>
</svg>
<span>Insert row</span>
</button>
</div>
{% endif %}
{% include custom_table_templates %}

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:
"""
@ -67,38 +52,6 @@ class TokenRestrictions:
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
return self
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
"""
Return the abbreviated ``_r`` dictionary shape for this set of
restrictions, using action abbreviations registered with ``datasette``.
Returns ``None`` if no restrictions are set.
"""
if not (self.all or self.database or self.resource):
return None
def abbreviate_action(action):
action_obj = datasette.actions.get(action)
if not action_obj:
return action
return action_obj.abbr or action
result: dict = {}
if self.all:
result["a"] = [abbreviate_action(a) for a in self.all]
if self.database:
result["d"] = {
database: [abbreviate_action(a) for a in actions]
for database, actions in self.database.items()
}
if self.resource:
result["r"] = {}
for database, resources in self.resource.items():
for resource, actions in resources.items():
result["r"].setdefault(database, {})[resource] = [
abbreviate_action(a) for a in actions
]
return result
class TokenHandler:
"""
@ -123,12 +76,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
@ -155,43 +104,59 @@ class SignedTokenHandler(TokenHandler):
token = {"a": actor_id, "t": int(time.time())}
def abbreviate_action(action):
action_obj = datasette.actions.get(action)
if not action_obj:
return action
return action_obj.abbr or action
if expires_after:
token["d"] = expires_after
if restrictions is not None:
abbreviated = restrictions.abbreviated(datasette)
if abbreviated is not None:
token["_r"] = abbreviated
if restrictions and (
restrictions.all or restrictions.database or restrictions.resource
):
token["_r"] = {}
if restrictions.all:
token["_r"]["a"] = [abbreviate_action(a) for a in restrictions.all]
if restrictions.database:
token["_r"]["d"] = {}
for database, actions in restrictions.database.items():
token["_r"]["d"][database] = [abbreviate_action(a) for a in actions]
if restrictions.resource:
token["_r"]["r"] = {}
for database, resources in restrictions.resource.items():
for resource, actions in resources.items():
token["_r"]["r"].setdefault(database, {})[resource] = [
abbreviate_action(a) for a in actions
]
return "dstok_{}".format(datasette.sign(token, namespace="token"))
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 +167,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

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

View file

@ -1,5 +1,4 @@
import asyncio
import binascii
from contextlib import contextmanager
import aiofiles
import click
@ -156,15 +155,9 @@ Column = namedtuple(
functions_marked_as_documented = []
def documented(fn=None, *, label=None):
def decorate(fn):
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
functions_marked_as_documented.append(fn)
return fn
if fn is None:
return decorate
return decorate(fn)
def documented(fn):
functions_marked_as_documented.append(fn)
return fn
@documented
@ -225,71 +218,24 @@ def compound_keys_after_sql(pks, start_index=0):
return "({})".format("\n or\n".join(or_clauses))
@documented
class CustomJSONEncoder(json.JSONEncoder):
"""
The CustomJSONEncoder class handles serialization for objects commonly used by Datasette,
including SQLite cursors and binary blobs. Datasette uses it internally to serve .json endpoints,
and plugins that return JSON can use it to match Datasette's own handling.
Built-in types (text, numbers, lists, etc) are encoded the same as Python's built-in ``json`` module.
- ``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: ::
{
"$base64": True,
"encoded": ...,
}
Example: https://latest.datasette.io/fixtures/binary_data.json
"""
def default(self, obj):
if isinstance(obj, sqlite3.Row):
return tuple(obj)
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)
@ -458,7 +404,8 @@ def escape_css_string(s):
def escape_sqlite(s):
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
return s
return '"{}"'.format(s.replace('"', '""'))
else:
return f"[{s}]"
def make_dockerfile(
@ -636,7 +583,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:
@ -734,18 +681,13 @@ def detect_fts_sql(table):
def detect_json1(conn=None):
close_conn = False
if conn is None:
conn = sqlite3.connect(":memory:")
close_conn = True
try:
conn.execute("SELECT json('{}')")
return True
except Exception:
return False
finally:
if close_conn:
conn.close()
def table_columns(conn, table):
@ -884,8 +826,7 @@ def path_with_format(
*, request=None, path=None, format=None, extra_qs=None, replace_format=None
):
qs = extra_qs or {}
if path is None and request:
path = request.path
path = request.path if request else path
if replace_format and path.endswith(f".{replace_format}"):
path = path[: -(1 + len(replace_format))]
if "." in path:
@ -1145,35 +1086,12 @@ def _gather_arguments(fn, kwargs):
return call_with
@documented
def call_with_supported_arguments(fn, **kwargs):
"""
Call ``fn`` with the subset of ``**kwargs`` matching its signature.
This implements dependency injection: the caller provides all available
keyword arguments and the function receives only the ones it declares
as parameters.
:param fn: A callable (sync function)
:param kwargs: All available keyword arguments
:returns: The return value of ``fn``
"""
call_with = _gather_arguments(fn, kwargs)
return fn(*call_with)
@documented
async def async_call_with_supported_arguments(fn, **kwargs):
"""
Async version of :func:`call_with_supported_arguments`.
Calls ``await fn(...)`` with the subset of ``**kwargs`` matching its
signature.
:param fn: An async callable
:param kwargs: All available keyword arguments
:returns: The return value of ``await fn(...)``
"""
call_with = _gather_arguments(fn, kwargs)
return await fn(*call_with)
@ -1288,20 +1206,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 +1220,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 +1236,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"
@ -1648,17 +1509,6 @@ def md5_not_usedforsecurity(s):
_etag_cache = {}
def sha256_file(filepath, chunk_size=4096):
hasher = hashlib.sha256()
with open(filepath, "rb") as fp:
while True:
chunk = fp.read(chunk_size)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
async def calculate_etag(filepath, chunk_size=4096):
if filepath in _etag_cache:
return _etag_cache[filepath]

View file

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

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