Compare commits

..

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

175 changed files with 3407 additions and 39400 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 runs-on: ubuntu-latest
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v7 uses: actions/checkout@v6
- uses: actions/cache@v6 - uses: actions/cache@v5
name: Configure npm caching name: Configure npm caching
with: with:
path: ~/.npm path: ~/.npm

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ jobs:
#"3.23.1" # 2018-04-10, before UPSERT #"3.23.1" # 2018-04-10, before UPSERT
] ]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -34,7 +34,7 @@ jobs:
cache: pip cache: pip
cache-dependency-path: pyproject.toml cache-dependency-path: pyproject.toml
- name: Set up SQLite ${{ matrix.sqlite-version }} - name: Set up SQLite ${{ matrix.sqlite-version }}
uses: ./.github/actions/setup-sqlite-version uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
with: with:
version: ${{ matrix.sqlite-version }} version: ${{ matrix.sqlite-version }}
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1" 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: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:

View file

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

View file

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

4
.gitignore vendored
View file

@ -1,12 +1,8 @@
build-metadata.json build-metadata.json
datasets.json datasets.json
.playwright-mcp
scratchpad scratchpad
ignored/
.vscode .vscode
uv.lock uv.lock

View file

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

View file

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

View file

@ -19,40 +19,25 @@ import weakref
import pytest import pytest
from datasette.app import Datasette
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar( _active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
"datasette_active_instances", default=None "datasette_active_instances", default=None
) )
_original_init = None
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__ _original_init = Datasette.__init__
def _tracking_init(self, *args, **kwargs): def _tracking_init(self, *args, **kwargs):
_original_init(self, *args, **kwargs) _original_init(self, *args, **kwargs)
instances = _active_instances.get() instances = _active_instances.get()
if instances is not None: if instances is not None:
instances.append(weakref.ref(self)) instances.append(weakref.ref(self))
Datasette.__init__ = _tracking_init Datasette.__init__ = _tracking_init
def pytest_configure(config):
if _enabled(config):
_install_tracking()
def pytest_addoption(parser): def pytest_addoption(parser):
parser.addini( parser.addini(
"datasette_autoclose", "datasette_autoclose",

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextvars import contextvars
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence from typing import TYPE_CHECKING, Any, Dict, Iterable, List
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.permissions import Resource from datasette.permissions import Resource
@ -12,6 +12,7 @@ import dataclasses
import datetime import datetime
import functools import functools
import glob import glob
import hashlib
import httpx import httpx
import importlib.metadata import importlib.metadata
import inspect import inspect
@ -34,7 +35,6 @@ from jinja2 import (
ChoiceLoader, ChoiceLoader,
Environment, Environment,
FileSystemLoader, FileSystemLoader,
pass_context,
PrefixLoader, PrefixLoader,
) )
from jinja2.environment import Template from jinja2.environment import Template
@ -42,36 +42,12 @@ from jinja2.exceptions import TemplateNotFound
from .events import Event from .events import Event
from .column_types import SQLiteType from .column_types import SQLiteType
from . import stored_queries, write_sql
from .views import Context from .views import Context
from .views.database import ( from .views.database import database_download, DatabaseView, TableCreateView, QueryView
database_download,
DatabaseView,
QueryView,
)
from .views.table_create_alter import (
DatabaseForeignKeyTargetsView,
TableAlterView,
TableCreateView,
TableForeignKeySuggestionsView,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.stored_queries import (
QueryCreateAnalyzeView,
QueryDeleteView,
QueryDefinitionView,
QueryEditView,
GlobalQueryListView,
QueryListView,
QueryParametersView,
QueryStoreView,
QueryUpdateView,
)
from .views.index import IndexView from .views.index import IndexView
from .views.special import ( from .views.special import (
JsonDataView, JsonDataView,
PatternPortfolioView, PatternPortfolioView,
AutocompleteDebugView,
AuthTokenView, AuthTokenView,
ApiExplorerView, ApiExplorerView,
CreateTokenView, CreateTokenView,
@ -88,12 +64,10 @@ from .views.special import (
TableSchemaView, TableSchemaView,
) )
from .views.table import ( from .views.table import (
TableAutocompleteView,
TableInsertView, TableInsertView,
TableUpsertView, TableUpsertView,
TableSetColumnTypeView, TableSetColumnTypeView,
TableDropView, TableDropView,
TableFragmentView,
table_view, table_view,
) )
from .views.row import RowView, RowDeleteView, RowUpdateView from .views.row import RowView, RowDeleteView, RowUpdateView
@ -111,7 +85,6 @@ from .utils import (
baseconv, baseconv,
call_with_supported_arguments, call_with_supported_arguments,
detect_json1, detect_json1,
add_cors_headers,
display_actor, display_actor,
escape_css_string, escape_css_string,
escape_sqlite, escape_sqlite,
@ -123,7 +96,6 @@ from .utils import (
parse_metadata, parse_metadata,
resolve_env_secrets, resolve_env_secrets,
resolve_routes, resolve_routes,
sha256_file,
tilde_decode, tilde_decode,
tilde_encode, tilde_encode,
to_css_class, to_css_class,
@ -131,10 +103,8 @@ from .utils import (
redact_keys, redact_keys,
row_sql_params_pks, row_sql_params_pks,
) )
from .tokens import TokenInvalid
from .utils.asgi import ( from .utils.asgi import (
AsgiLifespan, AsgiLifespan,
BadRequest,
Forbidden, Forbidden,
NotFound, NotFound,
DatabaseNotFound, DatabaseNotFound,
@ -209,11 +179,6 @@ SETTINGS = (
100, 100,
"Maximum rows that can be inserted at a time using the bulk insert API", "Maximum rows that can be inserted at a time using the bulk insert API",
), ),
Setting(
"max_post_body_bytes",
2 * 1024 * 1024,
"Maximum size in bytes for a POST body read into memory, e.g. JSON API requests - set 0 to disable this limit",
),
Setting( Setting(
"num_sql_threads", "num_sql_threads",
3, 3,
@ -308,21 +273,12 @@ DEFAULT_NOT_SET = object()
ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params"))
def _permission_cache_key(actor, action, parent, child):
# Key on the full serialized actor so actors differing in any field
# (e.g. token restrictions) never share cache entries
actor_key = (
json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
)
return (actor_key, action, parent, child)
async def favicon(request, send): async def favicon(request, send):
await asgi_send_file( await asgi_send_file(
send, send,
str(FAVICON_PATH), str(FAVICON_PATH),
content_type="image/png", content_type="image/png",
headers={"Cache-Control": "max-age=3600, public"}, headers={"Cache-Control": "max-age=3600, immutable, public"},
) )
@ -339,57 +295,6 @@ def _to_string(value):
return json.dumps(value, default=str) return json.dumps(value, default=str)
def _template_context_json_default(value):
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return {
field.name: getattr(value, field.name)
for field in dataclasses.fields(value)
}
return repr(value)
@pass_context
def _legacy_template_csrftoken(context):
request = context.get("request")
if request and "csrftoken" in request.scope:
return request.scope["csrftoken"]()
return ""
def _resolve_static_asset_path(root_path, path):
root = Path(root_path).resolve()
full_path = (root / path).resolve()
try:
full_path.relative_to(root)
except ValueError:
raise ValueError("Static asset path cannot escape static root") from None
return full_path
# Documentation for the variables Datasette.render_template() adds to the
# context for every page. This is part of the documented template contract:
# keys added in render_template() must be documented here - the contract
# tests in tests/test_template_context.py enforce this, and the docs in
# docs/template_context.rst are generated from it.
TEMPLATE_BASE_CONTEXT = {
"request": "The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.",
"crumb_items": 'Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.',
"urls": "Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.",
"actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.",
"menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.",
"display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.",
"show_logout": "True if the logout link should be shown in the navigation menu",
"zip": "Python's ``zip()`` builtin, made available to template logic",
"body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``<script type="module">``; otherwise Datasette wraps it in a regular ``<script>`` block.',
"format_bytes": "Function that accepts a byte count integer and returns a human-readable string such as ``1.2 MB``.",
"show_messages": "Function returning any messages set for the current user, clearing them in the process. Returns a list of ``(message, type)`` pairs, where ``type`` is one of Datasette's ``INFO``, ``WARNING`` or ``ERROR`` constants.",
"extra_css_urls": "List of extra CSS stylesheets to include on the page. Each item is a dictionary with ``url`` and optional ``sri`` keys, from plugins and configuration.",
"extra_js_urls": "List of extra JavaScript URLs to include on the page. Each item is a dictionary with ``url`` plus optional ``sri`` and ``module`` keys, from plugins and configuration.",
"base_url": "The configured :ref:`setting_base_url` setting",
"datasette_version": "The version of Datasette that is running",
}
class Datasette: class Datasette:
# Message constants: # Message constants:
INFO = 1 INFO = 1
@ -482,7 +387,6 @@ class Datasette:
self._internal_database.name = INTERNAL_DB_NAME self._internal_database.name = INTERNAL_DB_NAME
self.cache_headers = cache_headers self.cache_headers = cache_headers
self._static_asset_hashes = {}
self.cors = cors self.cors = cors
config_files = [] config_files = []
metadata_files = [] metadata_files = []
@ -623,8 +527,6 @@ class Datasette:
) )
environment.filters["escape_css_string"] = escape_css_string environment.filters["escape_css_string"] = escape_css_string
environment.filters["quote_plus"] = urllib.parse.quote_plus environment.filters["quote_plus"] = urllib.parse.quote_plus
environment.globals["csrftoken"] = _legacy_template_csrftoken
environment.globals["static"] = self.static
self._jinja_env = environment self._jinja_env = environment
environment.filters["escape_sqlite"] = escape_sqlite environment.filters["escape_sqlite"] = escape_sqlite
environment.filters["to_css_class"] = to_css_class environment.filters["to_css_class"] = to_css_class
@ -669,9 +571,6 @@ class Datasette:
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log # TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
# a warning to user that they should delete their metadata.json file # a warning to user that they should delete their metadata.json file
async def _save_queries_from_config(self):
await stored_queries.save_queries_from_config(self)
def get_jinja_environment(self, request: Request = None) -> Environment: def get_jinja_environment(self, request: Request = None) -> Environment:
environment = self._jinja_env environment = self._jinja_env
if request: if request:
@ -693,12 +592,9 @@ class Datasette:
return action return action
return None return None
async def refresh_schemas(self, *, force=False): async def refresh_schemas(self):
# Throttle schema refreshes to at most once per second # Throttle schema refreshes to at most once per second
if ( if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0:
not force
and time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0
):
return return
self._last_schema_refresh = time.monotonic() self._last_schema_refresh = time.monotonic()
if self._refresh_schemas_lock.locked(): if self._refresh_schemas_lock.locked():
@ -835,7 +731,6 @@ class Datasette:
await await_me_maybe(hook) await await_me_maybe(hook)
# Ensure internal tables and metadata are populated before startup hooks # Ensure internal tables and metadata are populated before startup hooks
await self._refresh_schemas() await self._refresh_schemas()
await self._save_queries_from_config()
# Load column_types from config into internal DB # Load column_types from config into internal DB
await self._apply_column_types_config() await self._apply_column_types_config()
for hook in pm.hook.startup(datasette=self): for hook in pm.hook.startup(datasette=self):
@ -913,9 +808,7 @@ class Datasette:
Verify an API token by trying all registered token handlers. Verify an API token by trying all registered token handlers.
Returns an actor dict from the first handler that recognizes the Returns an actor dict from the first handler that recognizes the
token, or None if no handler accepts it. A handler may raise token, or None if no handler accepts it.
TokenInvalid for a token it recognizes but rejects (bad signature,
expired) - Datasette turns that into a 401 response.
""" """
for token_handler in self._token_handlers(): for token_handler in self._token_handlers():
result = await token_handler.verify_token(self, token) result = await token_handler.verify_token(self, token)
@ -1114,180 +1007,6 @@ class Datasette:
[database_name, resource_name, column_name, key, value], [database_name, resource_name, column_name, key, value],
) )
@staticmethod
def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None:
return stored_queries.query_row_to_stored_query(row)
@staticmethod
def _query_options_json(options):
return stored_queries.query_options_json(options)
async def add_query(
self,
database: str,
name: str,
sql: str,
*,
title: str | None = None,
description: str | None = None,
description_html: str | None = None,
hide_sql: bool = False,
fragment: str | None = None,
parameters: Iterable[str] | None = None,
is_write: bool = False,
is_private: bool = False,
is_trusted: bool = False,
source: str = "plugin",
owner_id: str | None = None,
on_success_message: str | None = None,
on_success_message_sql: str | None = None,
on_success_redirect: str | None = None,
on_error_message: str | None = None,
on_error_redirect: str | None = None,
replace: bool = True,
) -> None:
return await stored_queries.add_query(
self,
database,
name,
sql,
title=title,
description=description,
description_html=description_html,
hide_sql=hide_sql,
fragment=fragment,
parameters=parameters,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
on_success_message=on_success_message,
on_success_message_sql=on_success_message_sql,
on_success_redirect=on_success_redirect,
on_error_message=on_error_message,
on_error_redirect=on_error_redirect,
replace=replace,
)
async def update_query(
self,
database: str,
name: str,
*,
sql=stored_queries.UNCHANGED,
title=stored_queries.UNCHANGED,
description=stored_queries.UNCHANGED,
description_html=stored_queries.UNCHANGED,
hide_sql=stored_queries.UNCHANGED,
fragment=stored_queries.UNCHANGED,
parameters=stored_queries.UNCHANGED,
is_write=stored_queries.UNCHANGED,
is_private=stored_queries.UNCHANGED,
is_trusted=stored_queries.UNCHANGED,
source=stored_queries.UNCHANGED,
owner_id=stored_queries.UNCHANGED,
on_success_message=stored_queries.UNCHANGED,
on_success_message_sql=stored_queries.UNCHANGED,
on_success_redirect=stored_queries.UNCHANGED,
on_error_message=stored_queries.UNCHANGED,
on_error_redirect=stored_queries.UNCHANGED,
) -> None:
return await stored_queries.update_query(
self,
database,
name,
sql=sql,
title=title,
description=description,
description_html=description_html,
hide_sql=hide_sql,
fragment=fragment,
parameters=parameters,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
on_success_message=on_success_message,
on_success_message_sql=on_success_message_sql,
on_success_redirect=on_success_redirect,
on_error_message=on_error_message,
on_error_redirect=on_error_redirect,
)
async def remove_query(
self, database: str, name: str, source: str | None = None
) -> None:
return await stored_queries.remove_query(self, database, name, source=source)
async def get_query(
self, database: str, name: str
) -> stored_queries.StoredQuery | None:
return await stored_queries.get_query(self, database, name)
async def count_queries(
self,
database: str | None = None,
*,
actor: dict[str, Any] | None = None,
q: str | None = None,
is_write: bool | None = None,
is_private: bool | None = None,
is_trusted: bool | None = None,
source: str | None = None,
owner_id: str | None = None,
) -> int:
return await stored_queries.count_queries(
self,
database,
actor=actor,
q=q,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
)
async def list_queries(
self,
database: str | None = None,
*,
actor: dict[str, Any] | None = None,
limit: int = 50,
cursor: str | None = None,
q: str | None = None,
is_write: bool | None = None,
is_private: bool | None = None,
is_trusted: bool | None = None,
source: str | None = None,
owner_id: str | None = None,
include_private: bool = False,
) -> stored_queries.StoredQueryPage:
return await stored_queries.list_queries(
self,
database,
actor=actor,
limit=limit,
cursor=cursor,
q=q,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
include_private=include_private,
)
async def ensure_query_write_permissions(
self, database, sql, *, actor=None, params=None, analysis=None
):
# Raise Forbidden or QueryWriteRejected if SQL should not run
return await write_sql.ensure_query_write_permissions(
self, database, sql, actor=actor, params=params, analysis=analysis
)
# Column types API # Column types API
async def _get_resource_column_details(self, database: str, resource: str): async def _get_resource_column_details(self, database: str, resource: str):
@ -1500,55 +1219,47 @@ class Datasette:
return db_plugin_config return db_plugin_config
def _static_asset_path(self, path): def static_hash(self, filename):
return _resolve_static_asset_path(app_root / "datasette" / "static", path) if not hasattr(self, "_static_hashes"):
self._static_hashes = {}
path = os.path.join(str(app_root), "datasette/static", filename)
signature = (os.path.getmtime(path), os.path.getsize(path))
cached = self._static_hashes.get(filename)
if cached and cached["signature"] == signature:
return cached["hash"]
with open(path) as fp:
static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6]
self._static_hashes[filename] = {
"signature": signature,
"hash": static_hash,
}
return static_hash
def _static_plugin_asset_path(self, plugin_name, path): def app_css_hash(self):
for plugin in get_plugins(): return self.static_hash("app.css")
if not plugin["static_path"]:
continue
possible_names = {plugin["name"], plugin["name"].replace("-", "_")}
if plugin_name in possible_names:
return _resolve_static_asset_path(plugin["static_path"], path)
raise FileNotFoundError(
"No static assets found for plugin {}".format(plugin_name)
)
def _static_mounted_asset(self, mount_name, path): async def get_canned_queries(self, database_name, actor):
mount_name = mount_name.strip("/") queries = {}
for mount, dirname in self.static_mounts: for more_queries in pm.hook.canned_queries(
if mount.strip("/") == mount_name: datasette=self,
return ( database=database_name,
_resolve_static_asset_path(dirname, path), actor=actor,
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))), ):
) more_queries = await await_me_maybe(more_queries)
raise FileNotFoundError("No static mount found for {}".format(mount_name)) queries.update(more_queries or {})
# Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
for key in queries:
if not isinstance(queries[key], dict):
queries[key] = {"sql": queries[key]}
# Also make sure "name" is available:
queries[key]["name"] = key
return queries
def _static_asset_hash(self, filepath): async def get_canned_query(self, database_name, query_name, actor):
filepath = Path(filepath) queries = await self.get_canned_queries(database_name, actor)
if self.cache_headers: query = queries.get(query_name)
cached = self._static_asset_hashes.get(filepath) if query:
if cached: return query
return cached
digest = sha256_file(filepath)[:12]
if self.cache_headers:
self._static_asset_hashes[filepath] = digest
return digest
def static(self, path, plugin=None, mount=None):
if plugin and mount:
raise ValueError("Use either plugin= or mount=, not both")
if plugin:
filepath = self._static_plugin_asset_path(plugin, path)
url = self.urls.static_plugins(plugin, path)
elif mount:
filepath, url = self._static_mounted_asset(mount, path)
else:
filepath = self._static_asset_path(path)
url = self.urls.static(path)
hash_value = self._static_asset_hash(filepath)
separator = "&" if "?" in url else "?"
return url + separator + urllib.parse.urlencode({"_hash": hash_value})
def _prepare_connection(self, conn, database): def _prepare_connection(self, conn, database):
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -1933,124 +1644,46 @@ class Datasette:
# For global actions, resource can be omitted: # For global actions, resource can be omitted:
can_debug = await datasette.allowed(action="permissions-debug", actor=actor) can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
""" """
results = await self.allowed_many( from datasette.utils.actions_sql import check_permission_for_resource
actions=[action], resource=resource, actor=actor
)
return results[action]
async def allowed_many( # For global actions, resource remains None
self,
*,
actions: Sequence[str],
resource: "Resource" = None,
actor: dict | None = None,
) -> dict[str, bool]:
"""
Check several actions against one resource for one actor.
Resolves every action (plus any also_requires dependencies) with a # Check if this action has also_requires - if so, check that action first
single internal database query, instead of one or two queries per action_obj = self.actions.get(action)
action. Results are stored in the request-scoped permission cache, if action_obj and action_obj.also_requires:
so subsequent datasette.allowed() calls for the same checks within # Must have the required action first
the same request are served from the cache. if not await self.allowed(
action=action_obj.also_requires,
Example: resource=resource,
from datasette.resources import TableResource
results = await datasette.allowed_many(
actions=["edit-schema", "drop-table", "insert-row"],
resource=TableResource(database="data", table="exercise"),
actor=actor, actor=actor,
) ):
# {"edit-schema": True, "drop-table": True, "insert-row": False} return False
"""
from datasette.utils.actions_sql import check_permissions_for_actions
from datasette.permissions import (
_permission_check_cache,
_skip_permission_checks,
)
# For global actions, resource is None # For global actions, resource is None
parent = resource.parent if resource else None parent = resource.parent if resource else None
child = resource.child if resource else None child = resource.child if resource else None
# Expand also_requires dependencies (transitively) so that each result = await check_permission_for_resource(
# dependency is resolved within the same batch
expanded = []
def add_action(name):
if name in expanded:
return
action_obj = self.actions.get(name)
if action_obj is None:
raise ValueError(f"Unknown action: {name}")
expanded.append(name)
if action_obj.also_requires:
add_action(action_obj.also_requires)
requested = list(dict.fromkeys(actions))
for name in requested:
add_action(name)
# Consult the request-scoped cache, unless permission checks are
# being skipped (skip-mode verdicts must never be cached)
skip = _skip_permission_checks.get()
cache = None if skip else _permission_check_cache.get()
final = {}
to_check = []
for name in expanded:
if cache is not None:
key = _permission_cache_key(actor, name, parent, child)
if key in cache:
final[name] = cache[key]
continue
to_check.append(name)
raw = {}
if to_check:
raw = await check_permissions_for_actions(
datasette=self, datasette=self,
actor=actor, actor=actor,
actions=to_check, action=action,
parent=parent, parent=parent,
child=child, child=child,
) )
def resolve(name): # Log the permission check for debugging
# final verdict = own rules AND verdict of also_requires chain
if name in final:
return final[name]
result = raw[name]
action_obj = self.actions.get(name)
if result and action_obj.also_requires:
result = resolve(action_obj.also_requires)
final[name] = result
return result
for name in expanded:
resolve(name)
# Cache the freshly computed checks
if cache is not None:
for name in to_check:
cache[_permission_cache_key(actor, name, parent, child)] = final[name]
# Log every check (including cache hits) for the debug page,
# dependencies before the actions that required them
when = datetime.datetime.now(datetime.timezone.utc).isoformat()
for name in reversed(expanded):
self._permission_checks.append( self._permission_checks.append(
PermissionCheck( PermissionCheck(
when=when, when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
actor=actor, actor=actor,
action=name, action=action,
parent=parent, parent=parent,
child=child, child=child,
result=final[name], result=result,
) )
) )
return {name: final[name] for name in requested} return result
async def ensure_permission( async def ensure_permission(
self, self,
@ -2123,11 +1756,6 @@ class Datasette:
other_table = fk["other_table"] other_table = fk["other_table"]
other_column = fk["other_column"] other_column = fk["other_column"]
if other_column is None:
other_pks = await db.primary_keys(other_table)
if len(other_pks) != 1:
return {}
other_column = other_pks[0]
visible, _ = await self.check_visibility( visible, _ = await self.check_visibility(
actor, actor,
action="view-table", action="view-table",
@ -2178,18 +1806,6 @@ class Datasette:
for name, d in self.databases.items() for name, d in self.databases.items()
] ]
async def _connected_databases_for_actor(self, actor):
page = await self.allowed_resources("view-database", actor)
allowed_names = {resource.parent async for resource in page.all()}
return [
database
for database in self._connected_databases()
if database["name"] in allowed_names
]
async def _databases_data(self, request):
return {"databases": await self._connected_databases_for_actor(request.actor)}
def _versions(self): def _versions(self):
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
self._prepare_connection(conn, "_memory") self._prepare_connection(conn, "_memory")
@ -2373,11 +1989,7 @@ class Datasette:
templates = [templates] templates = [templates]
template = self.get_jinja_environment(request).select_template(templates) template = self.get_jinja_environment(request).select_template(templates)
if dataclasses.is_dataclass(context): if dataclasses.is_dataclass(context):
# Shallow conversion - asdict() would deep-copy values, which context = dataclasses.asdict(context)
# is wasteful and fails on values like sqlite3.Row
context = {
f.name: getattr(context, f.name) for f in dataclasses.fields(context)
}
body_scripts = [] body_scripts = []
# pylint: disable=no-member # pylint: disable=no-member
for extra_script in pm.hook.extra_body_script( for extra_script in pm.hook.extra_body_script(
@ -2427,8 +2039,6 @@ class Datasette:
links.extend(extra_links) links.extend(extra_links)
return links return links
# Keys added here must be documented in TEMPLATE_BASE_CONTEXT -
# the contract tests fail otherwise
template_context = { template_context = {
**context, **context,
**{ **{
@ -2441,6 +2051,7 @@ class Datasette:
"show_logout": request is not None "show_logout": request is not None
and "ds_actor" in request.cookies and "ds_actor" in request.cookies
and request.actor, and request.actor,
"app_css_hash": self.app_css_hash(),
"zip": zip, "zip": zip,
"body_scripts": body_scripts, "body_scripts": body_scripts,
"format_bytes": format_bytes, "format_bytes": format_bytes,
@ -2452,19 +2063,18 @@ class Datasette:
"extra_js_urls", template, context, request, view_name "extra_js_urls", template, context, request, view_name
), ),
"base_url": self.setting("base_url"), "base_url": self.setting("base_url"),
"csrftoken": (
request.scope["csrftoken"]
if request and "csrftoken" in request.scope
else lambda: ""
),
"datasette_version": __version__, "datasette_version": __version__,
}, },
**extra_template_vars, **extra_template_vars,
} }
if request and request.args.get("_context") and self.setting("template_debug"): if request and request.args.get("_context") and self.setting("template_debug"):
return "<pre>{}</pre>".format( return "<pre>{}</pre>".format(
escape( escape(json.dumps(template_context, default=repr, indent=4))
json.dumps(
template_context,
default=_template_context_json_default,
indent=4,
)
)
) )
return await template.render_async(template_context) return await template.render_async(template_context)
@ -2536,9 +2146,10 @@ class Datasette:
def add_route(view, regex): def add_route(view, regex):
routes.append((regex, view)) routes.append((regex, view))
add_route(IndexView.as_view(self), r"/(\.(?P<format>json))?$") add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>json))?$") add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
add_route(permanent_redirect("/-/"), r"/-$") add_route(permanent_redirect("/-/"), r"/-$")
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
add_route(favicon, "/favicon.ico") add_route(favicon, "/favicon.ico")
add_route( add_route(
@ -2573,10 +2184,7 @@ class Datasette:
) )
add_route( add_route(
JsonDataView.as_view( JsonDataView.as_view(
self, self, "plugins.json", self._plugins, needs_request=True
"plugins.json",
lambda request: {"plugins": self._plugins(request)},
needs_request=True,
), ),
r"/-/plugins(\.(?P<format>json))?$", r"/-/plugins(\.(?P<format>json))?$",
) )
@ -2589,18 +2197,11 @@ class Datasette:
r"/-/config(\.(?P<format>json))?$", r"/-/config(\.(?P<format>json))?$",
) )
add_route( add_route(
JsonDataView.as_view( JsonDataView.as_view(self, "threads.json", self._threads),
self, "threads.json", self._threads, permission="permissions-debug"
),
r"/-/threads(\.(?P<format>json))?$", r"/-/threads(\.(?P<format>json))?$",
) )
add_route( add_route(
JsonDataView.as_view( JsonDataView.as_view(self, "databases.json", self._connected_databases),
self,
"databases.json",
self._databases_data,
needs_request=True,
),
r"/-/databases(\.(?P<format>json))?$", r"/-/databases(\.(?P<format>json))?$",
) )
add_route( add_route(
@ -2613,7 +2214,7 @@ class Datasette:
JsonDataView.as_view( JsonDataView.as_view(
self, self,
"actions.json", "actions.json",
lambda: {"actions": self._actions()}, self._actions,
template="debug_actions.html", template="debug_actions.html",
permission="permissions-debug", permission="permissions-debug",
), ),
@ -2635,10 +2236,6 @@ class Datasette:
JumpView.as_view(self), JumpView.as_view(self),
r"/-/jump(\.(?P<format>json))?$", r"/-/jump(\.(?P<format>json))?$",
) )
add_route(
GlobalQueryListView.as_view(self),
r"/-/queries(\.(?P<format>json))?$",
)
add_route( add_route(
InstanceSchemaView.as_view(self), InstanceSchemaView.as_view(self),
r"/-/schema(\.(?P<format>json|md))?$", r"/-/schema(\.(?P<format>json|md))?$",
@ -2675,10 +2272,6 @@ class Datasette:
wrap_view(PatternPortfolioView, self), wrap_view(PatternPortfolioView, self),
r"/-/patterns$", r"/-/patterns$",
) )
add_route(
AutocompleteDebugView.as_view(self),
r"/-/debug/autocomplete$",
)
add_route( add_route(
wrap_view(database_download, self), wrap_view(database_download, self),
r"/(?P<database>[^\/\.]+)\.db$", r"/(?P<database>[^\/\.]+)\.db$",
@ -2688,58 +2281,14 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
) )
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$") add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
add_route(
DatabaseForeignKeyTargetsView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/foreign-key-targets$",
)
add_route(
QueryListView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
)
add_route(
QueryCreateAnalyzeView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/analyze$",
)
add_route(
QueryStoreView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/store$",
)
add_route(
ExecuteWriteAnalyzeView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write/analyze$",
)
add_route(
ExecuteWriteView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write$",
)
add_route( add_route(
DatabaseSchemaView.as_view(self), DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$", r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
) )
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
)
add_route( add_route(
wrap_view(QueryView, self), wrap_view(QueryView, self),
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
) )
add_route(
QueryDefinitionView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
)
add_route(
QueryEditView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/edit$",
)
add_route(
QueryUpdateView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
)
add_route(
QueryDeleteView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
)
add_route( add_route(
wrap_view(table_view, self), wrap_view(table_view, self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
@ -2756,26 +2305,10 @@ class Datasette:
TableUpsertView.as_view(self), TableUpsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
) )
add_route(
TableAlterView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/alter$",
)
add_route(
TableForeignKeySuggestionsView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/foreign-key-suggestions$",
)
add_route( add_route(
TableSetColumnTypeView.as_view(self), TableSetColumnTypeView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
) )
add_route(
TableFragmentView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
)
add_route(
TableAutocompleteView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/autocomplete$",
)
add_route( add_route(
TableDropView.as_view(self), TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
@ -2821,10 +2354,6 @@ class Datasette:
db, table_name, _ = await self.resolve_table(request) db, table_name, _ = await self.resolve_table(request)
pk_values = urlsafe_components(request.url_vars["pks"]) pk_values = urlsafe_components(request.url_vars["pks"])
sql, params, pks = await row_sql_params_pks(db, table_name, pk_values) sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)
if len(pk_values) != len(pks):
raise BadRequest(
"URL row identifier does not match the primary key for this table"
)
results = await db.execute(sql, params, truncate=True) results = await db.execute(sql, params, truncate=True)
row = results.first() row = results.first()
if row is None: if row is None:
@ -2866,16 +2395,7 @@ class DatasetteRouter:
if raw_path: if raw_path:
path = raw_path.decode("ascii") path = raw_path.decode("ascii")
path = path.partition("?")[0] path = path.partition("?")[0]
# Give each request a fresh permission check cache, so repeated
# datasette.allowed() checks within the request are memoized but
# results never persist beyond it
from datasette.permissions import _permission_check_cache
cache_token = _permission_check_cache.set({})
try:
return await self.route_path(scope, receive, send, path) return await self.route_path(scope, receive, send, path)
finally:
_permission_check_cache.reset(cache_token)
async def route_path(self, scope, receive, send, path): async def route_path(self, scope, receive, send, path):
# Strip off base_url if present before routing # Strip off base_url if present before routing
@ -2883,11 +2403,7 @@ class DatasetteRouter:
if base_url != "/" and path.startswith(base_url): if base_url != "/" and path.startswith(base_url):
path = "/" + path[len(base_url) :] path = "/" + path[len(base_url) :]
scope = dict(scope, route_path=path) scope = dict(scope, route_path=path)
request = Request( request = Request(scope, receive)
scope,
receive,
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
)
# Populate request_messages if ds_messages cookie is present # Populate request_messages if ds_messages cookie is present
try: try:
request._messages = self.ds.unsign( request._messages = self.ds.unsign(
@ -2907,24 +2423,13 @@ class DatasetteRouter:
# Handle authentication # Handle authentication
default_actor = scope.get("actor") or None default_actor = scope.get("actor") or None
actor = None actor = None
token_error = None
results = pm.hook.actor_from_request(datasette=self.ds, request=request) results = pm.hook.actor_from_request(datasette=self.ds, request=request)
for result in results: for result in results:
try:
result = await await_me_maybe(result) result = await await_me_maybe(result)
except TokenInvalid as ex:
# A presented token was recognized but rejected - fail the
# request with a 401 even if another credential is valid,
# but keep awaiting the remaining coroutines first
if token_error is None:
token_error = ex
continue
if result and actor is None: if result and actor is None:
actor = result actor = result
# Don't break — we must await all coroutines to avoid # Don't break — we must await all coroutines to avoid
# "coroutine was never awaited" warnings # "coroutine was never awaited" warnings
if token_error is not None:
return await self.handle_401(request, send, token_error)
scope_modifications["actor"] = actor or default_actor scope_modifications["actor"] = actor or default_actor
scope = dict(scope, **scope_modifications) scope = dict(scope, **scope_modifications)
@ -2956,15 +2461,6 @@ class DatasetteRouter:
except Exception as exception: except Exception as exception:
return await self.handle_exception(request, send, exception) return await self.handle_exception(request, send, exception)
async def handle_401(self, request, send, exception):
# A presented bearer token was recognized by a handler but rejected.
# Bearer tokens are API credentials, so this is always JSON.
headers = {"www-authenticate": 'Bearer error="invalid_token"'}
if self.ds.cors:
add_cors_headers(headers)
response = Response.error([str(exception)], 401, headers=headers)
await response.asgi_send(send)
async def handle_404(self, request, send, exception=None): async def handle_404(self, request, send, exception=None):
# If path contains % encoding, redirect to tilde encoding # If path contains % encoding, redirect to tilde encoding
if "%" in request.path: if "%" in request.path:
@ -3162,22 +2658,19 @@ def wrap_view_function(view_fn, datasette):
def permanent_redirect(path, forward_query_string=False, forward_rest=False): def permanent_redirect(path, forward_query_string=False, forward_rest=False):
def view(request, send): return wrap_view(
redirect_path = ( lambda request, send: Response.redirect(
path path
+ (request.url_vars["rest"] if forward_rest else "") + (request.url_vars["rest"] if forward_rest else "")
+ ( + (
("?" + request.query_string) ("?" + request.query_string)
if forward_query_string and request.query_string if forward_query_string and request.query_string
else "" else ""
),
status=301,
),
datasette=None,
) )
)
route_path = request.scope.get("route_path")
if route_path and request.path.endswith(route_path):
redirect_path = request.path[: -len(route_path)] + redirect_path
return Response.redirect(redirect_path, status=301)
return wrap_view(view, datasette=None)
_curly_re = re.compile(r"({.*?})") _curly_re = re.compile(r"({.*?})")

View file

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

View file

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

View file

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

View file

@ -48,32 +48,12 @@ def register_actions():
resource_class=DatabaseResource, resource_class=DatabaseResource,
also_requires="view-database", also_requires="view-database",
), ),
Action(
name="execute-write-sql",
abbr="ews",
description="Execute writable SQL queries",
resource_class=DatabaseResource,
also_requires="view-database",
),
Action( Action(
name="create-table", name="create-table",
abbr="ct", abbr="ct",
description="Create tables", description="Create tables",
resource_class=DatabaseResource, resource_class=DatabaseResource,
), ),
Action(
name="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) # Table-level actions (child-level)
Action( Action(
name="view-table", name="view-table",
@ -117,12 +97,6 @@ def register_actions():
description="Drop tables", description="Drop tables",
resource_class=TableResource, resource_class=TableResource,
), ),
Action(
name="drop-view",
abbr="dv",
description="Drop views",
resource_class=TableResource,
),
# Query-level actions (child-level) # Query-level actions (child-level)
Action( Action(
name="view-query", name="view-query",
@ -130,16 +104,4 @@ def register_actions():
description="View named query results", description="View named query results",
resource_class=QueryResource, resource_class=QueryResource,
), ),
Action(
name="update-query",
abbr="uq",
description="Update stored queries",
resource_class=QueryResource,
),
Action(
name="delete-query",
abbr="dq",
description="Delete stored queries",
resource_class=QueryResource,
),
) )

View file

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

View file

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

View file

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

View file

@ -17,6 +17,13 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
# Re-export all hooks and public utilities # Re-export all hooks and public utilities
from .restrictions import ( from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql, actor_restrictions_sql as actor_restrictions_sql,
@ -26,9 +33,16 @@ from .restrictions import (
from .root import root_user_permissions_sql as root_user_permissions_sql from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql from .config import config_permissions_sql as config_permissions_sql
from .defaults import ( from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check, default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql, default_action_permissions_sql as default_action_permissions_sql,
default_query_permissions_sql as default_query_permissions_sql,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS, DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
) )
@hookimpl
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
"""Return canned queries defined in datasette.yaml configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

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

View file

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

View file

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

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

View file

@ -1,19 +1,9 @@
from datasette import hookimpl, Response from datasette import hookimpl, Response
from .utils import add_cors_headers
@hookimpl(trylast=True) @hookimpl(trylast=True)
def forbidden(datasette, request, message): def forbidden(datasette, request, message):
async def inner(): 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( return Response.html(
await datasette.render_template( await datasette.render_template(
"error.html", "error.html",

View file

@ -1,5 +1,5 @@
from datasette import hookimpl, Response from datasette import hookimpl, Response
from .utils import add_cors_headers, error_body from .utils import add_cors_headers
from .utils.asgi import ( from .utils.asgi import (
Base400, Base400,
) )
@ -28,7 +28,6 @@ def handle_exception(datasette, request, exception):
rich.get_console().print_exception(show_locals=True) rich.get_console().print_exception(show_locals=True)
title = None title = None
plain_message = None
if isinstance(exception, Base400): if isinstance(exception, Base400):
status = exception.status status = exception.status
info = {} info = {}
@ -37,7 +36,6 @@ def handle_exception(datasette, request, exception):
status = exception.status status = exception.status
info = exception.error_dict info = exception.error_dict
message = exception.message message = exception.message
plain_message = exception.plain_message
if exception.message_is_html: if exception.message_is_html:
message = Markup(message) message = Markup(message)
title = exception.title title = exception.title
@ -47,13 +45,6 @@ def handle_exception(datasette, request, exception):
message = str(exception) message = str(exception)
traceback.print_exc() traceback.print_exc()
templates = [f"{status}.html", "error.html"] 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( info.update(
{ {
"ok": False, "ok": False,
@ -62,6 +53,12 @@ def handle_exception(datasette, request, exception):
"title": title, "title": title,
} }
) )
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) environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates) template = environment.select_template(templates)
return Response.html( return Response.html(
@ -69,6 +66,7 @@ def handle_exception(datasette, request, exception):
dict( dict(
info, info,
urls=datasette.urls, urls=datasette.urls,
app_css_hash=datasette.app_css_hash(),
menu_links=lambda: [], menu_links=lambda: [],
) )
), ),

View file

@ -137,6 +137,11 @@ def permission_resources_sql(datasette, actor, action):
""" """
@hookspec
def canned_queries(datasette, database, actor):
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
@hookspec @hookspec
def register_magic_parameters(datasette): def register_magic_parameters(datasette):
"""Return a list of (name, function) magic parameter functions""" """Return a list of (name, function) magic parameter functions"""
@ -159,32 +164,32 @@ def jump_items_sql(datasette, actor, request):
@hookspec @hookspec
def row_actions(datasette, actor, request, database, table, row): def row_actions(datasette, actor, request, database, table, row):
"""Items for the row actions menu""" """Links for the row actions menu"""
@hookspec @hookspec
def table_actions(datasette, actor, database, table, request): def table_actions(datasette, actor, database, table, request):
"""Items for the table actions menu""" """Links for the table actions menu"""
@hookspec @hookspec
def view_actions(datasette, actor, database, view, request): def view_actions(datasette, actor, database, view, request):
"""Items for the view actions menu""" """Links for the view actions menu"""
@hookspec @hookspec
def query_actions(datasette, actor, database, query_name, request, sql, params): def query_actions(datasette, actor, database, query_name, request, sql, params):
"""Items for the query and stored query actions menu""" """Links for the query and canned query actions menu"""
@hookspec @hookspec
def database_actions(datasette, actor, database, request): def database_actions(datasette, actor, database, request):
"""Items for the database actions menu""" """Links for the database actions menu"""
@hookspec @hookspec
def homepage_actions(datasette, actor, request): def homepage_actions(datasette, actor, request):
"""Items for the homepage actions menu""" """Links for the homepage actions menu"""
@hookspec @hookspec
@ -228,8 +233,8 @@ def top_query(datasette, request, database, sql):
@hookspec @hookspec
def top_stored_query(datasette, request, database, query_name): def top_canned_query(datasette, request, database, query_name):
"""HTML to include at the top of the stored query page""" """HTML to include at the top of the canned query page"""
@hookspec @hookspec

View file

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

View file

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

View file

@ -1,7 +1,5 @@
import json import json
from datasette.extras import extra_names_from_request
from datasette.utils import ( from datasette.utils import (
error_body,
value_as_boolean, value_as_boolean,
remove_infinites, remove_infinites,
CustomJSONEncoder, CustomJSONEncoder,
@ -53,7 +51,8 @@ def json_renderer(request, args, data, error, truncated=None):
if error: if error:
shape = "objects" shape = "objects"
status_code = 400 status_code = 400
data.update(error_body(error, status_code)) data["error"] = error
data["ok"] = False
if truncated is not None: if truncated is not None:
data["truncated"] = truncated data["truncated"] = truncated
@ -87,8 +86,7 @@ def json_renderer(request, args, data, error, truncated=None):
object_rows[pk_string] = row object_rows[pk_string] = row
data = object_rows data = object_rows
if shape_error: if shape_error:
status_code = 400 data = {"ok": False, "error": shape_error}
data = error_body(shape_error, status_code)
elif shape == "array": elif shape == "array":
data = data["rows"] 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"]] data["rows"] = [list(row.values()) for row in data["rows"]]
else: else:
status_code = 400 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 # Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136 # https://github.com/simonw/datasette/issues/2136
if isinstance(data, dict) and "columns" not in extra_names_from_request(request): if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
data.pop("columns", None) data.pop("columns", None)
# Handle _nl option for _shape=array # Handle _nl option for _shape=array

View file

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

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

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

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

View file

@ -1,5 +1,5 @@
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script> <script src="{{ base_url }}-/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/cm-editor-6.0.1.bundle.js"></script>
<style> <style>
.cm-editor { .cm-editor {
resize: both; 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"> <ul class="tight-bullets">
{% for facet_value in facet_info.results %} {% for facet_value in facet_info.results %}
{% if not facet_value.selected %} {% if not facet_value.selected %}
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span></li> <li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
{% else %} {% else %}
<li>{{ facet_value.label or "-" }} &middot; <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span> <a href="{{ facet_value.toggle_url }}" class="cross">&#x2716;</a></li> <li>{{ facet_value.label or "-" }} &middot; {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">&#x2716;</a></li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if facet_info.truncated %} {% if facet_info.truncated %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,13 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{% block title %}{% endblock %}</title> <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"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% for url in extra_css_urls %} {% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}> <link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %} {% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script> <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 %} {% 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> <script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
{% endfor %} {% endfor %}
@ -70,7 +70,7 @@
{% endfor %} {% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %} {% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ static('navigation-search.js') }}" defer></script> <script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search> <navigation-search url="/-/jump"></navigation-search>
</body> </body>
</html> </html>

View file

@ -5,11 +5,6 @@
{% block extra_head %} {% block extra_head %}
{{- super() -}} {{- super() -}}
{% include "_codemirror.html" %} {% 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 %} {% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% 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 %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if allow_execute_sql %} {% if allow_execute_sql %}
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters"> <form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
<h3>Custom SQL query</h3> <h3>Custom SQL query</h3>
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p> <p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
{% set parameter_names = [] %}
{% set parameter_values = {} %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p> <p>
<button id="sql-format" type="button" hidden>Format SQL</button> <button id="sql-format" type="button" hidden>Format SQL</button>
<input type="submit" value="Run SQL"> <input type="submit" value="Run SQL">
@ -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> <li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if queries_more %}
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
{% endif %}
{% endif %} {% endif %}
{% if tables %} {% if tables %}
@ -76,7 +64,7 @@
<div class="db-table"> <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> <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><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> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -99,11 +87,5 @@
{% endif %} {% endif %}
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %} {% endblock %}

View file

@ -9,7 +9,7 @@
{% include "_permissions_debug_tabs.html" %} {% include "_permissions_debug_tabs.html" %}
<p style="margin-bottom: 2em;"> <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. Actions are used by the permission system to control access to different features.
</p> </p>
@ -26,7 +26,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for action in data.actions %} {% for action in data %}
<tr> <tr>
<td><strong>{{ action.name }}</strong></td> <td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td> <td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>

View file

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

@ -3,7 +3,7 @@
{% block title %}Permission Check{% endblock %} {% block title %}Permission Check{% endblock %}
{% block extra_head %} {% 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 "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %} {% include "_debug_common_functions.html" %}
<style> <style>

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,12 @@
{% extends "base.html" %} {% 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 %} {% block extra_head %}
{{- super() -}} {{- super() -}}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script> <script src="{{ urls.static('column-chooser.js') }}" defer></script>
<script src="{{ static('column-chooser.js') }}" defer></script> <script src="{{ urls.static('table.js') }}" defer></script>
{% if table_page_data.foreignKeys %} <script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<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>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script> <script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style> <style>
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {
@ -48,19 +43,19 @@
{% if count or human_description_en %} {% if count or human_description_en %}
<h3> <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 %} {% 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 %} {% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
{% if human_description_en %}{{ human_description_en }}{% endif %} {% if human_description_en %}{{ human_description_en }}{% endif %}
</h3> </h3>
{% endif %} {% 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 %} {% if supports_search %}
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div> <div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %} {% endif %}
{% for column, lookup, value in filters.selections() %} {% for column, lookup, value in filters.selections() %}
<div class="filter-row filter-controls-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
<select name="_filter_column_{{ loop.index }}"> <select name="_filter_column_{{ loop.index }}">
<option value="">- remove filter -</option> <option value="">- remove filter -</option>
@ -77,7 +72,7 @@
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}"> </div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
</div> </div>
{% endfor %} {% endfor %}
<div class="filter-row filter-controls-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
<select name="_filter_column"> <select name="_filter_column">
<option value="">- column -</option> <option value="">- column -</option>
@ -93,9 +88,9 @@
</select> </select>
</div><input type="text" name="_filter_value" class="filter-value"> </div><input type="text" name="_filter_value" class="filter-value">
</div> </div>
<div class="filter-row filter-actions-row"> <div class="filter-row">
{% if is_sortable %} {% if is_sortable %}
<div class="select-wrapper"> <div class="select-wrapper small-screen-only">
<select name="_sort" id="sort_by"> <select name="_sort" id="sort_by">
<option value="">Sort...</option> <option value="">Sort...</option>
{% for column in display_columns %} {% for column in display_columns %}
@ -105,12 +100,12 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </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 %} {% endif %}
{% for key, value in form_hidden_args %} {% for key, value in form_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}"> <input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %} {% endfor %}
<input type="submit" value="Apply filters" tabindex="0"> <input type="submit" value="Apply">
</div> </div>
</form> </form>
@ -163,19 +158,6 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
</script> </script>
{% endif %} {% endif %}
{% if table_insert_ui %}
<div class="table-row-toolbar">
<button type="button" class="core table-insert-row" data-table-action="insert-row">
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M8 12h8"></path>
<path d="M12 8v8"></path>
</svg>
<span>Insert row</span>
</button>
</div>
{% endif %}
{% include custom_table_templates %} {% include custom_table_templates %}
{% if next_url %} {% if next_url %}

View file

@ -18,21 +18,6 @@ if TYPE_CHECKING:
from datasette.app import Datasette 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 @dataclasses.dataclass
class TokenRestrictions: class TokenRestrictions:
""" """
@ -123,12 +108,8 @@ class TokenHandler:
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]: async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
""" """
Verify a token and return an actor dict. Verify a token and return an actor dict, or None if this handler
does not recognize the token.
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.
""" """
raise NotImplementedError raise NotImplementedError
@ -166,32 +147,29 @@ class SignedTokenHandler(TokenHandler):
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]: async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
prefix = "dstok_" prefix = "dstok_"
if not token.startswith(prefix): if not datasette.setting("allow_signed_tokens"):
# Not one of our tokens - leave it for other handlers
return None 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") max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
if not token.startswith(prefix):
return None
raw = token[len(prefix) :] raw = token[len(prefix) :]
try: try:
decoded = datasette.unsign(raw, namespace="token") decoded = datasette.unsign(raw, namespace="token")
except itsdangerous.BadSignature: except itsdangerous.BadSignature:
raise TokenInvalid("Invalid token signature") return None
if "t" not in decoded: if "t" not in decoded:
raise TokenInvalid("Invalid token: no timestamp") return None
created = decoded["t"] created = decoded["t"]
if not isinstance(created, int): if not isinstance(created, int):
raise TokenInvalid("Invalid token: invalid timestamp") return None
duration = decoded.get("d") duration = decoded.get("d")
if duration is not None and not isinstance(duration, int): 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 ( if (duration is None and max_signed_tokens_ttl) or (
duration is not None duration is not None
@ -202,7 +180,7 @@ class SignedTokenHandler(TokenHandler):
if duration: if duration:
if time.time() - created > duration: if time.time() - created > duration:
raise TokenInvalid("Token has expired") return None
actor = {"id": decoded["a"], "token": "dstok"} actor = {"id": decoded["a"], "token": "dstok"}

View file

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

View file

@ -1,5 +1,4 @@
import asyncio import asyncio
import binascii
from contextlib import contextmanager from contextlib import contextmanager
import aiofiles import aiofiles
import click import click
@ -225,35 +224,17 @@ def compound_keys_after_sql(pks, start_index=0):
return "({})".format("\n or\n".join(or_clauses)) return "({})".format("\n or\n".join(or_clauses))
@documented
class CustomJSONEncoder(json.JSONEncoder): 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): def default(self, obj):
if isinstance(obj, sqlite3.Row): if isinstance(obj, sqlite3.Row):
return tuple(obj) return tuple(obj)
if isinstance(obj, sqlite3.Cursor): if isinstance(obj, sqlite3.Cursor):
return list(obj) return list(obj)
if isinstance(obj, bytes): if isinstance(obj, bytes):
# Does it encode to utf8?
try:
return obj.decode("utf8")
except UnicodeDecodeError:
return { return {
"$base64": True, "$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"), "encoded": base64.b64encode(obj).decode("latin1"),
@ -261,35 +242,6 @@ class CustomJSONEncoder(json.JSONEncoder):
return json.JSONEncoder.default(self, obj) 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 @contextmanager
def sqlite_timelimit(conn, ms): def sqlite_timelimit(conn, ms):
deadline = time.perf_counter() + (ms / 1000) deadline = time.perf_counter() + (ms / 1000)
@ -458,7 +410,8 @@ def escape_css_string(s):
def escape_sqlite(s): def escape_sqlite(s):
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words): if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
return s return s
return '"{}"'.format(s.replace('"', '""')) else:
return f"[{s}]"
def make_dockerfile( def make_dockerfile(
@ -884,8 +837,7 @@ def path_with_format(
*, request=None, path=None, format=None, extra_qs=None, replace_format=None *, request=None, path=None, format=None, extra_qs=None, replace_format=None
): ):
qs = extra_qs or {} qs = extra_qs or {}
if path is None and request: path = request.path if request else path
path = request.path
if replace_format and path.endswith(f".{replace_format}"): if replace_format and path.endswith(f".{replace_format}"):
path = path[: -(1 + len(replace_format))] path = path[: -(1 + len(replace_format))]
if "." in path: if "." in path:
@ -1288,20 +1240,10 @@ class StartupError(Exception):
pass pass
# Comments and string literals, matched in a single pass so that whichever _single_line_comment_re = re.compile(r"--.*")
# construct starts first "wins" - this ensures a comment marker inside a string _multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
# literal (or a quote inside a comment) does not confuse the parameter scan. _single_quote_re = re.compile(r"'(?:''|[^'])*'")
_comments_and_strings_re = re.compile( _double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
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,
)
_named_param_re = re.compile(r":(\w+)") _named_param_re = re.compile(r":(\w+)")
@ -1312,9 +1254,10 @@ def named_parameters(sql: str) -> List[str]:
e.g. for ``select * from foo where id=:id`` this would return ``["id"]`` e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
""" """
# Strip comments and string literals first so that any ":name" sequences sql = _single_line_comment_re.sub("", sql)
# inside them are not mistaken for named parameters sql = _multi_line_comment_re.sub("", sql)
sql = _comments_and_strings_re.sub("", sql) sql = _single_quote_re.sub("", sql)
sql = _double_quote_re.sub("", sql)
# Extract parameters from what is left # Extract parameters from what is left
return _named_param_re.findall(sql) return _named_param_re.findall(sql)
@ -1327,54 +1270,6 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
return named_parameters(sql) 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): def add_cors_headers(headers):
headers["Access-Control-Allow-Origin"] = "*" headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type" headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
@ -1648,17 +1543,6 @@ def md5_not_usedforsecurity(s):
_etag_cache = {} _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): async def calculate_etag(filepath, chunk_size=4096):
if filepath in _etag_cache: if filepath in _etag_cache:
return _etag_cache[filepath] return _etag_cache[filepath]

View file

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

View file

@ -1,6 +1,6 @@
import json import json
from typing import Optional from typing import Optional
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file from datasette.utils import MultiParams, calculate_etag
from datasette.utils.multipart import ( from datasette.utils.multipart import (
parse_form_data, parse_form_data,
MultipartParseError, MultipartParseError,
@ -67,25 +67,13 @@ class BadRequest(Base400):
status = 400 status = 400
class PayloadTooLarge(Base400):
status = 413
SAMESITE_VALUES = ("strict", "lax", "none") SAMESITE_VALUES = ("strict", "lax", "none")
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
# while these bodies are held in RAM and json.loads() can multiply their
# footprint several times over.
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
class Request: class Request:
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES): def __init__(self, scope, receive):
self.scope = scope self.scope = scope
self.receive = receive self.receive = receive
self.max_post_body_bytes = max_post_body_bytes
def __repr__(self): def __repr__(self):
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url) return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
@ -153,52 +141,20 @@ class Request:
def actor(self): def actor(self):
return self.scope.get("actor", None) return self.scope.get("actor", None)
async def post_body(self, max_bytes=None): async def post_body(self):
""" body = b""
Read the request body fully into memory.
The body is capped at max_bytes - or self.max_post_body_bytes
(default 2MB, set from the max_post_body_bytes setting for requests
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
oversized bodies are rejected as soon as the limit is passed, without
buffering the rest.
"""
if max_bytes is None:
max_bytes = self.max_post_body_bytes
too_large = PayloadTooLarge(
"Request body exceeded maximum size of {} bytes".format(max_bytes)
)
if max_bytes:
# Reject early if the client declares an oversized body
try:
if int(self.headers.get("content-length", "")) > max_bytes:
raise too_large
except ValueError:
# Missing or malformed - the streaming check below still applies
pass
chunks = []
received = 0
more_body = True more_body = True
while more_body: while more_body:
message = await self.receive() message = await self.receive()
assert message["type"] == "http.request", message assert message["type"] == "http.request", message
chunk = message.get("body", b"") body += message.get("body", b"")
received += len(chunk)
if max_bytes and received > max_bytes:
raise too_large
chunks.append(chunk)
more_body = message.get("more_body", False) more_body = message.get("more_body", False)
return b"".join(chunks) return body
async def post_vars(self): async def post_vars(self):
body = await self.post_body() body = await self.post_body()
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True)) return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
async def json(self):
body = await self.post_body()
return json.loads(body)
async def form( async def form(
self, self,
files: bool = False, files: bool = False,
@ -374,11 +330,9 @@ async def asgi_send_html(send, html, status=200, headers=None):
async def asgi_send_redirect(send, location, status=302): async def asgi_send_redirect(send, location, status=302):
# Prevent open redirect vulnerability: collapse leading slashes and # Prevent open redirect vulnerability: strip multiple leading slashes
# backslashes down to a single slash. //example.com is a protocol-relative # //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)
# URL, and browsers normalise backslashes to slashes so /\example.com would location = re.sub(r"^/+", "/", location)
# be treated as //example.com - https://github.com/simonw/datasette/issues/2680
location = re.sub(r"^[/\\]+", "/", location)
await asgi_send( await asgi_send(
send, send,
"", "",
@ -437,9 +391,6 @@ async def asgi_send_file(
) )
HASHED_STATIC_CACHE_CONTROL = "max-age=31536000, immutable, public"
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None): def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
root_path = Path(root_path) root_path = Path(root_path)
static_headers = {} static_headers = {}
@ -466,17 +417,11 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
return return
try: try:
# Calculate ETag for filepath # Calculate ETag for filepath
hash_value = request.args.get("_hash")
if (
hash_value
and hash_value == sha256_file(full_path, chunk_size=chunk_size)[:12]
):
headers["Cache-Control"] = HASHED_STATIC_CACHE_CONTROL
etag = await calculate_etag(full_path, chunk_size=chunk_size) etag = await calculate_etag(full_path, chunk_size=chunk_size)
headers["ETag"] = etag headers["ETag"] = etag
if_none_match = request.headers.get("if-none-match") if_none_match = request.headers.get("if-none-match")
if if_none_match and if_none_match == etag: if if_none_match and if_none_match == etag:
return await asgi_send(send, "", 304, headers=headers) return await asgi_send(send, "", 304)
await asgi_send_file( await asgi_send_file(
send, full_path, chunk_size=chunk_size, headers=headers send, full_path, chunk_size=chunk_size, headers=headers
) )
@ -575,18 +520,6 @@ class Response:
content_type="application/json; charset=utf-8", content_type="application/json; charset=utf-8",
) )
@classmethod
def error(cls, messages, status=400, headers=None):
"""
A JSON error response using Datasette's standard error format.
messages can be a single string or a list of strings. For errors
that should content-negotiate between JSON and HTML, raise
Forbidden, NotFound, BadRequest or DatasetteError instead and let
Datasette's error handling hooks build the response.
"""
return cls.json(error_body(messages, status), status=status, headers=headers)
@classmethod @classmethod
def redirect(cls, path, status=302, headers=None): def redirect(cls, path, status=302, headers=None):
headers = headers or {} headers = headers or {}

View file

@ -1,30 +1,9 @@
import textwrap import textwrap
from sqlite_utils import Database as SQLiteUtilsDatabase
from sqlite_utils import Migrations
from datasette.utils import table_column_details from datasette.utils import table_column_details
INTERNAL_DB_SCHEMA_TABLES = {
"catalog_databases",
"catalog_tables",
"catalog_views",
"catalog_columns",
"catalog_indexes",
"catalog_foreign_keys",
"metadata_instance",
"metadata_databases",
"metadata_resources",
"metadata_columns",
"column_types",
"queries",
}
INTERNAL_DB_SCHEMA_INDEXES = { async def init_internal_db(db):
"queries_owner_idx", create_tables_sql = textwrap.dedent("""
}
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
CREATE TABLE IF NOT EXISTS catalog_databases ( CREATE TABLE IF NOT EXISTS catalog_databases (
database_name TEXT PRIMARY KEY, database_name TEXT PRIMARY KEY,
path TEXT, path TEXT,
@ -88,7 +67,13 @@ INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name), FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name) FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
); );
""").strip()
await db.execute_write_script(create_tables_sql)
await initialize_metadata_tables(db)
async def initialize_metadata_tables(db):
await db.execute_write_script(textwrap.dedent("""
CREATE TABLE IF NOT EXISTS metadata_instance ( CREATE TABLE IF NOT EXISTS metadata_instance (
key text, key text,
value text, value text,
@ -127,57 +112,7 @@ INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
config TEXT, config TEXT,
PRIMARY KEY (database_name, resource_name, column_name) PRIMARY KEY (database_name, resource_name, column_name)
); );
"""))
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
""").strip()
internal_migrations = Migrations("datasette_internal")
def _internal_schema_exists(db):
table_names = set(db.table_names())
if not INTERNAL_DB_SCHEMA_TABLES.issubset(table_names):
return False
index_names = {
row[0]
for row in db.execute("select name from sqlite_master where type = 'index'")
}
return INTERNAL_DB_SCHEMA_INDEXES.issubset(index_names)
@internal_migrations(name="0001_initial")
def initial_internal_schema(db):
if _internal_schema_exists(db):
return
db.executescript(INTERNAL_DB_SCHEMA_SQL)
async def init_internal_db(db):
def apply_migrations(conn):
internal_migrations.apply(SQLiteUtilsDatabase(conn, execute_plugins=False))
await db.execute_write_fn(apply_migrations, transaction=False)
async def populate_schema_tables(internal_db, db): async def populate_schema_tables(internal_db, db):

View file

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

View file

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

View file

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

View file

@ -1,89 +1,2 @@
from dataclasses import dataclass
import dataclasses
import types
import typing
@dataclass(frozen=True)
class ContextField:
name: str
type_name: str
help: str
from_extra: bool = False
def _type_name(type_):
if type_ is type(None):
return "None"
origin = typing.get_origin(type_)
args = typing.get_args(type_)
if origin in (typing.Union, types.UnionType):
return " | ".join(_type_name(arg) for arg in args)
if origin is not None:
name = getattr(origin, "__name__", str(origin).removeprefix("typing."))
return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args))
return getattr(type_, "__name__", str(type_).removeprefix("typing."))
def from_extra():
"""
Declare a Context dataclass field whose value comes from a registered
Extra of the same name - its documentation is the Extra description,
so the doc string lives next to the resolve() code rather than being
duplicated on the dataclass.
"""
return dataclasses.field(metadata={"from_extra": True})
class Context: class Context:
"Base class for all documented contexts" "Base class for all documented contexts"
# Set on subclasses whose from_extra() fields should be resolved
# against the extras registry for this scope
extras_scope = None
@classmethod
def documented_fields(cls):
"List of ContextField describing the documented fields of this context"
documented = []
for f in dataclasses.fields(cls):
if f.name.startswith("_"):
continue
is_from_extra = bool(f.metadata.get("from_extra"))
if is_from_extra:
help_text = cls._extra_description(f.name)
else:
help_text = f.metadata.get("help", "")
documented.append(
ContextField(
name=f.name,
type_name=_type_name(f.type),
help=help_text,
from_extra=is_from_extra,
)
)
return documented
@classmethod
def _extra_description(cls, name):
# Imported lazily - table_extras is not needed just to define
# Context subclasses
from datasette.views.table_extras import table_extra_registry
try:
extra_class = table_extra_registry.classes_by_name[name]
except KeyError:
raise KeyError(
"{}.{} is declared with from_extra() but there is no "
"registered extra of that name".format(cls.__name__, name)
)
if cls.extras_scope is not None and not extra_class.available_for(
cls.extras_scope
):
raise ValueError(
"{}.{} is declared with from_extra() but the {} extra is "
"not available for scope {}".format(
cls.__name__, name, name, cls.extras_scope
)
)
return extra_class.description or ""

View file

@ -1,19 +1,31 @@
import asyncio
import csv import csv
import hashlib import hashlib
import sys import sys
import textwrap
import time
import urllib
from markupsafe import escape
from datasette.database import QueryInterrupted
from datasette.utils.asgi import Request from datasette.utils.asgi import Request
from datasette.utils import ( from datasette.utils import (
add_cors_headers, add_cors_headers,
await_me_maybe,
EscapeHtmlWriter, EscapeHtmlWriter,
InvalidSql, InvalidSql,
LimitedWriter, LimitedWriter,
call_with_supported_arguments,
path_from_row_pks, path_from_row_pks,
path_with_added_args,
path_with_removed_args,
path_with_format, path_with_format,
sqlite3, sqlite3,
) )
from datasette.utils.asgi import ( from datasette.utils.asgi import (
AsgiStream, AsgiStream,
NotFound,
Response, Response,
BadRequest, BadRequest,
) )
@ -28,15 +40,12 @@ class DatasetteError(Exception):
status=500, status=500,
template=None, template=None,
message_is_html=False, message_is_html=False,
plain_message=None,
): ):
self.message = message self.message = message
self.title = title self.title = title
self.error_dict = error_dict or {} self.error_dict = error_dict or {}
self.status = status self.status = status
self.message_is_html = message_is_html self.message_is_html = message_is_html
# Plain text used for JSON error responses when message is HTML
self.plain_message = plain_message
class View: class View:
@ -52,7 +61,9 @@ class View:
request.path.endswith(".json") request.path.endswith(".json")
or request.headers.get("content-type") == "application/json" or request.headers.get("content-type") == "application/json"
): ):
response = Response.error("Method not allowed", 405) response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
else: else:
response = Response.text("Method not allowed", status=405) response = Response.text("Method not allowed", status=405)
return response return response
@ -91,7 +102,9 @@ class BaseView:
request.path.endswith(".json") request.path.endswith(".json")
or request.headers.get("content-type") == "application/json" or request.headers.get("content-type") == "application/json"
): ):
response = Response.error("Method not allowed", 405) response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
else: else:
response = Response.text("Method not allowed", status=405) response = Response.text("Method not allowed", status=405)
return response return response
@ -140,13 +153,7 @@ class BaseView:
if self.has_json_alternate: if self.has_json_alternate:
alternate_url_json = self.ds.absolute_url( alternate_url_json = self.ds.absolute_url(
request, request,
self.ds.urls.path( self.ds.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
template_context["alternate_url_json"] = alternate_url_json template_context["alternate_url_json"] = alternate_url_json
headers.update( headers.update(
@ -179,6 +186,223 @@ class BaseView:
return view return view
class DataView(BaseView):
name = ""
def redirect(self, request, path, forward_querystring=True, remove_args=None):
if request.query_string and "?" not in path and forward_querystring:
path = f"{path}?{request.query_string}"
if remove_args:
path = path_with_removed_args(request, remove_args, path=path)
r = Response.redirect(path)
r.headers["Link"] = f"<{path}>; rel=preload"
if self.ds.cors:
add_cors_headers(r.headers)
return r
async def data(self, request):
raise NotImplementedError
async def as_csv(self, request, database):
return await stream_csv(self.ds, self.data, request, database)
async def get(self, request):
db = await self.ds.resolve_database(request)
database = db.name
database_route = db.route
_format = request.url_vars["format"]
data_kwargs = {}
if _format == "csv":
return await self.as_csv(request, database_route)
if _format is None:
# HTML views default to expanding all foreign key labels
data_kwargs["default_labels"] = True
extra_template_data = {}
start = time.perf_counter()
status_code = None
templates = []
try:
response_or_template_contexts = await self.data(request, **data_kwargs)
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
# If it has four items, it includes an HTTP status code
if len(response_or_template_contexts) == 4:
(
data,
extra_template_data,
templates,
status_code,
) = response_or_template_contexts
else:
data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""".format(escape(ex.sql))).strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError:
raise
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
# Special case for .jsono extension - redirect to _shape=objects
if _format == "jsono":
return self.redirect(
request,
path_with_added_args(
request,
{"_shape": "objects"},
path=request.path.rsplit(".jsono", 1)[0] + ".json",
),
forward_querystring=False,
)
if _format in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
self.ds.renderers[_format][0],
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
truncated=False, # TODO: support this
error=data.get("error"),
# These will be deprecated in Datasette 1.0:
args=request.args,
data=data,
)
if asyncio.iscoroutine(result):
result = await result
if result is None:
raise NotFound("No data")
if isinstance(result, dict):
r = Response(
body=result.get("body"),
status=result.get("status_code", status_code or 200),
content_type=result.get("content_type", "text/plain"),
headers=result.get("headers"),
)
elif isinstance(result, Response):
r = result
if status_code is not None:
# Over-ride the status code
r.status = status_code
else:
assert False, f"{result} should be dict or Response"
else:
extras = {}
if callable(extra_template_data):
extras = extra_template_data()
if asyncio.iscoroutine(extras):
extras = await extras
else:
extras = extra_template_data
url_labels_extra = {}
if data.get("expandable_columns"):
url_labels_extra = {"_labels": "on"}
renderers = {}
for key, (_, can_render) in self.ds.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = self.ds.urls.path(
path_with_format(
request=request, format=key, extra_qs={**url_labels_extra}
)
)
url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path(
path_with_format(request=request, format="csv", extra_qs=url_csv_args)
)
url_csv_path = url_csv.split("?")[0]
context = {
**data,
**extras,
**{
"renderers": renderers,
"url_csv": url_csv,
"url_csv_path": url_csv_path,
"url_csv_hidden_args": [
(key, value)
for key, value in urllib.parse.parse_qsl(request.query_string)
if key not in ("_labels", "_facet", "_size")
]
+ [("_size", "max")],
"settings": self.ds.settings_dict(),
},
}
if "metadata" not in context:
context["metadata"] = await self.ds.get_instance_metadata()
r = await self.render(templates, request=request, context=context)
if status_code is not None:
r.status = status_code
ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(r, ttl)
def set_response_headers(self, response, ttl):
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
return response
def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)
async def stream_csv(datasette, fetch_data, request, database): async def stream_csv(datasette, fetch_data, request, database):
kwargs = {} kwargs = {}
stream = request.args.get("_stream") stream = request.args.get("_stream")

File diff suppressed because it is too large Load diff

View file

@ -1,505 +0,0 @@
import re
from urllib.parse import urlencode
from datasette.resources import DatabaseResource
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
from datasette.utils.asgi import Response
from .base import BaseView
from .database import display_rows as display_query_rows
from .query_helpers import (
QueryValidationError,
SQL_PARAMETER_FORM_PREFIX,
_analysis_is_write,
_analysis_rows,
_analysis_rows_with_permissions,
_block_framing,
_coerce_execute_write_payload,
_derived_query_parameters,
_execute_write_analysis_data,
_execute_write_disabled_reason,
_inserted_row_url,
_json_or_form_payload,
_prepare_execute_write,
_table_columns,
_wants_json,
)
WRITE_TEMPLATE_LABELS = {
"insert": "Insert row",
"update": "Update rows",
"delete": "Delete rows",
}
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
(
"create table new_table (",
" id integer primary key,",
" name text",
" -- created text default (datetime('now'))",
")",
)
)
def _parameter_names(columns):
seen = set()
names = {}
for column in columns:
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
base = base.strip("_") or "value"
if base[0].isdigit():
base = "p_{}".format(base)
name = base
index = 2
while name in seen:
name = "{}_{}".format(base, index)
index += 1
seen.add(name)
names[column] = name
return names
def _quote_identifier(identifier):
return '"{}"'.format(identifier.replace('"', '""'))
def _preferred_where_column(table, columns):
lower_table_id = "{}_id".format(table.lower())
return (
next((column for column in columns if column.lower() == "id"), None)
or next(
(column for column in columns if column.lower() == lower_table_id), None
)
or columns[0]
)
def _auto_incrementing_primary_key(columns):
primary_keys = [column for column in columns if column.is_pk]
if len(primary_keys) != 1:
return None
primary_key = primary_keys[0]
if primary_key.type and primary_key.type.lower() == "integer":
return primary_key.name
return None
def _insert_template_sql(table, columns):
column_names = [column.name for column in columns]
auto_pk = _auto_incrementing_primary_key(columns)
insert_columns = [column for column in column_names if column != auto_pk]
if not insert_columns:
return "insert into {}\ndefault values".format(_quote_identifier(table))
names = _parameter_names(insert_columns)
return "\n".join(
(
"insert into {} (".format(_quote_identifier(table)),
",\n".join(
" {}".format(_quote_identifier(column)) for column in insert_columns
),
")",
"values (",
",\n".join(" :{}".format(names[column]) for column in insert_columns),
")",
)
)
def _update_template_sql(table, columns):
column_names = [column.name for column in columns]
names = _parameter_names(column_names)
where_column = _preferred_where_column(table, column_names)
set_columns = [column for column in column_names if column != where_column]
if not set_columns:
return "\n".join(
(
"update {}".format(_quote_identifier(table)),
"set {} = :new_{}".format(
_quote_identifier(where_column), names[where_column]
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
return "\n".join(
(
"update {}".format(_quote_identifier(table)),
"set "
+ ",\n".join(
"{}{} = :{}".format(
" " if index else "",
_quote_identifier(column),
names[column],
)
for index, column in enumerate(set_columns)
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
def _delete_template_sql(table, columns):
column_names = [column.name for column in columns]
names = _parameter_names(column_names)
where_column = _preferred_where_column(table, column_names)
return "\n".join(
(
"delete from {}".format(_quote_identifier(table)),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
def _template_sqls_for_table(table, columns):
return {
"insert": _insert_template_sql(table, columns),
"update": _update_template_sql(table, columns),
"delete": _delete_template_sql(table, columns),
}
async def _template_sql_allowed(datasette, db, sql, actor):
params = {parameter: "" for parameter in _derived_query_parameters(sql)}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError:
return False
if not _analysis_is_write(analysis):
return False
analysis_rows = await _analysis_rows_with_permissions(datasette, analysis, actor)
return _execute_write_disabled_reason(sql, None, analysis_rows) is None
async def _write_template_tables(
datasette, db, table_columns, hidden_table_names, actor
):
write_template_tables = {}
for table in table_columns:
if table in hidden_table_names or not table_columns[table]:
continue
column_details = [
column
for column in await db.table_column_details(table)
if not column.hidden
]
if not column_details:
continue
templates = {}
for operation, sql in _template_sqls_for_table(table, column_details).items():
if await _template_sql_allowed(datasette, db, sql, actor):
templates[operation] = sql
if templates:
write_template_tables[table] = {
"templates": templates,
}
return write_template_tables
def _write_template_operations(write_template_tables):
operations = []
for operation in WRITE_TEMPLATE_OPERATIONS:
if any(
operation in table["templates"] for table in write_template_tables.values()
):
operations.append(
{
"name": operation,
"label": WRITE_TEMPLATE_LABELS[operation],
}
)
return operations
async def _create_table_template_sql(datasette, db, actor):
if await datasette.allowed(
action="create-table",
resource=DatabaseResource(db.name),
actor=actor,
):
return CREATE_TABLE_TEMPLATE_SQL
return None
def _analysis_changes_schema(analysis):
return any(
operation.operation in {"create", "alter", "drop"}
for operation in analysis.operations
)
class ExecuteWriteView(BaseView):
name = "execute-write"
has_json_alternate = False
async def _render_form(
self,
request,
db,
*,
sql="",
parameter_values=None,
analysis=None,
analysis_error=None,
execution_message=None,
execution_links=None,
execution_ok=None,
execute_write_returns_rows=False,
execute_write_columns=None,
execute_write_display_rows=None,
execute_write_truncated=False,
status=200,
):
parameter_values = parameter_values or {}
execution_links = execution_links or []
execute_write_columns = execute_write_columns or []
execute_write_display_rows = execute_write_display_rows or []
parameter_names = []
analysis_rows = []
table_columns = await _table_columns(self.ds, db.name)
hidden_table_names = set(await db.hidden_table_names())
write_template_tables = await _write_template_tables(
self.ds, db, table_columns, hidden_table_names, request.actor
)
write_template_operations = _write_template_operations(write_template_tables)
write_create_table_template_sql = await _create_table_template_sql(
self.ds, db, request.actor
)
if sql and analysis_error is None:
try:
parameter_names = _derived_query_parameters(sql)
if analysis is None:
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
self.ds, analysis, request.actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
allow_save_query = await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
) and await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
)
save_query_base_url = None
save_query_url = None
execute_disabled_reason = _execute_write_disabled_reason(
sql, analysis_error, analysis_rows
)
if allow_save_query:
save_query_base_url = self.ds.urls.database(db.name) + "/-/queries/store"
if not execute_disabled_reason:
save_query_url = save_query_base_url + "?" + urlencode({"sql": sql})
response = await self.render(
["execute_write.html"],
request,
{
"database": db.name,
"database_color": db.color,
"sql": sql,
"parameter_names": parameter_names,
"parameter_values": parameter_values,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"execution_message": execution_message,
"execution_links": execution_links,
"execution_ok": execution_ok,
"execute_write_returns_rows": execute_write_returns_rows,
"execute_write_columns": execute_write_columns,
"execute_write_display_rows": execute_write_display_rows,
"execute_write_truncated": execute_write_truncated,
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
"table_columns": table_columns,
"write_template_tables": write_template_tables,
"write_template_operations": write_template_operations,
"write_create_table_template_sql": write_create_table_template_sql,
"save_query_url": save_query_url,
"save_query_base_url": save_query_base_url,
},
)
response.status = status
return _block_framing(response)
async def get(self, request):
db = await self.ds.resolve_database(request)
await self.ds.ensure_permission(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
)
if not db.is_mutable:
return _block_framing(
Response.error(
["Cannot execute write SQL because this database is immutable."],
403,
)
)
return await self._render_form(
request,
db,
sql=request.args.get("sql") or "",
)
async def post(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-write-sql"], 403)
)
if not db.is_mutable:
return _block_framing(Response.error(["Database is immutable"], 403))
data = {}
is_json = request.headers.get("content-type", "").startswith("application/json")
sql = ""
provided_params = {}
try:
data, is_json = await _json_or_form_payload(request)
sql, provided_params = _coerce_execute_write_payload(data, is_json)
parameter_names, params, analysis = await _prepare_execute_write(
self.ds, db, sql, provided_params, request.actor
)
except QueryValidationError as ex:
if _wants_json(request, is_json, data):
return _block_framing(Response.error([ex.message], ex.status))
if ex.flash:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
request,
db,
sql=sql or "",
parameter_values=provided_params,
analysis_error=None if ex.flash else ex.message,
execution_message=None if ex.flash else ex.message,
execution_ok=False,
status=ex.status,
)
wants_json = _wants_json(request, is_json, data)
try:
execute_write_kwargs = {"request": request}
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
except sqlite3.DatabaseError as ex:
message = str(ex)
if wants_json:
return _block_framing(Response.error([message], 400))
return await self._render_form(
request,
db,
sql=sql,
parameter_values=params,
analysis=analysis,
execution_message=message,
execution_ok=False,
status=400,
)
if _analysis_changes_schema(analysis):
await self.ds.refresh_schemas(force=True)
if cursor.rowcount == -1:
message = "Query executed"
else:
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
if wants_json:
data = {
"ok": True,
"message": message,
"rowcount": cursor.rowcount,
"rows": [],
"truncated": False,
"analysis": _analysis_rows(analysis),
}
if cursor.description is not None:
data["rows"] = [dict(row) for row in cursor.fetchall()]
data["truncated"] = cursor.truncated
return _block_framing(Response.json(data))
inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor)
execution_links = (
[{"href": inserted_row_url, "label": "View row"}]
if inserted_row_url
else []
)
execute_write_returns_rows = cursor.description is not None
execute_write_columns = []
execute_write_display_rows = []
if execute_write_returns_rows:
execute_write_columns = [
description[0] for description in cursor.description
]
execute_write_display_rows = await display_query_rows(
self.ds,
db.name,
request,
cursor.fetchall(),
execute_write_columns,
)
return await self._render_form(
request,
db,
sql=sql,
parameter_values={name: params.get(name, "") for name in parameter_names},
analysis=analysis,
execution_message=message,
execution_links=execution_links,
execution_ok=True,
execute_write_returns_rows=execute_write_returns_rows,
execute_write_columns=execute_write_columns,
execute_write_display_rows=execute_write_display_rows,
execute_write_truncated=cursor.truncated,
)
class ExecuteWriteAnalyzeView(BaseView):
name = "execute-write-analyze"
has_json_alternate = False
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-write-sql"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
Response.error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
analysis["unstable"] = UNSTABLE_API_MESSAGE
return _block_framing(Response.json(analysis))

View file

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

View file

@ -1,636 +0,0 @@
import json
import re
from datasette.resources import DatabaseResource
from datasette.stored_queries import (
StoredQuery,
)
from datasette.write_sql import (
IgnoreWriteSqlOperation,
QueryWriteRejected,
RequireWriteSqlPermissions,
decision_for_write_sql_operation,
operation_is_write,
)
from datasette.utils import (
parse_size_limit,
named_parameters as derive_named_parameters,
escape_sqlite,
path_from_row_pks,
sqlite3,
validate_sql_select,
InvalidSql,
)
from datasette.utils.asgi import Forbidden
from datasette.utils.sql_analysis import Operation, SQLAnalysis
_query_name_re = re.compile(r"^[^/\.\n]+$")
_query_fields = {
"sql",
"title",
"description",
"hide_sql",
"fragment",
"parameters",
"is_private",
"on_success_message",
"on_success_redirect",
"on_error_message",
"on_error_redirect",
}
_query_create_fields = _query_fields | {"name", "mode", "csrftoken"}
_query_update_fields = _query_fields
_query_write_fields = {
"on_success_message",
"on_success_redirect",
"on_error_message",
"on_error_redirect",
}
SQL_PARAMETER_FORM_PREFIX = "_sql_param_"
class QueryValidationError(Exception):
def __init__(self, message, status=400, *, flash=False):
self.message = message
self.status = status
self.flash = flash
super().__init__(message)
def _actor_id(actor):
if isinstance(actor, dict):
return actor.get("id")
return None
def _as_bool(value):
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, int):
return bool(value)
if isinstance(value, str):
return value.lower() in {"1", "true", "t", "yes", "on"}
return bool(value)
def _as_optional_bool(value, name):
if value is None or value == "":
return None
if isinstance(value, bool):
return value
if isinstance(value, int):
return bool(value)
if isinstance(value, str):
lowered = value.lower()
if lowered in {"1", "true", "t", "yes", "on"}:
return True
if lowered in {"0", "false", "f", "no", "off"}:
return False
raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default, maximum):
try:
return parse_size_limit(value, default, maximum)
except ValueError as ex:
raise QueryValidationError(str(ex)) from ex
def _derived_query_parameters(sql):
parameters = []
seen = set()
for parameter in derive_named_parameters(sql):
if parameter.startswith("_"):
raise QueryValidationError("Magic parameters are not allowed")
if parameter not in seen:
parameters.append(parameter)
seen.add(parameter)
return parameters
def _coerce_query_parameters(value, derived):
if value is None:
return derived
if isinstance(value, str):
parameters = [
parameter.strip()
for parameter in re.split(r"[\s,]+", value)
if parameter.strip()
]
elif isinstance(value, list):
parameters = value
else:
raise QueryValidationError("parameters must be a list of strings")
if not all(isinstance(parameter, str) for parameter in parameters):
raise QueryValidationError("parameters must be a list of strings")
if any(parameter.startswith("_") for parameter in parameters):
raise QueryValidationError("Magic parameters are not allowed")
if set(parameters) != set(derived):
raise QueryValidationError("parameters must match SQL named parameters")
return parameters
def _analysis_is_write(analysis: SQLAnalysis) -> bool:
return any(operation_is_write(operation) for operation in analysis.operations)
def _block_framing(response):
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
response.headers["X-Frame-Options"] = "DENY"
return response
def _wants_json(request, is_json, data):
return (
is_json
or request.headers.get("accept") == "application/json"
or (isinstance(data, dict) and data.get("_json"))
)
def _query_create_form_error_message(message):
return {
"Query name is required": "URL is required",
"Invalid query name": "Invalid URL",
"Query name conflicts with a table or view": (
"URL conflicts with an existing table or view"
),
"Query already exists": "A query already exists at that URL",
}.get(message, message)
async def _json_or_form_payload(request):
content_type = request.headers.get("content-type", "")
if content_type.startswith("application/json"):
body = await request.post_body()
try:
return json.loads(body or b"{}"), True
except json.JSONDecodeError as e:
raise QueryValidationError("Invalid JSON: {}".format(e))
return await request.post_vars(), False
async def _check_query_name(db, name, *, existing=False):
if not name or not isinstance(name, str):
raise QueryValidationError("Query name is required")
if not _query_name_re.match(name):
raise QueryValidationError("Invalid query name")
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
raise QueryValidationError("Query name conflicts with a table or view")
async def _analyze_user_query(datasette, db, sql, *, actor):
if not sql or not isinstance(sql, str):
raise QueryValidationError("SQL is required")
derived = _derived_query_parameters(sql)
params = {parameter: "" for parameter in derived}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
is_write = _analysis_is_write(analysis)
if is_write:
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except QueryWriteRejected as ex:
raise QueryValidationError(ex.message, status=403, flash=True) from ex
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
else:
try:
validate_sql_select(sql)
except InvalidSql as ex:
raise QueryValidationError(str(ex)) from ex
return is_write, derived, analysis
def _display_operations(analysis: SQLAnalysis) -> list[Operation]:
operations = []
for operation in analysis.operations:
if isinstance(
decision_for_write_sql_operation(operation), IgnoreWriteSqlOperation
):
continue
operations.append(operation)
return operations
def _analysis_rows(analysis: SQLAnalysis) -> list[dict[str, object]]:
rows = []
for operation in _display_operations(analysis):
decision = decision_for_write_sql_operation(operation)
required_permission = (
", ".join(permission.action for permission in decision.permissions)
if isinstance(decision, RequireWriteSqlPermissions)
else ""
)
rows.append(
{
"operation": operation.operation,
"database": operation.database,
"table": operation.table or operation.target,
"required_permission": required_permission,
"source": operation.source,
}
)
return rows
async def _analysis_rows_with_permissions(
datasette, analysis: SQLAnalysis, actor
) -> list[dict[str, object]]:
rows = _analysis_rows(analysis)
is_write = _analysis_is_write(analysis)
for row, operation in zip(rows, _display_operations(analysis)):
decision = decision_for_write_sql_operation(operation)
if isinstance(decision, RequireWriteSqlPermissions):
row["allowed"] = True
for permission in decision.permissions:
if not await datasette.allowed(
action=permission.action,
resource=permission.resource,
actor=actor,
):
row["allowed"] = False
break
elif is_write:
row["allowed"] = False
else:
row["allowed"] = None
return rows
def _execute_write_disabled_reason(sql, analysis_error, analysis_rows):
if not (sql and sql.strip()):
return "Enter writable SQL before executing."
if analysis_error:
return analysis_error
if any(row.get("allowed") is False for row in analysis_rows):
return "You do not have permission for every operation listed above."
return None
def _coerce_execute_write_payload(data, is_json):
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
if is_json:
invalid_keys = set(data) - {"sql", "params"}
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
params = data.get("params") or {}
else:
params = {}
for key, value in data.items():
if key in {"sql", "csrftoken", "_json"}:
continue
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
params[key] = value
if not isinstance(params, dict):
raise QueryValidationError("params must be a dictionary")
return data.get("sql"), params
async def _prepare_execute_write(datasette, db, sql, params, actor):
if not sql or not isinstance(sql, str):
raise QueryValidationError("SQL is required")
parameter_names = _derived_query_parameters(sql)
extra_params = set(params) - set(parameter_names)
if extra_params:
raise QueryValidationError(
"Unknown parameters: {}".format(", ".join(sorted(extra_params)))
)
params = {name: params.get(name, "") for name in parameter_names}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
if not _analysis_is_write(analysis):
raise QueryValidationError(
"Use /-/query for read-only SQL; this endpoint only executes writes"
)
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except QueryWriteRejected as ex:
raise QueryValidationError(ex.message, status=403, flash=True) from ex
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
return parameter_names, params, analysis
async def _ensure_stored_query_execution_permissions(
datasette, db, query: StoredQuery, actor
):
if query.is_trusted:
return
if query.is_write:
await datasette.ensure_permission(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=actor,
)
await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor)
else:
await datasette.ensure_permission(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=actor,
)
async def _execute_write_analysis_data(datasette, db, sql, actor):
parameter_names = []
analysis_rows = []
analysis_error = None
if sql:
try:
parameter_names = _derived_query_parameters(sql)
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
datasette, analysis, actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
execute_disabled_reason = _execute_write_disabled_reason(
sql, analysis_error, analysis_rows
)
return {
"ok": analysis_error is None,
"parameters": parameter_names,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
}
async def _query_create_analysis_data(datasette, db, sql, actor):
has_sql = bool(sql and sql.strip())
parameter_names = []
analysis_rows = []
analysis_error = None
analysis: SQLAnalysis | None = None
if has_sql:
try:
parameter_names = _derived_query_parameters(sql)
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
analysis_rows = await _analysis_rows_with_permissions(
datasette, analysis, actor
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
return {
"ok": analysis_error is None,
"parameters": parameter_names,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"has_sql": has_sql,
"analysis_is_write": _analysis_is_write(analysis) if analysis else False,
"save_disabled": bool(
(not has_sql)
or analysis_error
or any(row["allowed"] is False for row in analysis_rows)
),
}
async def _query_create_form_context(
datasette,
request,
db,
*,
sql="",
name="",
title="",
description="",
is_private=True,
):
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
return {
"database": db.name,
"database_color": db.color,
"sql": sql,
"name": name,
"title": title,
"description": description,
"is_private": is_private,
**analysis_data,
}
async def _query_edit_form_context(
datasette,
request,
db,
existing: StoredQuery,
*,
sql=None,
title=None,
description=None,
is_private=None,
):
sql = existing.sql if sql is None else sql
title = existing.title if title is None else title
description = existing.description if description is None else description
is_private = existing.is_private if is_private is None else is_private
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
return {
"database": db.name,
"database_color": db.color,
"name": existing.name,
"sql": sql,
"title": title or "",
"description": description or "",
"is_private": is_private,
"query_url": datasette.urls.table(db.name, existing.name),
**analysis_data,
}
async def _inserted_row_url(datasette, db, analysis, cursor):
if cursor.rowcount != 1:
return None
lastrowid = getattr(cursor, "lastrowid", None)
if lastrowid is None:
return None
direct_inserts = [
operation
for operation in analysis.operations
if operation.operation == "insert"
and operation.target_type == "table"
and not operation.internal
and operation.source is None
and operation.database == db.name
]
if len(direct_inserts) != 1:
return None
table = direct_inserts[0].table
if table is None:
return None
pks = await db.primary_keys(table)
use_rowid = not pks
select = (
"rowid"
if use_rowid
else ", ".join(escape_sqlite(primary_key) for primary_key in pks)
)
try:
result = await db.execute(
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
[lastrowid],
)
except sqlite3.DatabaseError:
return None
row = result.first()
if row is None:
return None
row_path = path_from_row_pks(row, pks, use_rowid)
return datasette.urls.row(db.name, table, row_path)
def _apply_query_data_types(data):
typed = dict(data)
for key in ("hide_sql", "is_private"):
if key in typed:
typed[key] = _as_bool(typed[key])
return typed
async def _prepare_query_create(datasette, request, db, data):
invalid_keys = set(data) - _query_create_fields
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
data = _apply_query_data_types(data)
name = data.get("name")
await _check_query_name(db, name)
if await datasette.get_query(db.name, name) is not None:
raise QueryValidationError("Query already exists")
is_write, derived, analysis = await _analyze_user_query(
datasette,
db,
data.get("sql"),
actor=request.actor,
)
if not is_write and any(data.get(field) for field in _query_write_fields):
raise QueryValidationError("Writable query fields require writable SQL")
parameters = _coerce_query_parameters(
data.get("parameters"),
derived,
)
return {
"name": name,
"sql": data["sql"],
"title": data.get("title"),
"description": data.get("description"),
"hide_sql": _as_bool(data.get("hide_sql")),
"fragment": data.get("fragment"),
"parameters": parameters,
"is_write": is_write,
"is_private": _as_bool(data.get("is_private", True)),
"is_trusted": False,
"source": "user",
"owner_id": _actor_id(request.actor),
"on_success_message": data.get("on_success_message"),
"on_success_redirect": data.get("on_success_redirect"),
"on_error_message": data.get("on_error_message"),
"on_error_redirect": data.get("on_error_redirect"),
"analysis": analysis,
}
async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update):
invalid_keys = set(update) - _query_update_fields
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
update = _apply_query_data_types(update)
sql = update.get("sql", existing.sql)
query_is_write = existing.is_write
derived = _derived_query_parameters(sql)
parameters = None
if "sql" in update:
query_is_write, derived, _ = await _analyze_user_query(
datasette,
db,
sql,
actor=request.actor,
)
if "parameters" in update:
parameters = _coerce_query_parameters(
update.get("parameters"),
derived,
)
elif "sql" in update:
parameters = derived
if not query_is_write and any(update.get(field) for field in _query_write_fields):
raise QueryValidationError("Writable query fields require writable SQL")
field_values = {
"sql": sql,
"title": update.get("title"),
"description": update.get("description"),
"hide_sql": update.get("hide_sql"),
"fragment": update.get("fragment"),
"parameters": parameters,
"is_write": query_is_write,
"is_private": update.get("is_private"),
"on_success_message": update.get("on_success_message"),
"on_success_redirect": update.get("on_success_redirect"),
"on_error_message": update.get("on_error_message"),
"on_error_redirect": update.get("on_error_redirect"),
}
update_kwargs = {}
for field_name, value in field_values.items():
if field_name in update:
update_kwargs[field_name] = value
if parameters is not None:
update_kwargs["parameters"] = parameters
if "sql" in update:
update_kwargs["is_write"] = query_is_write
return update_kwargs
async def _table_columns(datasette, database_name):
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(row["name"])
# Add views
db = datasette.get_database(database_name)
for view_name in await db.view_names():
table_columns[view_name] = []
return table_columns

View file

@ -1,398 +1,25 @@
import asyncio from datasette.utils.asgi import NotFound, Forbidden, Response
import json
import textwrap
import time
import urllib.parse
from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.events import UpdateRowEvent, DeleteRowEvent from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.resources import TableResource from datasette.resources import TableResource
from .base import BaseView, DatasetteError, stream_csv from .base import DataView, BaseView, _error
from datasette.utils import ( from datasette.utils import (
add_cors_headers,
await_me_maybe, await_me_maybe,
call_with_supported_arguments,
CustomJSONEncoder,
CustomRow, CustomRow,
decode_write_json_row,
InvalidSql,
make_slot_function, make_slot_function,
path_from_row_pks,
path_with_format,
path_with_removed_args,
to_css_class, to_css_class,
escape_sqlite, escape_sqlite,
sqlite3,
WriteJsonValueError,
) )
from datasette.plugins import pm from datasette.plugins import pm
from datasette.extras import extra_names_from_request, ExtraScope import json
from . import Context, from_extra import markupsafe
from .table import ( import sqlite_utils
display_columns_and_rows, from .table import display_columns_and_rows, _get_extras
_table_page_data,
row_label_from_label_column,
)
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
@dataclass class RowView(DataView):
class RowContext(Context):
"The page showing an individual row, e.g. /fixtures/facetable/1."
documented_template = "row.html"
extras_scope = ExtraScope.ROW
# Fields resolved by registered extras - their documentation comes
# from the description on each Extra class in table_extras.py
columns: list = from_extra()
database: str = from_extra()
database_color: str = from_extra()
foreign_key_tables: list = from_extra()
metadata: dict = from_extra()
primary_keys: list = from_extra()
private: bool = from_extra()
table: str = from_extra()
# Fields added by the view code
ok: bool = field(
metadata={"help": "True if the data for this page was retrieved without errors"}
)
rows: list = field(
metadata={
"help": "A single-item list containing this row as a dictionary mapping column name to raw value."
}
)
primary_key_values: list = field(
metadata={"help": "Values of the primary keys for this row, from the URL"}
)
query_ms: float = field(
metadata={
"help": "Time taken by the SQL queries for this page, in milliseconds"
}
)
display_columns: list = field(
metadata={
"help": "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
}
)
display_rows: list = field(
metadata={
"help": "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys."
}
)
custom_table_templates: list = field(
metadata={
"help": "Custom template names that were considered for displaying this row's table, in lookup order."
}
)
row_actions: list = field(
metadata={
"help": 'Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.'
}
)
row_mutation_ui: bool = field(
metadata={"help": "True if the row edit/delete JavaScript UI should be enabled"}
)
table_page_data: dict = field(
metadata={
"help": "JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs."
}
)
top_row: callable = field(
metadata={
"help": "Async callable that renders the ``top_row`` plugin slot for this row and returns HTML."
}
)
renderers: dict = field(
metadata={
"help": "Dictionary mapping output format names such as ``json`` to URLs for this row in that format."
}
)
url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
url_csv_hidden_args: list = field(
metadata={
"help": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``."
}
)
settings: dict = field(
metadata={
"help": "Dictionary of Datasette's current settings, keyed by setting name."
}
)
select_templates: list = field(
metadata={
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for the JSON version of this page"}
)
class RowView(BaseView):
name = "row" name = "row"
def redirect(self, request, path, forward_querystring=True, remove_args=None):
if request.query_string and "?" not in path and forward_querystring:
path = f"{path}?{request.query_string}"
if remove_args:
path = path_with_removed_args(request, remove_args, path=path)
response = Response.redirect(path)
response.headers["Link"] = f"<{path}>; rel=preload"
if self.ds.cors:
add_cors_headers(response.headers)
return response
async def as_csv(self, request, database):
return await stream_csv(self.ds, self.data, request, database)
async def get(self, request):
db = await self.ds.resolve_database(request)
database = db.name
database_route = db.route
format_ = request.url_vars.get("format") or "html"
data_kwargs = {}
if format_ == "csv":
return await self.as_csv(request, database_route)
if format_ == "html":
# HTML views default to expanding all foreign key labels
data_kwargs["default_labels"] = True
extra_template_data = {}
start = time.perf_counter()
status_code = None
templates = ()
try:
response_or_template_contexts = await self.data(request, **data_kwargs)
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
# If it has four items, it includes an HTTP status code
if len(response_or_template_contexts) == 4:
(
data,
extra_template_data,
templates,
status_code,
) = response_or_template_contexts
else:
data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""".format(markupsafe.escape(ex.sql))).strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
plain_message=(
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
),
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError:
raise
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
if format_ in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
self.ds.renderers[format_][0],
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
truncated=False, # TODO: support this
error=data.get("error"),
# These will be deprecated in Datasette 1.0:
args=request.args,
data=data,
)
if asyncio.iscoroutine(result):
result = await result
if result is None:
raise NotFound("No data")
if isinstance(result, dict):
response = Response(
body=result.get("body"),
status=result.get("status_code", status_code or 200),
content_type=result.get("content_type", "text/plain"),
headers=result.get("headers"),
)
elif isinstance(result, Response):
response = result
if status_code is not None:
# Over-ride the status code
response.status = status_code
else:
assert False, f"{result} should be dict or Response"
elif format_ == "html":
response = await self.html(request, data, extra_template_data, templates)
if status_code is not None:
response.status = status_code
else:
raise NotFound("Invalid format: {}".format(format_))
ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(response, ttl)
async def html(self, request, data, extra_template_data, templates):
extras = {}
if callable(extra_template_data):
extras = extra_template_data()
if asyncio.iscoroutine(extras):
extras = await extras
else:
extras = extra_template_data
url_labels_extra = {}
if data.get("expandable_columns"):
url_labels_extra = {"_labels": "on"}
renderers = {}
for key, (_, can_render) in self.ds.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=data.get("database"),
table=data.get("table"),
request=request,
view_name=self.name,
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
)
)
url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
)
url_csv_path = url_csv.split("?")[0]
context = {**data, **extras}
if "metadata" not in context:
context["metadata"] = await self.ds.get_instance_metadata()
environment = self.ds.get_jinja_environment(request)
template = environment.select_template(templates)
alternate_url_json = self.ds.absolute_url(
request,
self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
)
return Response.html(
await self.ds.render_template(
template,
RowContext(
columns=context["columns"],
database=context["database"],
database_color=context["database_color"],
foreign_key_tables=context["foreign_key_tables"],
metadata=context["metadata"],
primary_keys=context["primary_keys"],
private=context["private"],
table=context["table"],
ok=context["ok"],
rows=context["rows"],
primary_key_values=context["primary_key_values"],
query_ms=context["query_ms"],
display_columns=context["display_columns"],
display_rows=context["display_rows"],
custom_table_templates=context["custom_table_templates"],
row_actions=context["row_actions"],
row_mutation_ui=context["row_mutation_ui"],
table_page_data=context["table_page_data"],
top_row=context["top_row"],
renderers=renderers,
url_csv=url_csv,
url_csv_path=url_csv_path,
url_csv_hidden_args=[
(key, value)
for key, value in urllib.parse.parse_qsl(request.query_string)
if key not in ("_labels", "_facet", "_size")
]
+ [("_size", "max")],
settings=self.ds.settings_dict(),
select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
],
alternate_url_json=alternate_url_json,
),
request=request,
view_name=self.name,
),
headers={
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
},
)
def set_response_headers(self, response, ttl):
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
return response
async def data(self, request, default_labels=False): async def data(self, request, default_labels=False):
resolved = await self.ds.resolve_row(request) resolved = await self.ds.resolve_row(request)
db = resolved.db db = resolved.db
@ -420,7 +47,6 @@ class RowView(BaseView):
pks = resolved.pks pks = resolved.pks
async def template_data(): async def template_data():
is_table = await db.table_exists(table)
# Reorder columns so primary keys come first # Reorder columns so primary keys come first
pk_set = set(pks) pk_set = set(pks)
pk_cols = [d for d in results.description if d[0] in pk_set] pk_cols = [d for d in results.description if d[0] in pk_set]
@ -489,60 +115,7 @@ class RowView(BaseView):
"<strong>{}</strong>".format(cell["value"]) "<strong>{}</strong>".format(cell["value"])
) )
label_column = await db.label_column_for_table(table) if is_table else None
row_path = path_from_row_pks(rows[0], pks, False)
pk_path = path_from_row_pks(rows[0], pks, False, False)
row_label = row_label_from_label_column(expanded_rows[0], label_column)
for display_row in display_rows:
display_row.pk_path = pk_path
display_row.row_path = row_path
display_row.row_label = row_label
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = "{} {}".format(pk_path, row_label)
row_action_permissions = {}
if is_table and db.is_mutable:
row_action_permissions = await self.ds.allowed_many(
actions=["update-row", "delete-row"],
resource=TableResource(database=database, table=table),
actor=request.actor,
)
row_actions = [] row_actions = []
if row_action_permissions.get("update-row"):
attrs = {
"aria-label": "Edit row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "edit",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Edit row",
"description": "Open a dialog to edit this row.",
"attrs": attrs,
}
)
if row_action_permissions.get("delete-row"):
attrs = {
"aria-label": "Delete row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "delete",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Delete row",
"description": "Open a confirmation dialog to delete this row.",
"attrs": attrs,
}
)
for hook in pm.hook.row_actions( for hook in pm.hook.row_actions(
datasette=self.ds, datasette=self.ds,
actor=request.actor, actor=request.actor,
@ -569,17 +142,6 @@ class RowView(BaseView):
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html", f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
"_table.html", "_table.html",
], ],
"row_mutation_ui": any(row_action_permissions.values()),
"table_page_data": await _table_page_data(
datasette=self.ds,
request=request,
db=db,
database_name=database,
table_name=table,
is_view=not is_table,
table_insert_ui=None,
table_alter_ui=None,
),
"row_actions": row_actions, "row_actions": row_actions,
"top_row": make_slot_function( "top_row": make_slot_function(
"top_row", "top_row",
@ -602,30 +164,60 @@ class RowView(BaseView):
"primary_key_values": pk_values, "primary_key_values": pk_values,
} }
extras = extra_names_from_request(request) # Handle _extra parameter (new style)
if request.url_vars.get("format"): extras = _get_extras(request)
# Data formats reject unknown extras; HTML ignores them
table_extra_registry.validate_requested(extras, ExtraScope.ROW) # Also support legacy _extras parameter for backward compatibility
if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
extras.add("foreign_key_tables")
# Process extras # Process extras
row_extra_context = RowExtraContext( if "foreign_key_tables" in extras:
data["foreign_key_tables"] = await self.foreign_key_tables(
database, table, pk_values
)
if "render_cell" in extras:
# Call render_cell plugin hook for each cell
ct_map = await self.ds.get_column_types(database, table)
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
ct = ct_map.get(column)
plugin_display_value = None
# Try column type render_cell first
if ct:
candidate = await ct.render_cell(
value=value,
column=column,
table=table,
database=database,
datasette=self.ds, datasette=self.ds,
request=request, request=request,
db=db,
database_name=database,
table_name=table,
private=private,
rows=rows,
columns=columns,
pks=pks,
pk_values=pk_values,
sql=resolved.sql,
params=resolved.params,
extras=extras,
extra_registry=table_extra_registry,
foreign_key_tables=self.foreign_key_tables,
) )
data.update(await resolve_row_extras(extras, row_extra_context)) if candidate is not None:
plugin_display_value = candidate
if plugin_display_value is None:
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table,
pks=resolved.pks,
database=database,
datasette=self.ds,
request=request,
column_type=ct,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
plugin_display_value = candidate
break
if plugin_display_value:
rendered_row[column] = str(plugin_display_value)
rendered_rows.append(rendered_row)
data["render_cell"] = rendered_rows
return ( return (
data, data,
@ -688,40 +280,17 @@ class RowError(Exception):
self.error = error self.error = error
ROW_FLASH_LABEL_MAX_LENGTH = 80
def _truncated_row_flash_label(label):
label = " ".join(str(label).split())
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
return label
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
async def _row_flash_message(db, action, resolved, row=None):
pk_label = ", ".join(resolved.pk_values)
label_column = await db.label_column_for_table(resolved.table)
label = row_label_from_label_column(row or resolved.row, label_column)
if label:
label = _truncated_row_flash_label(label)
if label and label != pk_label:
return "{} row {} ({})".format(action, pk_label, label)
return "{} row {}".format(action, pk_label)
async def _resolve_row_and_check_permission(datasette, request, permission): async def _resolve_row_and_check_permission(datasette, request, permission):
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
try: try:
resolved = await datasette.resolve_row(request) resolved = await datasette.resolve_row(request)
except DatabaseNotFound as e: except DatabaseNotFound as e:
return False, Response.error( return False, _error(["Database not found: {}".format(e.database_name)], 404)
["Database not found: {}".format(e.database_name)], 404
)
except TableNotFound as e: except TableNotFound as e:
return False, Response.error(["Table not found: {}".format(e.table)], 404) return False, _error(["Table not found: {}".format(e.table)], 404)
except RowNotFound as e: except RowNotFound as e:
return False, Response.error(["Record not found: {}".format(e.pk_values)], 404) return False, _error(["Record not found: {}".format(e.pk_values)], 404)
# Ensure user has permission to delete this row # Ensure user has permission to delete this row
if not await datasette.allowed( if not await datasette.allowed(
@ -729,7 +298,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
resource=TableResource(database=resolved.db.name, table=resolved.table), resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor, actor=request.actor,
): ):
return False, Response.error(["Permission denied"], 403) return False, _error(["Permission denied"], 403)
return True, resolved return True, resolved
@ -754,7 +323,7 @@ class RowDeleteView(BaseView):
try: try:
await resolved.db.execute_write_fn(delete_row, request=request) await resolved.db.execute_write_fn(delete_row, request=request)
except Exception as e: except Exception as e:
return Response.error([str(e)], 400) return _error([str(e)], 500)
await self.ds.track_event( await self.ds.track_event(
DeleteRowEvent( DeleteRowEvent(
@ -765,15 +334,6 @@ class RowDeleteView(BaseView):
) )
) )
if request.args.get("_redirect_to_table"):
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
self.ds.add_message(
request,
await _row_flash_message(resolved.db, "Deleted", resolved),
self.ds.INFO,
)
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
return Response.json({"ok": True}, status=200) return Response.json({"ok": True}, status=200)
@ -790,27 +350,22 @@ class RowUpdateView(BaseView):
if not ok: if not ok:
return resolved return resolved
body = await request.post_body()
try: try:
data = await request.json() data = json.loads(body)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return Response.error(["Invalid JSON: {}".format(e)]) return _error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
if not isinstance(data, dict): if not isinstance(data, dict):
return Response.error(["JSON must be a dictionary"]) return _error(["JSON must be a dictionary"])
if "update" not in data or not isinstance(data["update"], dict): if "update" not in data or not isinstance(data["update"], dict):
return Response.error(["JSON must contain an update dictionary"]) return _error(["JSON must contain an update dictionary"])
invalid_keys = set(data.keys()) - {"update", "return", "alter"} invalid_keys = set(data.keys()) - {"update", "return", "alter"}
if invalid_keys: if invalid_keys:
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))]) return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
update = data["update"] update = data["update"]
try:
update = decode_write_json_row(update)
except WriteJsonValueError as e:
return Response.error([str(e)], 400)
# Validate column types # Validate column types
from datasette.views.table import _validate_column_types from datasette.views.table import _validate_column_types
@ -819,7 +374,7 @@ class RowUpdateView(BaseView):
self.ds, resolved.db.name, resolved.table, [update] self.ds, resolved.db.name, resolved.table, [update]
) )
if ct_errors: if ct_errors:
return Response.error(ct_errors, 400) return _error(ct_errors, 400)
alter = data.get("alter") alter = data.get("alter")
if alter and not await self.ds.allowed( if alter and not await self.ds.allowed(
@ -827,7 +382,7 @@ class RowUpdateView(BaseView):
resource=TableResource(database=resolved.db.name, table=resolved.table), resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied for alter-table"], 403) return _error(["Permission denied for alter-table"], 403)
def update_row(conn): def update_row(conn):
sqlite_utils.Database(conn)[resolved.table].update( sqlite_utils.Database(conn)[resolved.table].update(
@ -837,16 +392,14 @@ class RowUpdateView(BaseView):
try: try:
await resolved.db.execute_write_fn(update_row, request=request) await resolved.db.execute_write_fn(update_row, request=request)
except Exception as e: except Exception as e:
return Response.error([str(e)], 400) return _error([str(e)], 400)
result = {"ok": True} result = {"ok": True}
returned_row = None
if data.get("return"): if data.get("return"):
results = await resolved.db.execute( results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True resolved.sql, resolved.params, truncate=True
) )
returned_row = results.dicts()[0] result["row"] = results.dicts()[0]
result["rows"] = [returned_row]
await self.ds.track_event( await self.ds.track_event(
UpdateRowEvent( UpdateRowEvent(
@ -857,19 +410,4 @@ class RowUpdateView(BaseView):
) )
) )
if request.args.get("_message"): return Response.json(result, status=200)
message_row = returned_row
if message_row is None:
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
message_row = results.first()
self.ds.add_message(
request,
await _row_flash_message(
resolved.db, "Updated", resolved, row=message_row
),
self.ds.INFO,
)
return Response.json(result, status=200, default=CustomJSONEncoder().default)

View file

@ -6,12 +6,9 @@ from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden from datasette.utils.asgi import Response, Forbidden
from datasette.utils import ( from datasette.utils import (
UNSTABLE_API_MESSAGE,
actor_matches_allow, actor_matches_allow,
parse_size_limit,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
error_body,
tilde_encode, tilde_encode,
tilde_decode, tilde_decode,
) )
@ -55,9 +52,9 @@ class JsonDataView(BaseView):
if self.permission: if self.permission:
await self.ds.ensure_permission(action=self.permission, actor=request.actor) await self.ds.ensure_permission(action=self.permission, actor=request.actor)
if self.needs_request: if self.needs_request:
data = await await_me_maybe(self.data_callback(request)) data = self.data_callback(request)
else: else:
data = await await_me_maybe(self.data_callback()) data = self.data_callback()
# Return JSON or HTML depending on format parameter # Return JSON or HTML depending on format parameter
as_format = request.url_vars.get("format") as_format = request.url_vars.get("format")
@ -65,14 +62,12 @@ class JsonDataView(BaseView):
headers = {} headers = {}
if self.ds.cors: if self.ds.cors:
add_cors_headers(headers) add_cors_headers(headers)
if isinstance(data, dict):
data = {"ok": True, **data}
return Response.json(data, headers=headers) return Response.json(data, headers=headers)
else: else:
context = { context = {
"filename": self.filename, "filename": self.filename,
"data": data, "data": data,
"data_json": json.dumps(data, indent=2, default=repr), "data_json": json.dumps(data, indent=4, default=repr),
} }
# Add has_debug_permission if this view requires permissions-debug # Add has_debug_permission if this view requires permissions-debug
if self.permission == "permissions-debug": if self.permission == "permissions-debug":
@ -96,110 +91,6 @@ class PatternPortfolioView(View):
) )
class AutocompleteDebugView(BaseView):
name = "autocomplete_debug"
has_json_alternate = False
async def _suggested_tables(self, request):
scanned = 0
reached_scan_limit = False
suggestions = []
for database_name, db in self.ds.databases.items():
if scanned >= 100 or len(suggestions) >= 5:
break
remaining = 100 - scanned
results = await db.execute(
"select name from sqlite_master where type = 'table' order by name limit ?",
[remaining],
)
for row in results.rows:
table_name = row["name"]
scanned += 1
if scanned >= 100:
reached_scan_limit = True
visible, _ = await self.ds.check_visibility(
request.actor,
action="view-table",
resource=TableResource(database=database_name, table=table_name),
)
if not visible:
if scanned >= 100:
break
continue
label_column = await db.label_column_for_table(table_name)
if label_column:
suggestions.append(
{
"database": database_name,
"table": table_name,
"label_column": label_column,
"url": self.ds.urls.path(
"-/debug/autocomplete?"
+ urllib.parse.urlencode(
{
"database": database_name,
"table": table_name,
}
)
),
}
)
if len(suggestions) >= 5:
break
if scanned >= 100:
break
return suggestions, scanned, reached_scan_limit
async def get(self, request):
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
database_name = request.args.get("database")
table_name = request.args.get("table")
context = {
"database_name": database_name,
"table_name": table_name,
}
if database_name or table_name:
if not database_name or not table_name:
context["error"] = "Both database and table are required."
elif database_name not in self.ds.databases:
context["error"] = "Database not found."
else:
db = self.ds.databases[database_name]
if not await db.table_exists(table_name):
context["error"] = "Table not found."
else:
await self.ds.ensure_permission(
action="view-table",
resource=TableResource(
database=database_name,
table=table_name,
),
actor=request.actor,
)
context.update(
{
"autocomplete_url": "{}/-/autocomplete".format(
self.ds.urls.table(database_name, table_name)
),
"label_column": await db.label_column_for_table(table_name),
}
)
else:
suggestions, scanned, reached_scan_limit = await self._suggested_tables(
request
)
context.update(
{
"suggestions": suggestions,
"scanned": scanned,
"reached_scan_limit": reached_scan_limit,
}
)
return await self.render(["debug_autocomplete.html"], request, context)
class AuthTokenView(BaseView): class AuthTokenView(BaseView):
name = "auth_token" name = "auth_token"
has_json_alternate = False has_json_alternate = False
@ -297,12 +188,6 @@ class PermissionsDebugView(BaseView):
response, status = await _check_permission_for_actor( response, status = await _check_permission_for_actor(
self.ds, permission, parent, child, actor self.ds, permission, parent, child, actor
) )
if response.get("ok"):
response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
**response,
}
return Response.json(response, status=status) return Response.json(response, status=status)
@ -359,32 +244,29 @@ class AllowedResourcesView(BaseView):
async def _allowed_payload(self, request, has_debug_permission): async def _allowed_payload(self, request, has_debug_permission):
action = request.args.get("action") action = request.args.get("action")
if not action: if not action:
return error_body("action parameter is required", 400), 400 return {"error": "action parameter is required"}, 400
if action not in self.ds.actions: if action not in self.ds.actions:
return error_body(f"Unknown action: {action}", 404), 404 return {"error": f"Unknown action: {action}"}, 404
actor = request.actor if isinstance(request.actor, dict) else None actor = request.actor if isinstance(request.actor, dict) else None
actor_id = actor.get("id") if actor else None actor_id = actor.get("id") if actor else None
parent_filter = request.args.get("parent") parent_filter = request.args.get("parent")
child_filter = request.args.get("child") child_filter = request.args.get("child")
if child_filter and not parent_filter: if child_filter and not parent_filter:
return ( return {"error": "parent must be provided when child is specified"}, 400
error_body("parent must be provided when child is specified", 400),
400,
)
try: try:
page = int(request.args.get("_page", "1")) page = int(request.args.get("page", "1"))
if page < 1: page_size = int(request.args.get("page_size", "50"))
raise ValueError
except ValueError: except ValueError:
return error_body("_page must be a positive integer", 400), 400 return {"error": "page and page_size must be integers"}, 400
try: if page < 1:
page_size = parse_size_limit( return {"error": "page must be >= 1"}, 400
request.args.get("_size"), default=50, maximum=200 if page_size < 1:
) return {"error": "page_size must be >= 1"}, 400
except ValueError as ex: max_page_size = 200
return error_body(str(ex), 400), 400 if page_size > max_page_size:
page_size = max_page_size
offset = (page - 1) * page_size offset = (page - 1) * page_size
# Use the simplified allowed_resources method # Use the simplified allowed_resources method
@ -424,7 +306,6 @@ class AllowedResourcesView(BaseView):
# If catalog tables don't exist yet, return empty results # If catalog tables don't exist yet, return empty results
return ( return (
{ {
"ok": True,
"action": action, "action": action,
"actor_id": actor_id, "actor_id": actor_id,
"page": page, "page": page,
@ -449,17 +330,16 @@ class AllowedResourcesView(BaseView):
def build_page_url(page_number): def build_page_url(page_number):
pairs = [] pairs = []
for key in request.args: for key in request.args:
if key in {"_page", "_size"}: if key in {"page", "page_size"}:
continue continue
for value in request.args.getlist(key): for value in request.args.getlist(key):
pairs.append((key, value)) pairs.append((key, value))
pairs.append(("_page", str(page_number))) pairs.append(("page", str(page_number)))
pairs.append(("_size", str(page_size))) pairs.append(("page_size", str(page_size)))
query = urllib.parse.urlencode(pairs) query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}" return f"{request.path}?{query}"
response = { response = {
"ok": True,
"action": action, "action": action,
"actor_id": actor_id, "actor_id": actor_id,
"page": page, "page": page,
@ -501,24 +381,26 @@ class PermissionRulesView(BaseView):
# JSON API - action parameter is required # JSON API - action parameter is required
action = request.args.get("action") action = request.args.get("action")
if not action: if not action:
return Response.error("action parameter is required", 400) return Response.json({"error": "action parameter is required"}, status=400)
if action not in self.ds.actions: if action not in self.ds.actions:
return Response.error(f"Unknown action: {action}", 404) return Response.json({"error": f"Unknown action: {action}"}, status=404)
actor = request.actor if isinstance(request.actor, dict) else None actor = request.actor if isinstance(request.actor, dict) else None
try: try:
page = int(request.args.get("_page", "1")) page = int(request.args.get("page", "1"))
if page < 1: page_size = int(request.args.get("page_size", "50"))
raise ValueError
except ValueError: except ValueError:
return Response.error("_page must be a positive integer", 400) return Response.json(
try: {"error": "page and page_size must be integers"}, status=400
page_size = parse_size_limit(
request.args.get("_size"), default=50, maximum=200
) )
except ValueError as ex: if page < 1:
return Response.error(str(ex), 400) return Response.json({"error": "page must be >= 1"}, status=400)
if page_size < 1:
return Response.json({"error": "page_size must be >= 1"}, status=400)
max_page_size = 200
if page_size > max_page_size:
page_size = max_page_size
offset = (page - 1) * page_size offset = (page - 1) * page_size
from datasette.utils.actions_sql import build_permission_rules_sql from datasette.utils.actions_sql import build_permission_rules_sql
@ -569,17 +451,16 @@ class PermissionRulesView(BaseView):
def build_page_url(page_number): def build_page_url(page_number):
pairs = [] pairs = []
for key in request.args: for key in request.args:
if key in {"_page", "_size"}: if key in {"page", "page_size"}:
continue continue
for value in request.args.getlist(key): for value in request.args.getlist(key):
pairs.append((key, value)) pairs.append((key, value))
pairs.append(("_page", str(page_number))) pairs.append(("page", str(page_number)))
pairs.append(("_size", str(page_size))) pairs.append(("page_size", str(page_size)))
query = urllib.parse.urlencode(pairs) query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}" return f"{request.path}?{query}"
response = { response = {
"ok": True,
"action": action, "action": action,
"actor_id": (actor or {}).get("id") if actor else None, "actor_id": (actor or {}).get("id") if actor else None,
"page": page, "page": page,
@ -602,35 +483,32 @@ class PermissionRulesView(BaseView):
async def _check_permission_for_actor(ds, action, parent, child, actor): async def _check_permission_for_actor(ds, action, parent, child, actor):
"""Shared logic for checking permissions. Returns a dict with check results.""" """Shared logic for checking permissions. Returns a dict with check results."""
if action not in ds.actions: if action not in ds.actions:
return error_body(f"Unknown action: {action}", 404), 404 return {"error": f"Unknown action: {action}"}, 404
if child and not parent: if child and not parent:
return error_body("parent is required when child is provided", 400), 400 return {"error": "parent is required when child is provided"}, 400
# Use the action's properties to create the appropriate resource object # Use the action's properties to create the appropriate resource object
action_obj = ds.actions.get(action) action_obj = ds.actions.get(action)
if not action_obj: if not action_obj:
return error_body(f"Unknown action: {action}", 400), 400 return {"error": f"Unknown action: {action}"}, 400
# Global actions (no resource_class) don't have a resource # Global actions (no resource_class) don't have a resource
if action_obj.resource_class is None: if action_obj.resource_class is None:
resource_obj = None resource_obj = None
elif action_obj.takes_parent and action_obj.takes_child: elif action_obj.takes_parent and action_obj.takes_child:
# Child-level resource (e.g., TableResource, QueryResource). The child # Child-level resource (e.g., TableResource, QueryResource)
# argument is named differently per resource class (table, query, ...), resource_obj = action_obj.resource_class(database=parent, table=child)
# so pass positionally - https://github.com/simonw/datasette/issues/2756
resource_obj = action_obj.resource_class(parent, child)
elif action_obj.takes_parent: elif action_obj.takes_parent:
# Parent-level resource (e.g., DatabaseResource) # Parent-level resource (e.g., DatabaseResource)
resource_obj = action_obj.resource_class(parent) resource_obj = action_obj.resource_class(database=parent)
else: else:
# This shouldn't happen given validation in Action.__post_init__ # This shouldn't happen given validation in Action.__post_init__
return error_body(f"Invalid action configuration: {action}", 500), 500 return {"error": f"Invalid action configuration: {action}"}, 500
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor) allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
response = { response = {
"ok": True,
"action": action, "action": action,
"allowed": bool(allowed), "allowed": bool(allowed),
"resource": { "resource": {
@ -667,7 +545,7 @@ class PermissionCheckView(BaseView):
# JSON API - action parameter is required # JSON API - action parameter is required
action = request.args.get("action") action = request.args.get("action")
if not action: if not action:
return Response.error("action parameter is required", 400) return Response.json({"error": "action parameter is required"}, status=400)
parent = request.args.get("parent") parent = request.args.get("parent")
child = request.args.get("child") child = request.args.get("child")
@ -1014,15 +892,14 @@ class ApiExplorerView(BaseView):
raise Forbidden("You do not have permission to view this instance") raise Forbidden("You do not have permission to view this instance")
def api_path(link): def api_path(link):
return "{}#{}".format( return "/-/api#{}".format(
self.ds.urls.path("/-/api"),
urllib.parse.urlencode( urllib.parse.urlencode(
{ {
key: json.dumps(value, indent=2) if key == "json" else value key: json.dumps(value, indent=2) if key == "json" else value
for key, value in link.items() for key, value in link.items()
if key in ("path", "method", "json") if key in ("path", "method", "json")
} }
), )
) )
return await self.render( return await self.render(
@ -1214,7 +1091,7 @@ class JumpView(BaseView):
match["display_name"] = row["display_name"] match["display_name"] = row["display_name"]
matches.append(match) matches.append(match)
return Response.json({"ok": True, "matches": matches, "truncated": truncated}) return Response.json({"matches": matches, "truncated": truncated})
class SchemaBaseView(BaseView): class SchemaBaseView(BaseView):
@ -1236,7 +1113,7 @@ class SchemaBaseView(BaseView):
headers = {} headers = {}
if self.ds.cors: if self.ds.cors:
add_cors_headers(headers) add_cors_headers(headers)
return Response.json({"ok": True, **data}, headers=headers) return Response.json(data, headers=headers)
def format_error_response(self, error_message, format_, status=404): def format_error_response(self, error_message, format_, status=404):
"""Format error response based on requested format.""" """Format error response based on requested format."""
@ -1245,7 +1122,7 @@ class SchemaBaseView(BaseView):
if self.ds.cors: if self.ds.cors:
add_cors_headers(headers) add_cors_headers(headers)
return Response.json( return Response.json(
error_body(error_message, status), status=status, headers=headers {"ok": False, "error": error_message}, status=status, headers=headers
) )
else: else:
return Response.text(error_message, status=status) return Response.text(error_message, status=status)
@ -1321,17 +1198,17 @@ class DatabaseSchemaView(SchemaBaseView):
database_name = request.url_vars["database"] database_name = request.url_vars["database"]
format_ = request.url_vars.get("format") or "html" format_ = request.url_vars.get("format") or "html"
# Permission check comes first, so actors without view-database # Check if database exists
# cannot distinguish existing databases from missing ones if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Check view-database permission
await self.ds.ensure_permission( await self.ds.ensure_permission(
action="view-database", action="view-database",
resource=DatabaseResource(database=database_name), resource=DatabaseResource(database=database_name),
actor=request.actor, actor=request.actor,
) )
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
schema = await self.get_database_schema(database_name) schema = await self.get_database_schema(database_name)
if format_ == "json": if format_ == "json":
@ -1365,9 +1242,6 @@ class TableSchemaView(SchemaBaseView):
actor=request.actor, actor=request.actor,
) )
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Get schema for the table # Get schema for the table
db = self.ds.databases[database_name] db = self.ds.databases[database_name]
result = await db.execute( result = await db.execute(

View file

@ -1,677 +0,0 @@
from urllib.parse import parse_qsl, urlencode
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3, tilde_decode
from datasette.utils.asgi import Response
from .base import BaseView
from .query_helpers import (
QueryValidationError,
_as_bool,
_as_optional_bool,
_block_framing,
_derived_query_parameters,
_json_or_form_payload,
_prepare_query_create,
_prepare_query_update,
_query_create_analysis_data,
_query_create_form_context,
_query_create_form_error_message,
_query_edit_form_context,
_query_list_limit,
)
class QueryParametersView(BaseView):
name = "query-parameters"
has_json_alternate = False
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-sql"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
Response.error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
try:
parameters = _derived_query_parameters(request.args.get("sql") or "")
except QueryValidationError as ex:
return _block_framing(Response.error([ex.message], ex.status))
return _block_framing(
Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"parameters": parameters,
}
)
)
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
set_args = set_args or {}
remove_args = set(remove_args or ())
skip = set(set_args) | remove_args | {"_next"}
pairs = [
(key, value)
for key, value in parse_qsl(query_string, keep_blank_values=True)
if key not in skip
]
for key, value in set_args.items():
if value not in (None, ""):
pairs.append((key, value))
return path + (("?" + urlencode(pairs)) if pairs else "")
class QueryListView(BaseView):
name = "query-list"
async def database_name(self, request):
return (await self.ds.resolve_database(request)).name
def query_list_path(self, database):
return self.ds.urls.database(database) + "/-/queries"
async def get(self, request):
database = await self.database_name(request)
format_ = request.url_vars.get("format") or "html"
try:
limit = _query_list_limit(
request.args.get("_size"),
default=20 if format_ == "html" else 50,
maximum=self.ds.max_returned_rows,
)
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
except QueryValidationError as ex:
return Response.error([ex.message], ex.status)
page = await self.ds.list_queries(
database,
actor=request.actor,
limit=limit,
cursor=request.args.get("_next"),
q=request.args.get("q") or None,
is_write=is_write,
is_private=is_private,
source=request.args.get("source") or None,
owner_id=request.args.get("owner_id") or None,
include_private=True,
)
query_list_path = self.query_list_path(database)
next_url = None
if page.next:
pairs = [
(key, value)
for key, value in parse_qsl(
request.query_string, keep_blank_values=True
)
if key != "_next"
]
pairs.append(("_next", page.next))
next_url = self.ds.absolute_url(
request,
"{}?{}".format(request.path, urlencode(pairs)),
)
current_filters = {
"actor": request.actor,
"q": request.args.get("q") or None,
"is_write": is_write,
"is_private": is_private,
"source": request.args.get("source") or None,
"owner_id": request.args.get("owner_id") or None,
}
async def facet_count(field, value):
if current_filters[field] is not None and current_filters[field] != value:
return 0
filters = dict(current_filters)
filters[field] = value
return await self.ds.count_queries(database, **filters)
def facet_href(field, value):
if current_filters[field] == value:
return _query_list_url(
query_list_path,
request.query_string,
remove_args=[field],
)
if current_filters[field] is not None:
return None
return _query_list_url(
query_list_path,
request.query_string,
set_args={field: str(int(value))},
)
async def facet_item(label, field, value):
count = await facet_count(field, value)
active = current_filters[field] == value
if not active and not count:
return None
return {
"label": label,
"count": count,
"href": facet_href(field, value) if active or count else None,
"active": active,
}
async def facet_items(items):
return [
item
for item in [
await facet_item(label, field, value)
for label, field, value in items
]
if item is not None
]
facets = [
{
"title": "Mode",
"items": await facet_items(
[
("Read-only", "is_write", False),
("Writable", "is_write", True),
]
),
},
{
"title": "Visibility",
"items": await facet_items(
[
("Not private", "is_private", False),
("Private", "is_private", True),
]
),
},
]
data = {
"ok": True,
"database": database,
"database_color": (
self.ds.get_database(database).color if database is not None else None
),
"queries": page.queries,
"next": page.next,
"next_url": next_url,
"limit": page.limit,
"show_private_note": any(query.is_private for query in page.queries),
"show_trusted_note": any(query.is_trusted for query in page.queries),
"query_list_path": query_list_path,
"show_database": database is None,
"facets": facets,
"filters": {
"q": request.args.get("q") or "",
"is_write": request.args.get("is_write") or "",
"is_private": request.args.get("is_private") or "",
"source": request.args.get("source") or "",
"owner_id": request.args.get("owner_id") or "",
},
}
if format_ == "json":
return Response.json(
{
**data,
"queries": [stored_query_to_dict(query) for query in page.queries],
}
)
return await self.render(
["query_list.html"],
request,
data,
)
class GlobalQueryListView(QueryListView):
name = "global-query-list"
async def database_name(self, request):
return None
def query_list_path(self, database):
return self.ds.urls.path("/-/queries")
class QueryCreateView(BaseView):
name = "query-create"
has_json_alternate = False
async def _render_form(
self,
request,
db,
*,
sql="",
name="",
title="",
description="",
is_private=True,
status=200,
):
response = await self.render(
["query_create.html"],
request,
await _query_create_form_context(
self.ds,
request,
db,
sql=sql,
name=name,
title=title,
description=description,
is_private=is_private,
),
)
response.status = status
return response
async def get(self, request):
db = await self.ds.resolve_database(request)
await self.ds.ensure_permission(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
)
await self.ds.ensure_permission(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
)
return await self._render_form(request, db, sql=request.args.get("sql") or "")
class QueryCreateAnalyzeView(BaseView):
name = "query-create-analyze"
has_json_alternate = False
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need execute-sql"], 403)
)
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
Response.error(["Permission denied: need store-query"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
Response.error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
analysis = await _query_create_analysis_data(self.ds, db, sql, request.actor)
analysis["unstable"] = UNSTABLE_API_MESSAGE
return _block_framing(Response.json(analysis))
class QueryStoreView(QueryCreateView):
name = "query-store"
async def _error_response(self, request, db, query_data, message, status):
message = _query_create_form_error_message(message)
self.ds.add_message(request, message, self.ds.ERROR)
return await self._render_form(
request,
db,
sql=query_data.get("sql") or "",
name=query_data.get("name") or "",
title=query_data.get("title") or "",
description=query_data.get("description") or "",
is_private=_as_bool(query_data.get("is_private", True)),
status=status,
)
async def post(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return Response.error(["Permission denied: need execute-sql"], 403)
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return Response.error(["Permission denied: need store-query"], 403)
is_json = False
query_data = {}
try:
data, is_json = await _json_or_form_payload(request)
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
query_data = data.get("query") if is_json else data
if not isinstance(query_data, dict):
raise QueryValidationError("JSON must contain a query dictionary")
prepared = await _prepare_query_create(self.ds, request, db, query_data)
except QueryValidationError as ex:
if not is_json and isinstance(query_data, dict):
return await self._error_response(
request, db, query_data, ex.message, ex.status
)
return Response.error([ex.message], ex.status)
prepared.pop("analysis")
name = prepared.pop("name")
try:
await self.ds.add_query(db.name, name, replace=False, **prepared)
except sqlite3.IntegrityError as ex:
if not is_json and isinstance(query_data, dict):
return await self._error_response(request, db, query_data, str(ex), 400)
return Response.error([str(ex)], 400)
query = await self.ds.get_query(db.name, name)
assert query is not None
if is_json:
return Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
},
status=201,
)
self.ds.add_message(request, "Query saved", self.ds.INFO)
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
class QueryDefinitionView(BaseView):
name = "query-definition"
async def get(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name)
if query is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="view-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied"], 403)
return Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
}
)
class QueryUpdateView(BaseView):
name = "query-update"
async def post(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be updated using the API"], 403
)
try:
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
invalid_keys = set(data) - {"update", "return"}
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(invalid_keys))
)
update = data.get("update")
if not isinstance(update, dict):
raise QueryValidationError("JSON must contain an update dictionary")
if "sql" in update and not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
raise QueryValidationError(
"Permission denied: need execute-sql", status=403
)
update_kwargs = await _prepare_query_update(
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
return Response.error([ex.message], ex.status)
await self.ds.update_query(db.name, query_name, **update_kwargs)
if data.get("return"):
query = await self.ds.get_query(db.name, query_name)
assert query is not None
return Response.json(
{
"ok": True,
"query": stored_query_to_dict(query),
}
)
return Response.json({"ok": True})
class QueryEditView(BaseView):
name = "query-edit"
has_json_alternate = False
async def _load(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
return db, query_name, existing
async def _render_form(
self,
request,
db,
existing,
*,
sql=None,
title=None,
description=None,
is_private=None,
status=200,
):
response = await self.render(
["query_edit.html"],
request,
await _query_edit_form_context(
self.ds,
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
),
)
response.status = status
return response
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return Response.error(["Trusted queries cannot be edited"], 403)
return await self._render_form(request, db, existing)
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return Response.error(["Trusted queries cannot be edited"], 403)
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
return Response.error(["Invalid form submission"], 400)
sql = data.get("sql")
sql = existing.sql if sql is None else sql.strip()
title = data.get("title") or ""
description = data.get("description") or ""
is_private = _as_bool(data.get("is_private"))
update = {
"title": title,
"description": description,
"is_private": is_private,
}
if sql != existing.sql:
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
self.ds.add_message(
request,
"Permission denied: need execute-sql to change the SQL",
self.ds.ERROR,
)
return await self._render_form(
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
status=403,
)
update["sql"] = sql
try:
update_kwargs = await _prepare_query_update(
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
status=ex.status,
)
await self.ds.update_query(db.name, query_name, **update_kwargs)
self.ds.add_message(request, "Query updated", self.ds.INFO)
return Response.redirect(
self.ds.urls.path(self.ds.urls.table(db.name, query_name))
)
class QueryDeleteView(BaseView):
name = "query-delete"
has_json_alternate = False
async def _load(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
return db, query_name, existing
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
return await self.render(
["query_delete.html"],
request,
{
"database": db.name,
"database_color": db.color,
"query": stored_query_to_dict(existing),
"query_url": self.ds.urls.table(db.name, query_name),
},
)
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return Response.error(["Permission denied: need delete-query"], 403)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name)
if is_json:
return Response.json({"ok": True})
self.ds.add_message(
request,
"Query “{}” deleted".format(existing.title or query_name),
self.ds.INFO,
)
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -20,4 +20,4 @@ help:
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
livehtml: livehtml:
sphinx-autobuild -b html --watch ../datasette "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0) sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)

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