mirror of
https://github.com/simonw/datasette.git
synced 2026-07-26 17:34:35 +02:00
Compare commits
No commits in common. "main" and "1.0a32" have entirely different histories.
222 changed files with 4544 additions and 31724 deletions
39
.github/actions/setup-sqlite-version/action.yml
vendored
39
.github/actions/setup-sqlite-version/action.yml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
2
.github/workflows/deploy-latest.yml
vendored
2
.github/workflows/deploy-latest.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
16
.github/workflows/documentation-links.yml
vendored
Normal file
16
.github/workflows/documentation-links.yml
vendored
Normal 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"
|
||||
48
.github/workflows/playwright.yml
vendored
48
.github/workflows/playwright.yml
vendored
|
|
@ -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 }}
|
||||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Configure npm caching
|
||||
with:
|
||||
path: ~/.npm
|
||||
|
|
|
|||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
deploy_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/spellcheck.yml
vendored
2
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/stable-docs.yml
vendored
2
.github/workflows/stable-docs.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||
- name: Set up Git user
|
||||
|
|
|
|||
2
.github/workflows/test-coverage.yml
vendored
2
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/test-pyodide.yml
vendored
4
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-browsers
|
||||
|
|
|
|||
4
.github/workflows/test-sqlite-support.yml
vendored
4
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
#"3.23.1" # 2018-04-10, before UPSERT
|
||||
]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
||||
uses: ./.github/actions/setup-sqlite-version
|
||||
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
|
||||
with:
|
||||
version: ${{ matrix.sqlite-version }}
|
||||
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
||||
|
|
|
|||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
|
@ -9,11 +9,10 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
|||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
|
|
|
|||
2
.github/workflows/tmate.yml
vendored
2
.github/workflows/tmate.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,12 +1,8 @@
|
|||
build-metadata.json
|
||||
datasets.json
|
||||
|
||||
.playwright-mcp
|
||||
|
||||
scratchpad
|
||||
|
||||
ignored/
|
||||
|
||||
.vscode
|
||||
|
||||
uv.lock
|
||||
|
|
@ -135,4 +131,4 @@ tests/*.dylib
|
|||
tests/*.so
|
||||
tests/*.dll
|
||||
|
||||
.idea
|
||||
.idea
|
||||
19
Justfile
19
Justfile
|
|
@ -11,33 +11,16 @@ export DATASETTE_SECRET := "not_a_secret"
|
|||
@test *options: init
|
||||
uv run pytest -n auto {{options}}
|
||||
|
||||
# Install Playwright browser support, Chromium by default
|
||||
@playwright-install browser="chromium":
|
||||
uv run --group playwright playwright install {{browser}}
|
||||
|
||||
# Install all Playwright browsers used by the test suite
|
||||
@playwright-install-all:
|
||||
uv run --group playwright playwright install chromium firefox webkit
|
||||
|
||||
# Run Playwright tests, Chromium by default
|
||||
@playwright browser="chromium" *options:
|
||||
uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}}
|
||||
|
||||
# Run Playwright tests against all supported browsers
|
||||
@playwright-all *options:
|
||||
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}}
|
||||
|
||||
@codespell:
|
||||
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Run linters: black, ruff, prettier, cog
|
||||
# Run linters: black, ruff, cog
|
||||
@lint: codespell
|
||||
uv run black datasette tests --check
|
||||
uv run ruff check datasette tests
|
||||
npm run prettier -- --check
|
||||
uv run cog --check README.md docs/*.rst
|
||||
|
||||
# Apply ruff fixes
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
from datasette.permissions import Permission # noqa
|
||||
from datasette.version import __version_info__, __version__ # noqa
|
||||
from datasette.events import Event # noqa
|
||||
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import ( # noqa
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PayloadTooLarge,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
||||
from datasette.utils import actor_matches_allow # noqa
|
||||
from datasette.views import Context # noqa
|
||||
from .hookspecs import hookimpl # noqa
|
||||
|
|
|
|||
|
|
@ -19,38 +19,23 @@ import weakref
|
|||
|
||||
import pytest
|
||||
|
||||
from datasette.app import Datasette
|
||||
|
||||
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
|
||||
"datasette_active_instances", default=None
|
||||
)
|
||||
|
||||
_original_init = None
|
||||
_original_init = Datasette.__init__
|
||||
|
||||
|
||||
def _install_tracking():
|
||||
# datasette.app is imported lazily here rather than at module level:
|
||||
# as a pytest11 entry point this module is imported during pytest
|
||||
# startup, before pytest-cov starts measuring, so a module-level
|
||||
# import would drag in all of datasette and make every import-time
|
||||
# line in the package invisible to coverage
|
||||
global _original_init
|
||||
if _original_init is not None:
|
||||
return
|
||||
from datasette.app import Datasette
|
||||
|
||||
_original_init = Datasette.__init__
|
||||
|
||||
def _tracking_init(self, *args, **kwargs):
|
||||
_original_init(self, *args, **kwargs)
|
||||
instances = _active_instances.get()
|
||||
if instances is not None:
|
||||
instances.append(weakref.ref(self))
|
||||
|
||||
Datasette.__init__ = _tracking_init
|
||||
def _tracking_init(self, *args, **kwargs):
|
||||
_original_init(self, *args, **kwargs)
|
||||
instances = _active_instances.get()
|
||||
if instances is not None:
|
||||
instances.append(weakref.ref(self))
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
if _enabled(config):
|
||||
_install_tracking()
|
||||
Datasette.__init__ = _tracking_init
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
|
|
@ -89,8 +74,7 @@ def pytest_runtest_protocol(item, nextitem):
|
|||
continue
|
||||
try:
|
||||
ds.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Surfaced as a pytest warning; teardown must not fail the run
|
||||
except Exception as e:
|
||||
item.warn(
|
||||
pytest.PytestUnraisableExceptionWarning(
|
||||
f"Error closing Datasette instance: {e!r}"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import time
|
||||
|
||||
from itsdangerous import BadSignature
|
||||
|
||||
from datasette import hookimpl
|
||||
from itsdangerous import BadSignature
|
||||
from datasette.utils import baseconv
|
||||
import time
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
|
|||
754
datasette/app.py
754
datasette/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,7 @@
|
|||
import hashlib
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.utils.asgi import Response, BadRequest
|
||||
from datasette.utils import to_css_class
|
||||
from datasette.utils.asgi import BadRequest, Response
|
||||
import hashlib
|
||||
|
||||
_BLOB_COLUMN = "_blob_column"
|
||||
_BLOB_HASH = "_blob_hash"
|
||||
|
|
|
|||
|
|
@ -1,45 +1,43 @@
|
|||
import asyncio
|
||||
import uvicorn
|
||||
import click
|
||||
from click import formatting
|
||||
from click.types import CompositeParamType
|
||||
from click_default_group import DefaultGroup
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from runpy import run_module
|
||||
import shutil
|
||||
from subprocess import call
|
||||
import sys
|
||||
import textwrap
|
||||
import webbrowser
|
||||
from runpy import run_module
|
||||
from subprocess import call
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
from click import formatting
|
||||
from click.types import CompositeParamType
|
||||
from click_default_group import DefaultGroup
|
||||
|
||||
from .app import (
|
||||
Datasette,
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS,
|
||||
SQLITE_LIMIT_ATTACHED,
|
||||
Datasette,
|
||||
pm,
|
||||
)
|
||||
from .inspect import inspect_tables
|
||||
from .utils import (
|
||||
ConnectionProblem,
|
||||
LoadExtension,
|
||||
SpatialiteConnectionProblem,
|
||||
SpatialiteNotFound,
|
||||
StartupError,
|
||||
StaticMount,
|
||||
ValueAsBooleanError,
|
||||
check_connection,
|
||||
deep_dict_update,
|
||||
find_spatialite,
|
||||
parse_metadata,
|
||||
ConnectionProblem,
|
||||
SpatialiteConnectionProblem,
|
||||
initial_path_for_datasette,
|
||||
pairs_to_nested_config,
|
||||
parse_metadata,
|
||||
temporary_docker_directory,
|
||||
value_as_boolean,
|
||||
SpatialiteNotFound,
|
||||
StaticMount,
|
||||
ValueAsBooleanError,
|
||||
)
|
||||
from .utils.sqlite import sqlite3
|
||||
from .utils.testing import TestClient
|
||||
|
|
@ -77,7 +75,7 @@ class Setting(CompositeParamType):
|
|||
# Datasette 1.0, we turn bare setting names into setting.name
|
||||
# Type checking for those older settings
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
name = f"settings.{name}"
|
||||
name = "settings.{}".format(name)
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, "true" if value_as_boolean(value) else "false"
|
||||
|
|
@ -173,6 +171,7 @@ async def inspect_(files, sqlite_extensions):
|
|||
@cli.group()
|
||||
def publish():
|
||||
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
||||
pass
|
||||
|
||||
|
||||
# Register publish plugins
|
||||
|
|
@ -579,27 +578,27 @@ def serve(
|
|||
# https://github.com/simonw/datasette/issues/2389
|
||||
deep_dict_update(config_data, settings_updates)
|
||||
|
||||
kwargs = {
|
||||
"immutables": immutable,
|
||||
"cache_headers": not reload,
|
||||
"cors": cors,
|
||||
"inspect_data": inspect_data,
|
||||
"config": config_data,
|
||||
"metadata": metadata_data,
|
||||
"sqlite_extensions": sqlite_extensions,
|
||||
"template_dir": template_dir,
|
||||
"plugins_dir": plugins_dir,
|
||||
"static_mounts": static,
|
||||
"settings": None, # These are passed in config= now
|
||||
"memory": memory,
|
||||
"secret": secret,
|
||||
"version_note": version_note,
|
||||
"pdb": pdb,
|
||||
"crossdb": crossdb,
|
||||
"nolock": nolock,
|
||||
"internal": internal,
|
||||
"default_deny": default_deny,
|
||||
}
|
||||
kwargs = dict(
|
||||
immutables=immutable,
|
||||
cache_headers=not reload,
|
||||
cors=cors,
|
||||
inspect_data=inspect_data,
|
||||
config=config_data,
|
||||
metadata=metadata_data,
|
||||
sqlite_extensions=sqlite_extensions,
|
||||
template_dir=template_dir,
|
||||
plugins_dir=plugins_dir,
|
||||
static_mounts=static,
|
||||
settings=None, # These are passed in config= now
|
||||
memory=memory,
|
||||
secret=secret,
|
||||
version_note=version_note,
|
||||
pdb=pdb,
|
||||
crossdb=crossdb,
|
||||
nolock=nolock,
|
||||
internal=internal,
|
||||
default_deny=default_deny,
|
||||
)
|
||||
|
||||
# Separate directories from files
|
||||
directories = [f for f in files if os.path.isdir(f)]
|
||||
|
|
@ -622,7 +621,9 @@ def serve(
|
|||
conn.close()
|
||||
else:
|
||||
raise click.ClickException(
|
||||
f"Invalid value for '[FILES]...': Path '{file}' does not exist."
|
||||
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
|
||||
file
|
||||
)
|
||||
)
|
||||
|
||||
# Check for duplicate files by resolving all paths to their absolute forms
|
||||
|
|
@ -683,7 +684,7 @@ def serve(
|
|||
client = TestClient(ds)
|
||||
request_headers = {}
|
||||
if token:
|
||||
request_headers["Authorization"] = f"Bearer {token}"
|
||||
request_headers["Authorization"] = "Bearer {}".format(token)
|
||||
cookies = {}
|
||||
if actor:
|
||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||
|
|
@ -718,13 +719,9 @@ def serve(
|
|||
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||
url = f"http://{host}:{port}{path}"
|
||||
webbrowser.open(url)
|
||||
uvicorn_kwargs = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"log_level": "info",
|
||||
"lifespan": "on",
|
||||
"workers": 1,
|
||||
}
|
||||
uvicorn_kwargs = dict(
|
||||
host=host, port=port, log_level="info", lifespan="on", workers=1
|
||||
)
|
||||
if uds:
|
||||
uvicorn_kwargs["uds"] = uds
|
||||
if ssl_keyfile:
|
||||
|
|
@ -888,7 +885,7 @@ async def check_databases(ds):
|
|||
)
|
||||
except ConnectionProblem as e:
|
||||
raise click.UsageError(
|
||||
f"Connection to {database.path} failed check: {e.args[0]!s}"
|
||||
f"Connection to {database.path} failed check: {str(e.args[0])}"
|
||||
)
|
||||
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
|
||||
if (
|
||||
|
|
@ -896,5 +893,9 @@ async def check_databases(ds):
|
|||
and len([db for db in ds.databases.values() if not db.is_memory])
|
||||
> SQLITE_LIMIT_ATTACHED
|
||||
):
|
||||
msg = f"Warning: --crossdb only works with the first {SQLITE_LIMIT_ATTACHED} attached databases"
|
||||
msg = (
|
||||
"Warning: --crossdb only works with the first {} attached databases".format(
|
||||
SQLITE_LIMIT_ATTACHED
|
||||
)
|
||||
)
|
||||
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)
|
||||
|
|
|
|||
|
|
@ -6,17 +6,19 @@ class SQLiteType(Enum):
|
|||
INTEGER = "INTEGER"
|
||||
REAL = "REAL"
|
||||
BLOB = "BLOB"
|
||||
NUMERIC = "NUMERIC"
|
||||
NULL = "NULL"
|
||||
|
||||
@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:
|
||||
return cls.BLOB
|
||||
return cls.NULL
|
||||
|
||||
normalized = declared_type.strip().upper()
|
||||
if not normalized:
|
||||
return cls.BLOB
|
||||
return cls.NULL
|
||||
|
||||
if normalized == cls.NULL.value:
|
||||
return cls.NULL
|
||||
if "INT" in normalized:
|
||||
return cls.INTEGER
|
||||
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
||||
|
|
@ -29,7 +31,7 @@ class SQLiteType(Enum):
|
|||
):
|
||||
return cls.REAL
|
||||
|
||||
return cls.NUMERIC
|
||||
return None
|
||||
|
||||
|
||||
class ColumnType:
|
||||
|
|
@ -64,14 +66,14 @@ class ColumnType:
|
|||
Return an HTML string to render this cell value, or None to
|
||||
fall through to the default render_cell plugin hook chain.
|
||||
"""
|
||||
return
|
||||
return None
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
"""
|
||||
Validate a value before it is written. Return None if valid,
|
||||
or a string error message if invalid.
|
||||
"""
|
||||
return
|
||||
return None
|
||||
|
||||
async def transform_value(self, value, datasette):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ def _origin_tuple(value):
|
|||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not scheme or not host:
|
||||
raise ValueError(f"missing scheme or host in {value!r}")
|
||||
raise ValueError("missing scheme or host in {!r}".format(value))
|
||||
port = parsed.port # may raise ValueError on bad ports
|
||||
if port is None:
|
||||
port = DEFAULT_PORTS.get(scheme)
|
||||
if port is None:
|
||||
raise ValueError(f"unknown default port for scheme {scheme!r}")
|
||||
raise ValueError("unknown default port for scheme {!r}".format(scheme))
|
||||
return scheme, host, port
|
||||
|
||||
|
||||
|
|
@ -125,7 +125,9 @@ class CrossOriginProtectionMiddleware:
|
|||
return
|
||||
await self._forbid(
|
||||
send,
|
||||
f"Sec-Fetch-Site was {sec_fetch_site!r}, expected 'same-origin' or 'none'",
|
||||
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
|
||||
sec_fetch_site
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -139,11 +141,11 @@ class CrossOriginProtectionMiddleware:
|
|||
request_scheme = self._request_scheme(scope)
|
||||
try:
|
||||
origin_tuple = _origin_tuple(origin)
|
||||
expected_tuple = _origin_tuple(f"{request_scheme}://{host}")
|
||||
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
|
||||
except ValueError:
|
||||
await self._forbid(
|
||||
send,
|
||||
f"Malformed Origin {origin!r} or Host {host!r}",
|
||||
"Malformed Origin {!r} or Host {!r}".format(origin, host),
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -153,7 +155,7 @@ class CrossOriginProtectionMiddleware:
|
|||
|
||||
await self._forbid(
|
||||
send,
|
||||
f"Origin {origin!r} does not match Host {host!r}",
|
||||
"Origin {!r} does not match Host {!r}".format(origin, host),
|
||||
)
|
||||
|
||||
def _request_scheme(self, scope):
|
||||
|
|
@ -161,8 +163,7 @@ class CrossOriginProtectionMiddleware:
|
|||
try:
|
||||
if self.datasette.setting("force_https_urls"):
|
||||
return "https"
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Settings may not be readable this early; fall back to the ASGI scheme
|
||||
except Exception:
|
||||
pass
|
||||
return scope.get("scheme") or "http"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,33 @@
|
|||
import asyncio
|
||||
import atexit
|
||||
from collections import namedtuple
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import sqlite_utils
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
|
||||
import sqlite_utils
|
||||
|
||||
from .inspect import inspect_hash
|
||||
from .tracer import trace
|
||||
from .utils import (
|
||||
call_with_supported_arguments,
|
||||
detect_fts,
|
||||
detect_primary_keys,
|
||||
detect_spatialite,
|
||||
escape_sqlite,
|
||||
get_all_foreign_keys,
|
||||
get_outbound_foreign_keys,
|
||||
md5_not_usedforsecurity,
|
||||
sqlite3,
|
||||
sqlite_timelimit,
|
||||
table_column_details,
|
||||
sqlite3,
|
||||
table_columns,
|
||||
table_column_details,
|
||||
)
|
||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||
from .utils.sqlite import sqlite_hidden_table_names
|
||||
from .inspect import inspect_hash
|
||||
|
||||
connections = threading.local()
|
||||
|
||||
|
|
@ -100,7 +98,9 @@ class Database:
|
|||
|
||||
def _check_not_closed(self):
|
||||
if self._closed:
|
||||
raise DatasetteClosedError(f"Database {self.name!r} has been closed")
|
||||
raise DatasetteClosedError(
|
||||
"Database {!r} has been closed".format(self.name)
|
||||
)
|
||||
|
||||
def _remove_pending_execute_future(self, future):
|
||||
with self._pending_execute_futures_lock:
|
||||
|
|
@ -139,7 +139,7 @@ class Database:
|
|||
if write:
|
||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||
if self.memory_name:
|
||||
uri = f"file:{self.memory_name}?mode=memory&cache=shared"
|
||||
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
|
||||
conn = sqlite3.connect(
|
||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||
)
|
||||
|
|
@ -192,20 +192,21 @@ class Database:
|
|||
write_thread.join(timeout=10)
|
||||
if write_thread.is_alive():
|
||||
sys.stderr.write(
|
||||
f"Datasette: write thread for {self.name!r} did not exit within 10s\n"
|
||||
"Datasette: write thread for {!r} did not exit within 10s\n".format(
|
||||
self.name
|
||||
)
|
||||
)
|
||||
sys.stderr.flush()
|
||||
for future in pending_execute_futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Shutdown teardown - a failed pending write must not block close()
|
||||
except Exception:
|
||||
pass
|
||||
# Close anything still tracked in _all_file_connections
|
||||
for connection in self._all_file_connections:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
pass
|
||||
self._all_file_connections = []
|
||||
# Drop per-thread cached read connections we can reach
|
||||
|
|
@ -217,13 +218,13 @@ class Database:
|
|||
if self._read_connection is not None:
|
||||
try:
|
||||
self._read_connection.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
pass
|
||||
self._read_connection = None
|
||||
if self._write_connection is not None:
|
||||
try:
|
||||
self._write_connection.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
pass
|
||||
self._write_connection = None
|
||||
if self.is_temp_disk:
|
||||
|
|
@ -245,7 +246,6 @@ class Database:
|
|||
request=None,
|
||||
return_all=False,
|
||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||
transaction=True,
|
||||
):
|
||||
self._check_not_closed()
|
||||
if returning_limit < 0:
|
||||
|
|
@ -258,9 +258,7 @@ class Database:
|
|||
)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(
|
||||
_inner, block=block, request=request, transaction=transaction
|
||||
)
|
||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||
return results
|
||||
|
||||
async def execute_write_script(self, sql, block=True, request=None):
|
||||
|
|
@ -300,14 +298,13 @@ class Database:
|
|||
|
||||
async def execute_isolated_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
# Open a new connection just for the duration of this function,
|
||||
# Open a new connection just for the duration of this function
|
||||
# blocking the write queue to avoid any writes occurring during it
|
||||
write = self.is_mutable
|
||||
|
||||
def _run():
|
||||
isolated_connection = self.connect(write=write)
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
return fn(isolated_connection)
|
||||
result = fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
|
|
@ -315,18 +312,10 @@ class Database:
|
|||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
return _run()
|
||||
if not write:
|
||||
# Immutable database - no writes can ever occur, so there is no
|
||||
# write queue to block; run against a fresh read-only connection
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
self.ds.executor, _run
|
||||
)
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
return result
|
||||
else:
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
|
||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
||||
self._check_not_closed()
|
||||
|
|
@ -350,7 +339,6 @@ class Database:
|
|||
self.ds._prepare_connection(self._write_connection, self.name)
|
||||
if transaction:
|
||||
with self._write_connection:
|
||||
self._write_connection.execute("BEGIN IMMEDIATE")
|
||||
result = fn(self._write_connection)
|
||||
else:
|
||||
result = fn(self._write_connection)
|
||||
|
|
@ -369,8 +357,7 @@ class Database:
|
|||
async def _dispatch_events_after_write():
|
||||
try:
|
||||
await reply_future
|
||||
except Exception: # noqa: BLE001
|
||||
# The write failed; skip success events regardless of why
|
||||
except Exception:
|
||||
# if the write failed, don't emit success events
|
||||
return
|
||||
for event in pending_events:
|
||||
|
|
@ -423,7 +410,9 @@ class Database:
|
|||
self._write_thread = threading.Thread(
|
||||
target=self._execute_writes, daemon=True
|
||||
)
|
||||
self._write_thread.name = f"_execute_writes for database {self.name}"
|
||||
self._write_thread.name = "_execute_writes for database {}".format(
|
||||
self.name
|
||||
)
|
||||
self._write_thread.start()
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
loop = asyncio.get_running_loop()
|
||||
|
|
@ -444,8 +433,7 @@ class Database:
|
|||
try:
|
||||
conn = self.connect(write=True)
|
||||
self.ds._prepare_connection(conn, self.name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Stored and re-raised to whoever queues the next write
|
||||
except Exception as e:
|
||||
conn_exception = e
|
||||
while True:
|
||||
task = self._write_queue.get()
|
||||
|
|
@ -453,8 +441,7 @@ class Database:
|
|||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Best-effort close as the write thread exits
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
exception = None
|
||||
|
|
@ -462,32 +449,29 @@ class Database:
|
|||
if conn_exception is not None:
|
||||
exception = conn_exception
|
||||
elif task.isolated_connection:
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = task.fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Write thread must survive any task failure or the database wedges
|
||||
sys.stderr.write(f"{e}\n")
|
||||
result = task.fn(isolated_connection)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.stderr.write(f"{e}\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
_deliver_write_result(task, result, exception)
|
||||
|
|
@ -554,7 +538,9 @@ class Database:
|
|||
raise QueryInterrupted(e, sql, params)
|
||||
if log_sql_errors:
|
||||
sys.stderr.write(
|
||||
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n"
|
||||
"ERROR: conn={}, sql = {}, params = {}: {}\n".format(
|
||||
conn, repr(sql), params, e
|
||||
)
|
||||
)
|
||||
sys.stderr.flush()
|
||||
raise
|
||||
|
|
@ -607,7 +593,7 @@ class Database:
|
|||
try:
|
||||
table_count = (
|
||||
await self.execute(
|
||||
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
|
||||
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
|
||||
custom_time_limit=limit,
|
||||
)
|
||||
).rows[0][0]
|
||||
|
|
@ -711,9 +697,9 @@ class Database:
|
|||
column_names
|
||||
and len(column_names) == 2
|
||||
and ("id" in column_names or "pk" in column_names)
|
||||
and set(column_names) != {"id", "pk"}
|
||||
and not set(column_names) == {"id", "pk"}
|
||||
):
|
||||
return next(c for c in column_names if c not in ("id", "pk"))
|
||||
return [c for c in column_names if c not in ("id", "pk")][0]
|
||||
# Couldn't find a label:
|
||||
return None
|
||||
|
||||
|
|
@ -833,10 +819,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
|||
# Execute the actual write
|
||||
try:
|
||||
result = fn(conn)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Throw exception into generator so it can handle it
|
||||
try:
|
||||
gen.throw(e)
|
||||
gen.throw(*sys.exc_info())
|
||||
except StopIteration:
|
||||
pass
|
||||
# Re-raise the original exception
|
||||
|
|
@ -855,10 +841,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
|||
class WriteTask:
|
||||
__slots__ = (
|
||||
"fn",
|
||||
"isolated_connection",
|
||||
"task_id",
|
||||
"loop",
|
||||
"reply_future",
|
||||
"task_id",
|
||||
"isolated_connection",
|
||||
"transaction",
|
||||
)
|
||||
|
||||
|
|
@ -899,7 +885,7 @@ class QueryInterrupted(Exception):
|
|||
self.params = params
|
||||
|
||||
def __str__(self):
|
||||
return f"QueryInterrupted: {self.e}"
|
||||
return "QueryInterrupted: {}".format(self.e)
|
||||
|
||||
|
||||
class MultipleValues(Exception):
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ from datasette import hookimpl
|
|||
from datasette.permissions import Action
|
||||
from datasette.resources import (
|
||||
DatabaseResource,
|
||||
QueryResource,
|
||||
TableResource,
|
||||
QueryResource,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -61,12 +61,6 @@ def register_actions():
|
|||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="create-view",
|
||||
abbr="cv",
|
||||
description="Create views",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="store-query",
|
||||
abbr="sq",
|
||||
|
|
@ -117,12 +111,6 @@ def register_actions():
|
|||
description="Drop tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-view",
|
||||
abbr="dv",
|
||||
description="Drop views",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Query-level actions (child-level)
|
||||
Action(
|
||||
name="view-query",
|
||||
|
|
|
|||
|
|
@ -76,12 +76,6 @@ class JsonColumnType(ColumnType):
|
|||
return None
|
||||
|
||||
|
||||
class TextareaColumnType(ColumnType):
|
||||
name = "textarea"
|
||||
description = "Multiline text"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_column_types(datasette):
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType]
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType]
|
||||
|
|
|
|||
|
|
@ -37,11 +37,6 @@ DEBUG_MENU_ITEMS = (
|
|||
"Debug allow rules",
|
||||
"Explore how allow blocks match actors against permission rules.",
|
||||
),
|
||||
(
|
||||
"/-/debug/autocomplete",
|
||||
"Debug autocomplete",
|
||||
"Try out table autocomplete against a detected label column.",
|
||||
),
|
||||
(
|
||||
"/-/threads",
|
||||
"Debug threads",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
from datasette import hookimpl
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
def header(key, request):
|
||||
key = key.replace("_", "-").encode("utf-8")
|
||||
|
|
|
|||
|
|
@ -17,29 +17,18 @@ UNION/INTERSECT operations. The order of evaluation is:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import config_permissions_sql as config_permissions_sql
|
||||
from .defaults import (
|
||||
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||
)
|
||||
from .defaults import (
|
||||
default_action_permissions_sql as default_action_permissions_sql,
|
||||
)
|
||||
from .defaults import (
|
||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
||||
default_allow_sql_check as default_allow_sql_check,
|
||||
)
|
||||
from .defaults import (
|
||||
default_query_permissions_sql as default_query_permissions_sql,
|
||||
)
|
||||
from .restrictions import (
|
||||
ActorRestrictions as ActorRestrictions,
|
||||
)
|
||||
|
||||
# Re-export all hooks and public utilities
|
||||
from .restrictions import (
|
||||
actor_restrictions_sql as actor_restrictions_sql,
|
||||
)
|
||||
from .restrictions import (
|
||||
restrictions_allow_action as restrictions_allow_action,
|
||||
ActorRestrictions as ActorRestrictions,
|
||||
)
|
||||
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||
from .config import config_permissions_sql as config_permissions_sql
|
||||
from .defaults import (
|
||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
||||
default_allow_sql_check as default_allow_sql_check,
|
||||
default_action_permissions_sql as default_action_permissions_sql,
|
||||
default_query_permissions_sql as default_query_permissions_sql,
|
||||
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
):
|
||||
self.datasette = datasette
|
||||
|
|
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
|
|||
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||
|
||||
# Pre-compute restriction info for efficiency
|
||||
self.restricted_databases: set[str] = set()
|
||||
self.restricted_tables: set[tuple[str, str]] = set()
|
||||
self.restricted_databases: Set[str] = set()
|
||||
self.restricted_tables: Set[Tuple[str, str]] = set()
|
||||
|
||||
if self.has_restrictions:
|
||||
self.restricted_databases = {
|
||||
|
|
@ -92,20 +92,16 @@ class ConfigPermissionProcessor:
|
|||
# Tables implicitly reference their parent databases
|
||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
||||
|
||||
def evaluate_allow_block(self, allow_block: Any) -> bool | None:
|
||||
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
|
||||
"""Evaluate an allow block against the current actor."""
|
||||
if allow_block is None:
|
||||
return None
|
||||
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
|
||||
# integers, but should retain the CLI's boolean 1/0 behavior.
|
||||
if isinstance(allow_block, int) and allow_block in (0, 1):
|
||||
return bool(allow_block)
|
||||
return actor_matches_allow(self.actor, allow_block)
|
||||
|
||||
def is_in_restriction_allowlist(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
) -> bool:
|
||||
"""Check if resource is allowed by actor restrictions."""
|
||||
if not self.has_restrictions:
|
||||
|
|
@ -147,9 +143,9 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def add_permissions_rule(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
permissions_block: dict | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
permissions_block: Optional[dict],
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""Add a rule from a permissions:{action} block."""
|
||||
|
|
@ -169,8 +165,8 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def add_allow_block_rule(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow_block: Any,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
|
|
@ -202,8 +198,8 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def _add_restriction_gate_denies(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
is_allowed: bool,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
|
|
@ -235,7 +231,7 @@ class ConfigPermissionProcessor:
|
|||
if db_name == parent:
|
||||
self.collector.add(db_name, table_name, False, reason)
|
||||
|
||||
def process(self) -> PermissionSQL | None:
|
||||
def process(self) -> Optional[PermissionSQL]:
|
||||
"""Process all config rules and return combined PermissionSQL."""
|
||||
self._process_root_permissions()
|
||||
self._process_databases()
|
||||
|
|
@ -425,10 +421,10 @@ class ConfigPermissionProcessor:
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def config_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> list[PermissionSQL] | None:
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Apply permission rules from datasette.yaml configuration.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Provides default allow rules for standard view/execute actions.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -29,28 +29,29 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_allow_sql_check(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> PermissionSQL | None:
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Enforce the default_allow_sql setting.
|
||||
|
||||
When default_allow_sql is false (the default), execute-sql is denied
|
||||
unless explicitly allowed by config or other rules.
|
||||
"""
|
||||
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||
if action == "execute-sql":
|
||||
if not datasette.setting("default_allow_sql"):
|
||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_action_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> PermissionSQL | None:
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Provide default allow rules for standard view/execute actions.
|
||||
|
||||
|
|
@ -70,10 +71,10 @@ async def default_action_permissions_sql(
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_query_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> PermissionSQL | None:
|
||||
) -> Optional[PermissionSQL]:
|
||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
if action not in {"view-query", "update-query", "delete-query"}:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, List, Optional, Set
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
|
||||
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""
|
||||
Get all name variants for an action (full name and abbreviation).
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
|
|||
return variants
|
||||
|
||||
|
||||
def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool:
|
||||
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
|
||||
"""Check if an action (or its abbreviation) is in a list."""
|
||||
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
||||
|
||||
|
|
@ -36,8 +36,8 @@ def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool
|
|||
class PermissionRow:
|
||||
"""A single permission rule row."""
|
||||
|
||||
parent: str | None
|
||||
child: str | None
|
||||
parent: Optional[str]
|
||||
child: Optional[str]
|
||||
allow: bool
|
||||
reason: str
|
||||
|
||||
|
|
@ -46,14 +46,14 @@ class PermissionRowCollector:
|
|||
"""Collects permission rows and converts them to PermissionSQL."""
|
||||
|
||||
def __init__(self, prefix: str = "row"):
|
||||
self.rows: list[PermissionRow] = []
|
||||
self.rows: List[PermissionRow] = []
|
||||
self.prefix = prefix
|
||||
|
||||
def add(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
allow: bool | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow: Optional[bool],
|
||||
reason: str,
|
||||
if_not_none: bool = False,
|
||||
) -> None:
|
||||
|
|
@ -62,7 +62,7 @@ class PermissionRowCollector:
|
|||
return
|
||||
self.rows.append(PermissionRow(parent, child, allow, reason))
|
||||
|
||||
def to_permission_sql(self) -> PermissionSQL | None:
|
||||
def to_permission_sql(self) -> Optional[PermissionSQL]:
|
||||
"""Convert collected rows to a PermissionSQL object."""
|
||||
if not self.rows:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
|
|||
class ActorRestrictions:
|
||||
"""Parsed actor restrictions from the _r key."""
|
||||
|
||||
global_actions: list[str] # _r.a - globally allowed actions
|
||||
global_actions: List[str] # _r.a - globally allowed actions
|
||||
database_actions: dict # _r.d - {db_name: [actions]}
|
||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||
|
||||
@classmethod
|
||||
def from_actor(cls, actor: dict | None) -> ActorRestrictions | None:
|
||||
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
|
||||
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
||||
if not actor:
|
||||
return None
|
||||
|
|
@ -44,11 +44,11 @@ class ActorRestrictions:
|
|||
table_actions=restrictions.get("r", {}),
|
||||
)
|
||||
|
||||
def is_action_globally_allowed(self, datasette: Datasette, action: str) -> bool:
|
||||
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
|
||||
"""Check if action is in the global allowlist."""
|
||||
return action_in_list(datasette, action, self.global_actions)
|
||||
|
||||
def get_allowed_databases(self, datasette: Datasette, action: str) -> set[str]:
|
||||
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""Get database names where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, db_actions in self.database_actions.items():
|
||||
|
|
@ -57,8 +57,8 @@ class ActorRestrictions:
|
|||
return allowed
|
||||
|
||||
def get_allowed_tables(
|
||||
self, datasette: Datasette, action: str
|
||||
) -> set[tuple[str, str]]:
|
||||
self, datasette: "Datasette", action: str
|
||||
) -> Set[Tuple[str, str]]:
|
||||
"""Get (database, table) pairs where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, tables in self.table_actions.items():
|
||||
|
|
@ -70,10 +70,10 @@ class ActorRestrictions:
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def actor_restrictions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> list[PermissionSQL] | None:
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Handle actor restriction-based permission rules.
|
||||
|
||||
|
|
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
|
|||
|
||||
|
||||
def restrictions_allow_action(
|
||||
datasette: Datasette,
|
||||
datasette: "Datasette",
|
||||
restrictions: dict,
|
||||
action: str,
|
||||
resource: str | tuple[str, str] | None,
|
||||
resource: Optional[str | Tuple[str, str]],
|
||||
) -> bool:
|
||||
"""
|
||||
Check if restrictions allow the requested action on the requested resource.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def root_user_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
) -> PermissionSQL | None:
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Grant root user full permissions when --root flag is used.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -17,13 +17,15 @@ from datasette.tokens import SignedTokenHandler
|
|||
|
||||
|
||||
@hookimpl
|
||||
def register_token_handler(datasette: Datasette):
|
||||
def register_token_handler(datasette: "Datasette"):
|
||||
"""Register the default signed token handler."""
|
||||
return SignedTokenHandler()
|
||||
|
||||
|
||||
@hookimpl(specname="actor_from_request")
|
||||
async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None:
|
||||
async def actor_from_signed_api_token(
|
||||
datasette: "Datasette", request
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Authenticate requests using API tokens by delegating to all registered
|
||||
token handlers via datasette.verify_token().
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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": f"Alter table {table}",
|
||||
"data-table-action": "alter-table",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
from abc import ABC, abstractproperty
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datasette.hookspecs import hookimpl
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import json
|
||||
import urllib
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.utils import (
|
||||
detect_json1,
|
||||
escape_sqlite,
|
||||
path_with_added_args,
|
||||
path_with_removed_args,
|
||||
detect_json1,
|
||||
sqlite3,
|
||||
)
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ def load_facet_configs(request, table_config):
|
|||
assert (
|
||||
len(facet_config.values()) == 1
|
||||
), "Metadata config dicts should be {type: config}"
|
||||
type, facet_config = next(iter(facet_config.items()))
|
||||
type, facet_config = list(facet_config.items())[0]
|
||||
if isinstance(facet_config, str):
|
||||
facet_config = {"simple": facet_config}
|
||||
facet_configs.setdefault(type, []).append(
|
||||
|
|
@ -86,7 +85,7 @@ class Facet:
|
|||
self.database = database
|
||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||
self.table = table
|
||||
self.sql = sql or f"select * from {escape_sqlite(table)}"
|
||||
self.sql = sql or f"select * from [{table}]"
|
||||
self.params = params or []
|
||||
self.table_config = table_config
|
||||
# row_count can be None, in which case we calculate it ourselves:
|
||||
|
|
@ -161,13 +160,18 @@ class ColumnFacet(Facet):
|
|||
for column in columns:
|
||||
if column in already_enabled:
|
||||
continue
|
||||
suggested_facet_sql = f"""
|
||||
with limited as (select * from ({self.sql}) limit {self.suggest_consider})
|
||||
select {escape_sqlite(column)} as value, count(*) as n from limited
|
||||
suggested_facet_sql = """
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select {column} as value, count(*) as n from limited
|
||||
where value is not null
|
||||
group by value
|
||||
limit {facet_size + 1}
|
||||
"""
|
||||
limit {limit}
|
||||
""".format(
|
||||
column=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
suggest_consider=self.suggest_consider,
|
||||
)
|
||||
distinct_values = None
|
||||
try:
|
||||
distinct_values = await self.ds.execute(
|
||||
|
|
@ -263,7 +267,7 @@ class ColumnFacet(Facet):
|
|||
for row in facet_rows:
|
||||
column_qs = column
|
||||
if column.startswith("_"):
|
||||
column_qs = f"{column}__exact"
|
||||
column_qs = "{}__exact".format(column)
|
||||
selected = (column_qs, str(row["value"])) in qs_pairs
|
||||
if selected:
|
||||
toggle_path = path_with_removed_args(
|
||||
|
|
@ -338,12 +342,12 @@ class ArrayFacet(Facet):
|
|||
for v in await self.ds.execute(
|
||||
self.database,
|
||||
(
|
||||
f"select {escape_sqlite(column)} from ({self.sql}) "
|
||||
f"where {escape_sqlite(column)} is not null "
|
||||
f"and {escape_sqlite(column)} != '' "
|
||||
f"and json_array_length({escape_sqlite(column)}) > 0 "
|
||||
"select {column} from ({sql}) "
|
||||
"where {column} is not null "
|
||||
"and {column} != '' "
|
||||
"and json_array_length({column}) > 0 "
|
||||
"limit 100"
|
||||
),
|
||||
).format(column=escape_sqlite(column), sql=self.sql),
|
||||
self.params,
|
||||
truncate=False,
|
||||
custom_time_limit=self.ds.setting(
|
||||
|
|
@ -384,14 +388,14 @@ class ArrayFacet(Facet):
|
|||
source = source_and_config["source"]
|
||||
column = config.get("column") or config["simple"]
|
||||
# https://github.com/simonw/datasette/issues/448
|
||||
facet_sql = f"""
|
||||
with inner as ({self.sql}),
|
||||
facet_sql = """
|
||||
with inner as ({sql}),
|
||||
deduped_array_items as (
|
||||
select
|
||||
distinct j.value,
|
||||
inner.*
|
||||
from
|
||||
json_each([inner].{escape_sqlite(column)}) j
|
||||
json_each([inner].{col}) j
|
||||
join inner
|
||||
)
|
||||
select
|
||||
|
|
@ -402,8 +406,12 @@ class ArrayFacet(Facet):
|
|||
group by
|
||||
value
|
||||
order by
|
||||
count(*) desc, value limit {facet_size + 1}
|
||||
"""
|
||||
count(*) desc, value limit {limit}
|
||||
""".format(
|
||||
col=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import json
|
||||
from typing import ClassVar
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.utils.asgi import BadRequest
|
||||
from datasette.views.base import DatasetteError
|
||||
|
||||
from datasette.utils.asgi import BadRequest
|
||||
import json
|
||||
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
||||
|
||||
|
||||
|
|
@ -102,9 +99,9 @@ def search_filters(request, database, table, datasette):
|
|||
fts_table=escape_sqlite(fts_table),
|
||||
search_col=escape_sqlite(search_col),
|
||||
match_clause=(
|
||||
f":search_{i}"
|
||||
":search_{}".format(i)
|
||||
if search_mode_raw
|
||||
else f"escape_fts(:search_{i})"
|
||||
else "escape_fts(:search_{})".format(i)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -137,11 +134,11 @@ def through_filters(request, database, table, datasette):
|
|||
value = through_data["value"]
|
||||
db = datasette.get_database(database)
|
||||
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
||||
fk_to_us = next(
|
||||
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table),
|
||||
None,
|
||||
)
|
||||
if fk_to_us is None:
|
||||
try:
|
||||
fk_to_us = [
|
||||
fk for fk in outgoing_foreign_keys if fk["other_table"] == table
|
||||
][0]
|
||||
except IndexError:
|
||||
raise DatasetteError(
|
||||
"Invalid _through - could not find corresponding foreign key"
|
||||
)
|
||||
|
|
@ -368,7 +365,7 @@ class Filters:
|
|||
),
|
||||
]
|
||||
)
|
||||
_filters_by_key: ClassVar[dict[str, Filter]] = {f.key: f for f in _filters}
|
||||
_filters_by_key = {f.key: f for f in _filters}
|
||||
|
||||
def __init__(self, pairs):
|
||||
self.pairs = pairs
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils import documented
|
||||
import itertools
|
||||
import random
|
||||
import string
|
||||
|
||||
from datasette.utils import documented
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
|
||||
__all__ = [
|
||||
"EXTRA_DATABASE_SQL",
|
||||
"TABLES",
|
||||
|
|
@ -347,7 +346,9 @@ CREATE VIEW searchable_view_configured_by_metadata AS
|
|||
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
||||
+ "\n".join(
|
||||
[
|
||||
f'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'
|
||||
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
|
||||
a=a, b=b, c=c, content=content
|
||||
)
|
||||
for a, b, c, content in generate_compound_rows(1001)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,9 @@
|
|||
from datasette import Response, hookimpl
|
||||
|
||||
from .utils import add_cors_headers
|
||||
from datasette import hookimpl, Response
|
||||
|
||||
|
||||
@hookimpl(trylast=True)
|
||||
def forbidden(datasette, request, message):
|
||||
async def inner():
|
||||
if (
|
||||
request.path.split("?")[0].endswith(".json")
|
||||
or "application/json" in (request.headers.get("accept") or "")
|
||||
or request.headers.get("content-type") == "application/json"
|
||||
):
|
||||
headers = {}
|
||||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.error(message, 403, headers=headers)
|
||||
return Response.html(
|
||||
await datasette.render_template(
|
||||
"error.html",
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
import traceback
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from datasette import Response, hookimpl
|
||||
|
||||
from .utils import add_cors_headers, error_body
|
||||
from datasette import hookimpl, Response
|
||||
from .utils import add_cors_headers
|
||||
from .utils.asgi import (
|
||||
Base400,
|
||||
)
|
||||
from .views.base import DatasetteError
|
||||
from markupsafe import Markup
|
||||
import traceback
|
||||
|
||||
# Debugger imports are deliberate - they back the "pdb" setting, which drops
|
||||
# into a debugger on unhandled exceptions
|
||||
try:
|
||||
import ipdb as pdb # noqa: T100
|
||||
import ipdb as pdb
|
||||
except ImportError:
|
||||
import pdb # noqa: T100
|
||||
import pdb
|
||||
|
||||
try:
|
||||
import rich
|
||||
|
|
@ -33,7 +28,6 @@ def handle_exception(datasette, request, exception):
|
|||
rich.get_console().print_exception(show_locals=True)
|
||||
|
||||
title = None
|
||||
plain_message = None
|
||||
if isinstance(exception, Base400):
|
||||
status = exception.status
|
||||
info = {}
|
||||
|
|
@ -42,7 +36,6 @@ def handle_exception(datasette, request, exception):
|
|||
status = exception.status
|
||||
info = exception.error_dict
|
||||
message = exception.message
|
||||
plain_message = exception.plain_message
|
||||
if exception.message_is_html:
|
||||
message = Markup(message)
|
||||
title = exception.title
|
||||
|
|
@ -52,13 +45,6 @@ def handle_exception(datasette, request, exception):
|
|||
message = str(exception)
|
||||
traceback.print_exc()
|
||||
templates = [f"{status}.html", "error.html"]
|
||||
headers = {}
|
||||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
if request.path.split("?")[0].endswith(".json"):
|
||||
body = dict(info)
|
||||
body.update(error_body(plain_message or message, status))
|
||||
return Response.json(body, status=status, headers=headers)
|
||||
info.update(
|
||||
{
|
||||
"ok": False,
|
||||
|
|
@ -67,18 +53,25 @@ def handle_exception(datasette, request, exception):
|
|||
"title": title,
|
||||
}
|
||||
)
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
info,
|
||||
urls=datasette.urls,
|
||||
menu_links=list,
|
||||
)
|
||||
),
|
||||
status=status,
|
||||
headers=headers,
|
||||
)
|
||||
headers = {}
|
||||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
if request.path.split("?")[0].endswith(".json"):
|
||||
return Response.json(info, status=status, headers=headers)
|
||||
else:
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
info,
|
||||
urls=datasette.urls,
|
||||
app_css_hash=datasette.app_css_hash(),
|
||||
menu_links=lambda: [],
|
||||
)
|
||||
),
|
||||
status=status,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from pluggy import HookimplMarker, HookspecMarker
|
||||
from pluggy import HookimplMarker
|
||||
from pluggy import HookspecMarker
|
||||
|
||||
hookspec = HookspecMarker("datasette")
|
||||
hookimpl = HookimplMarker("datasette")
|
||||
|
|
@ -158,32 +159,32 @@ def jump_items_sql(datasette, actor, request):
|
|||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Items for the row actions menu"""
|
||||
"""Links for the row actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
"""Items for the table actions menu"""
|
||||
"""Links for the table actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def view_actions(datasette, actor, database, view, request):
|
||||
"""Items for the view actions menu"""
|
||||
"""Links for the view actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||
"""Items for the query and stored query actions menu"""
|
||||
"""Links for the query and stored query actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def database_actions(datasette, actor, database, request):
|
||||
"""Items for the database actions menu"""
|
||||
"""Links for the database actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def homepage_actions(datasette, actor, request):
|
||||
"""Items for the homepage actions menu"""
|
||||
"""Links for the homepage actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import hashlib
|
||||
|
||||
from .utils import (
|
||||
detect_spatialite,
|
||||
detect_fts,
|
||||
detect_primary_keys,
|
||||
detect_spatialite,
|
||||
escape_sqlite,
|
||||
get_all_foreign_keys,
|
||||
sqlite3,
|
||||
table_columns,
|
||||
sqlite3,
|
||||
)
|
||||
|
||||
HASH_BLOCK_SIZE = 1024 * 1024
|
||||
|
|
@ -95,10 +95,10 @@ def inspect_tables(conn, database_metadata):
|
|||
""")
|
||||
]
|
||||
|
||||
for t, table_info in tables.items():
|
||||
for t in tables.keys():
|
||||
for hidden_table in hidden_tables:
|
||||
if t == hidden_table or t.startswith(hidden_table):
|
||||
table_info["hidden"] = True
|
||||
tables[t]["hidden"] = True
|
||||
continue
|
||||
|
||||
return tables
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class JumpSQL:
|
|||
search_text: str | None = None,
|
||||
display_name: str | None = None,
|
||||
item_type: str = "menu",
|
||||
) -> JumpSQL:
|
||||
) -> "JumpSQL":
|
||||
if search_text is None:
|
||||
search_text = " ".join(
|
||||
text for text in (label, display_name, description) if text is not None
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
import contextvars
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NamedTuple
|
||||
import contextvars
|
||||
|
||||
# Context variable to track when permission checks should be skipped
|
||||
_skip_permission_checks = contextvars.ContextVar(
|
||||
"skip_permission_checks", default=False
|
||||
)
|
||||
|
||||
# Request-scoped cache of permission check results. The ASGI router sets
|
||||
# this to a fresh dict at the start of each request, so cached verdicts
|
||||
# never outlive a request or leak between actors. Keys are
|
||||
# (actor_json, action, parent, child) tuples, values are booleans.
|
||||
_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
|
||||
"permission_check_cache", default=None
|
||||
)
|
||||
|
||||
|
||||
class SkipPermissions:
|
||||
"""Context manager to temporarily skip permission checks.
|
||||
|
|
@ -72,8 +64,8 @@ class Resource(ABC):
|
|||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})"
|
||||
return "{}(parent={!r}, child={!r})".format(
|
||||
self.__class__.__name__, self.parent, self.child
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
@ -129,6 +121,7 @@ class Resource(ABC):
|
|||
|
||||
Must return two columns: parent, child
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AllowedResource(NamedTuple):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import importlib
|
||||
import importlib.metadata as importlib_metadata
|
||||
import importlib.resources as importlib_resources
|
||||
import os
|
||||
import sys
|
||||
from pprint import pprint
|
||||
|
||||
import pluggy
|
||||
|
||||
from pprint import pprint
|
||||
import sys
|
||||
from . import hookspecs
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
import importlib.resources as importlib_resources
|
||||
else:
|
||||
import importlib_resources
|
||||
if sys.version_info >= (3, 10):
|
||||
import importlib.metadata as importlib_metadata
|
||||
else:
|
||||
import importlib_metadata
|
||||
|
||||
|
||||
DEFAULT_PLUGINS = (
|
||||
"datasette.publish.heroku",
|
||||
"datasette.publish.cloudrun",
|
||||
|
|
@ -25,8 +31,6 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.default_debug_menu",
|
||||
"datasette.default_jump_items",
|
||||
"datasette.default_database_actions",
|
||||
"datasette.default_table_actions",
|
||||
"datasette.default_query_actions",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
"datasette.events",
|
||||
|
|
@ -79,7 +83,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
|
|||
# Ensure name can be found in plugin_to_distinfo later:
|
||||
pm._plugin_distinfo.append((mod, distribution))
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
sys.stderr.write(f"Plugin {package_name} could not be found\n")
|
||||
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
|
||||
|
||||
|
||||
# Load default plugins
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
from datasette import hookimpl
|
||||
import click
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from subprocess import CalledProcessError, check_call, check_output
|
||||
|
||||
import click
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
from ..utils import temporary_docker_directory
|
||||
from .common import (
|
||||
add_common_publish_arguments_and_options,
|
||||
fail_if_publish_binary_not_installed,
|
||||
)
|
||||
from ..utils import temporary_docker_directory
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
@ -221,7 +219,7 @@ def publish_subcommand(publish):
|
|||
|
||||
check_call(
|
||||
"gcloud builds submit --tag {}{}".format(
|
||||
image_id, f" --timeout {timeout}" if timeout else ""
|
||||
image_id, " --timeout {}".format(timeout) if timeout else ""
|
||||
),
|
||||
shell=True,
|
||||
)
|
||||
|
|
@ -233,7 +231,7 @@ def publish_subcommand(publish):
|
|||
("--min-instances", min_instances),
|
||||
):
|
||||
if value is not None:
|
||||
extra_deploy_options.append(f"{option} {value}")
|
||||
extra_deploy_options.append("{} {}".format(option, value))
|
||||
check_call(
|
||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||
image_id,
|
||||
|
|
@ -260,16 +258,24 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
|
|||
) from exc
|
||||
|
||||
describe_cmd = (
|
||||
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} "
|
||||
f"--location {artifact_region} --quiet"
|
||||
"gcloud artifacts repositories describe {repo} --project {project} "
|
||||
"--location {location} --quiet"
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
project=artifact_project,
|
||||
location=artifact_region,
|
||||
)
|
||||
try:
|
||||
check_call(describe_cmd, shell=True)
|
||||
return
|
||||
except CalledProcessError:
|
||||
create_cmd = (
|
||||
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker "
|
||||
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet'
|
||||
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
||||
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
location=artifact_region,
|
||||
project=artifact_project,
|
||||
)
|
||||
try:
|
||||
check_call(create_cmd, shell=True)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
from ..utils import StaticMount
|
||||
import click
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from ..utils import StaticMount
|
||||
|
||||
|
||||
def add_common_publish_arguments_and_options(subcommand):
|
||||
for decorator in reversed(
|
||||
|
|
@ -78,7 +76,9 @@ def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
|
|||
"""Exit (with error message) if ``binary` isn't installed"""
|
||||
if not shutil.which(binary):
|
||||
click.secho(
|
||||
f"Publishing to {publish_target} requires {binary} to be installed and configured",
|
||||
"Publishing to {publish_target} requires {binary} to be installed and configured".format(
|
||||
publish_target=publish_target, binary=binary
|
||||
),
|
||||
bg="red",
|
||||
fg="white",
|
||||
bold=True,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
from contextlib import contextmanager
|
||||
from datasette import hookimpl
|
||||
import click
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from subprocess import call, check_output
|
||||
|
||||
import click
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
||||
import tempfile
|
||||
|
||||
from .common import (
|
||||
add_common_publish_arguments_and_options,
|
||||
fail_if_publish_binary_not_installed,
|
||||
)
|
||||
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
@ -236,7 +234,7 @@ def temporary_heroku_directory(
|
|||
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
||||
|
||||
quoted_files = " ".join(
|
||||
[f"-i {shlex.quote(file_name)}" for file_name in file_names]
|
||||
["-i {}".format(shlex.quote(file_name)) for file_name in file_names]
|
||||
)
|
||||
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
|
||||
quoted_files=quoted_files, extras=" ".join(extras)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import json
|
||||
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.utils import (
|
||||
CustomJSONEncoder,
|
||||
error_body,
|
||||
path_from_row_pks,
|
||||
remove_infinites,
|
||||
sqlite3,
|
||||
value_as_boolean,
|
||||
remove_infinites,
|
||||
CustomJSONEncoder,
|
||||
path_from_row_pks,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
|
|
@ -54,7 +51,8 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
if error:
|
||||
shape = "objects"
|
||||
status_code = 400
|
||||
data.update(error_body(error, status_code))
|
||||
data["error"] = error
|
||||
data["ok"] = False
|
||||
|
||||
if truncated is not None:
|
||||
data["truncated"] = truncated
|
||||
|
|
@ -88,8 +86,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
object_rows[pk_string] = row
|
||||
data = object_rows
|
||||
if shape_error:
|
||||
status_code = 400
|
||||
data = error_body(shape_error, status_code)
|
||||
data = {"ok": False, "error": shape_error}
|
||||
elif shape == "array":
|
||||
data = data["rows"]
|
||||
|
||||
|
|
@ -102,11 +99,16 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
data["rows"] = [list(row.values()) for row in data["rows"]]
|
||||
else:
|
||||
status_code = 400
|
||||
data = error_body(f"Invalid _shape: {shape}", status_code)
|
||||
data = {
|
||||
"ok": False,
|
||||
"error": f"Invalid _shape: {shape}",
|
||||
"status": 400,
|
||||
"title": None,
|
||||
}
|
||||
|
||||
# Don't include "columns" in output
|
||||
# https://github.com/simonw/datasette/issues/2136
|
||||
if isinstance(data, dict) and "columns" not in extra_names_from_request(request):
|
||||
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
|
||||
data.pop("columns", None)
|
||||
|
||||
# Handle _nl option for _shape=array
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
})();
|
||||
|
|
@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {
|
|||
<style>
|
||||
:host {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #eef6ff;
|
||||
--paper: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #d8e6f5;
|
||||
--rule: #e2dfd8;
|
||||
--accent: #1a56db;
|
||||
--accent-light: #e8effd;
|
||||
--card: #ffffff;
|
||||
|
|
|
|||
|
|
@ -82,35 +82,6 @@ const datasetteManager = {
|
|||
return columnActions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
|
||||
* for specific Datasette column types.
|
||||
*
|
||||
* The first plugin to return a control object wins. Returning null or
|
||||
* undefined means "I do not handle this field".
|
||||
*/
|
||||
makeColumnField: (context) => {
|
||||
for (const [pluginName, plugin] of datasetteManager.plugins) {
|
||||
if (!plugin.makeColumnField) {
|
||||
continue;
|
||||
}
|
||||
let control = null;
|
||||
try {
|
||||
control = plugin.makeColumnField(context);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error in makeColumnField() for plugin ${pluginName}`,
|
||||
error,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (control) {
|
||||
return Object.assign({ pluginName }, control);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
makeJumpSections: (context) => {
|
||||
let jumpSections = [];
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 setColumnTypeDialogState = null;
|
||||
|
||||
function getParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
|
|
@ -633,151 +634,32 @@ const initDatasetteTable = function (manager) {
|
|||
});
|
||||
};
|
||||
|
||||
function filterRowSelector(manager) {
|
||||
return manager.selectors.filterRows || manager.selectors.filterRow;
|
||||
}
|
||||
|
||||
function filterRowsWithControls(manager) {
|
||||
return Array.from(
|
||||
document.querySelectorAll(filterRowSelector(manager)),
|
||||
).filter((el) => el.querySelector(".filter-op"));
|
||||
}
|
||||
|
||||
function filterRowNumberFromName(name) {
|
||||
var match = name && name.match(/^_filter_column_(\d+)$/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
function nextFilterRowNumber(manager) {
|
||||
return filterRowsWithControls(manager).reduce((max, row) => {
|
||||
var column = row.querySelector("select");
|
||||
return Math.max(max, filterRowNumberFromName(column && column.name));
|
||||
}, 0) + 1;
|
||||
}
|
||||
|
||||
function setFilterRowNumber(row, number) {
|
||||
row.querySelector("select").name = `_filter_column_${number}`;
|
||||
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
|
||||
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
|
||||
}
|
||||
|
||||
function resetFilterRow(row) {
|
||||
row.querySelector("select").value = "";
|
||||
row.querySelector(".filter-op select").value = "exact";
|
||||
row.querySelector("input.filter-value").value = "";
|
||||
}
|
||||
|
||||
function updateFilterRowButtons(manager) {
|
||||
var rows = filterRowsWithControls(manager);
|
||||
rows.forEach((row, index) => {
|
||||
var removeButton = row.querySelector(".filter-row-remove");
|
||||
var addButton = row.querySelector(".filter-row-add");
|
||||
var column = row.querySelector("select");
|
||||
if (removeButton) {
|
||||
removeButton.hidden = index === 0;
|
||||
}
|
||||
if (addButton) {
|
||||
addButton.hidden = index !== rows.length - 1 || !column.value;
|
||||
}
|
||||
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
|
||||
return button && !button.hidden;
|
||||
}).length;
|
||||
row.classList.toggle(
|
||||
"filter-controls-row-has-buttons",
|
||||
visibleButtonCount > 0,
|
||||
);
|
||||
row.classList.toggle(
|
||||
"filter-controls-row-one-button",
|
||||
visibleButtonCount === 1,
|
||||
);
|
||||
row.classList.toggle(
|
||||
"filter-controls-row-two-buttons",
|
||||
visibleButtonCount === 2,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function cloneFilterRow(row) {
|
||||
var clone = row.cloneNode(true);
|
||||
clone.querySelector("select").name = "_filter_column";
|
||||
clone.querySelector(".filter-op select").name = "_filter_op";
|
||||
clone.querySelector("input.filter-value").name = "_filter_value";
|
||||
resetFilterRow(clone);
|
||||
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
|
||||
return clone;
|
||||
}
|
||||
|
||||
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
|
||||
<path d="M10 11v6"></path>
|
||||
<path d="M14 11v6"></path>
|
||||
</svg>`;
|
||||
|
||||
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg>`;
|
||||
|
||||
function addFilterRowButtons(row, manager) {
|
||||
var removeButton = document.createElement("button");
|
||||
removeButton.type = "button";
|
||||
removeButton.className = "filter-row-icon filter-row-remove";
|
||||
removeButton.setAttribute("aria-label", "Remove this filter");
|
||||
removeButton.title = "Remove this filter";
|
||||
removeButton.tabIndex = 0;
|
||||
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
|
||||
removeButton.addEventListener("click", (ev) => {
|
||||
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
||||
var rows = filterRowsWithControls(manager);
|
||||
var rowIndex = rows.indexOf(row);
|
||||
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
|
||||
row.remove();
|
||||
updateFilterRowButtons(manager);
|
||||
if (focusRow) {
|
||||
var focusTarget =
|
||||
focusRow.querySelector(".filter-row-add:not([hidden])") ||
|
||||
focusRow.querySelector("select");
|
||||
if (focusTarget) {
|
||||
focusTarget.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
row.appendChild(removeButton);
|
||||
|
||||
var addButton = document.createElement("button");
|
||||
addButton.type = "button";
|
||||
addButton.className = "filter-row-icon filter-row-add";
|
||||
addButton.setAttribute("aria-label", "Add another filter");
|
||||
addButton.title = "Add another filter";
|
||||
addButton.tabIndex = 0;
|
||||
addButton.innerHTML = FILTER_ADD_ICON_SVG;
|
||||
addButton.addEventListener("click", (ev) => {
|
||||
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
||||
if (row.querySelector("select").name === "_filter_column") {
|
||||
setFilterRowNumber(row, nextFilterRowNumber(manager));
|
||||
}
|
||||
var clone = cloneFilterRow(row);
|
||||
addFilterRowButtons(clone, manager);
|
||||
row.parentNode.insertBefore(clone, row.nextSibling);
|
||||
updateFilterRowButtons(manager);
|
||||
clone.querySelector("select").focus();
|
||||
});
|
||||
row.appendChild(addButton);
|
||||
|
||||
row.querySelector("select").addEventListener("change", () => {
|
||||
updateFilterRowButtons(manager);
|
||||
});
|
||||
}
|
||||
|
||||
/* Add buttons to the filter rows */
|
||||
/* Add x buttons to the filter rows */
|
||||
function addButtonsToFilterRows(manager) {
|
||||
var rows = filterRowsWithControls(manager);
|
||||
var x = "✖";
|
||||
var rows = Array.from(
|
||||
document.querySelectorAll(manager.selectors.filterRow),
|
||||
).filter((el) => el.querySelector(".filter-op"));
|
||||
rows.forEach((row) => {
|
||||
addFilterRowButtons(row, manager);
|
||||
var a = document.createElement("a");
|
||||
a.setAttribute("href", "#");
|
||||
a.setAttribute("aria-label", "Remove this filter");
|
||||
a.style.textDecoration = "none";
|
||||
a.innerText = x;
|
||||
a.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
let row = ev.target.closest("div");
|
||||
row.querySelector("select").value = "";
|
||||
row.querySelector(".filter-op select").value = "exact";
|
||||
row.querySelector("input.filter-value").value = "";
|
||||
ev.target.closest("a").style.display = "none";
|
||||
});
|
||||
row.appendChild(a);
|
||||
var column = row.querySelector("select");
|
||||
if (!column.value) {
|
||||
a.style.display = "none";
|
||||
}
|
||||
});
|
||||
updateFilterRowButtons(manager);
|
||||
}
|
||||
|
||||
/* Set up datalist autocomplete for filter values */
|
||||
|
|
@ -806,11 +688,11 @@ function initAutocompleteForFilterValues(manager) {
|
|||
});
|
||||
}
|
||||
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) {
|
||||
if (event.target.name && event.target.name.startsWith("_filter_column")) {
|
||||
if (event.target.name === "_filter_column") {
|
||||
event.target
|
||||
.closest(filterRowSelector(manager))
|
||||
.closest(manager.selectors.filterRow)
|
||||
.querySelector(".filter-value")
|
||||
.setAttribute("list", "datalist-" + event.target.value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .utils import tilde_encode, urlsafe_components
|
||||
|
||||
|
|
@ -63,6 +62,7 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
|
|||
"description_html": query.description_html,
|
||||
"hide_sql": query.hide_sql,
|
||||
"fragment": query.fragment,
|
||||
"params": list(query.parameters),
|
||||
"parameters": list(query.parameters),
|
||||
"is_write": query.is_write,
|
||||
"is_private": query.is_private,
|
||||
|
|
@ -84,6 +84,7 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
|
|||
return {
|
||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
||||
"next": page.next,
|
||||
"has_more": page.has_more,
|
||||
"limit": page.limit,
|
||||
}
|
||||
|
||||
|
|
@ -387,7 +388,7 @@ async def count_queries(
|
|||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = f"%{q}%"
|
||||
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))
|
||||
|
|
@ -463,7 +464,7 @@ async def list_queries(
|
|||
except ValueError:
|
||||
components = []
|
||||
if database is None and len(components) == 3:
|
||||
where_clauses.append(f"""
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.database_name > :cursor_database
|
||||
OR (
|
||||
|
|
@ -477,12 +478,12 @@ async def list_queries(
|
|||
)
|
||||
)
|
||||
)
|
||||
""")
|
||||
""".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(f"""
|
||||
where_clauses.append("""
|
||||
(
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
|
|
@ -490,7 +491,7 @@ async def list_queries(
|
|||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
""")
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_sort_key"] = components[0]
|
||||
params["cursor_name"] = components[1]
|
||||
|
||||
|
|
@ -503,7 +504,7 @@ async def list_queries(
|
|||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = f"%{q}%"
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -15,22 +15,14 @@
|
|||
<div class="hook"></div>
|
||||
<ul role="menu">
|
||||
{% for link in action_links %}
|
||||
<li role="none">
|
||||
{% if link.get("type") == "button" %}
|
||||
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</button>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</a>
|
||||
{% endif %}
|
||||
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<p class="dropdown-description">{{ link.description }}</p>
|
||||
{% endif %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
|
||||
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
|
||||
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
|
||||
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
|
||||
<style>
|
||||
.cm-editor {
|
||||
resize: both;
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@
|
|||
<ul class="tight-bullets">
|
||||
{% for facet_value in facet_info.results %}
|
||||
{% if not facet_value.selected %}
|
||||
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span></li>
|
||||
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||
{% else %}
|
||||
<li>{{ facet_value.label or "-" }} · <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span> <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||
<li>{{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if facet_info.truncated %}
|
||||
|
|
|
|||
|
|
@ -6,20 +6,8 @@
|
|||
padding: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.permission-form form {
|
||||
max-width: 60rem;
|
||||
}
|
||||
.permission-form-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.permission-form-result {
|
||||
margin-top: 1rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 1.25em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
|
|
@ -27,51 +15,22 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
.form-section input[type="text"],
|
||||
.form-section input[type="number"],
|
||||
.form-section select,
|
||||
.permission-textarea {
|
||||
background-color: #fff;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
color: #222;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
.form-section input[type="text"] {
|
||||
height: 3rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.form-section input[type="number"] {
|
||||
height: 3rem;
|
||||
max-width: 7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.form-section select {
|
||||
height: 3rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.permission-textarea {
|
||||
font-family: monospace;
|
||||
min-height: 12rem;
|
||||
padding: 0.75rem;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section input[type="number"]:focus,
|
||||
.form-section select:focus,
|
||||
.permission-textarea:focus {
|
||||
.form-section select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.45em;
|
||||
margin-top: 0.3em;
|
||||
color: #666;
|
||||
}
|
||||
.form-actions {
|
||||
|
|
@ -183,9 +142,4 @@
|
|||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.permission-form-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -44,10 +44,10 @@
|
|||
</style>
|
||||
|
||||
<nav class="permissions-debug-tabs">
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
|
||||
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
||||
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -27,20 +27,16 @@ window.datasetteSqlParameters = (() => {
|
|||
manager.section
|
||||
.querySelectorAll("[data-parameter-control]")
|
||||
.forEach((control) => {
|
||||
manager.parameterState.set(
|
||||
control.dataset.parameterName,
|
||||
controlState(control)
|
||||
);
|
||||
manager.parameterState.set(control.name, controlState(control));
|
||||
});
|
||||
}
|
||||
|
||||
function createControl(parameter, id, state, namePrefix) {
|
||||
function createControl(parameter, id, state) {
|
||||
const control = document.createElement(state.expanded ? "textarea" : "input");
|
||||
control.id = id;
|
||||
control.name = `${namePrefix || ""}${parameter}`;
|
||||
control.name = parameter;
|
||||
control.value = state.value;
|
||||
control.setAttribute("data-parameter-control", "");
|
||||
control.dataset.parameterName = parameter;
|
||||
if (state.expanded) {
|
||||
control.rows = 5;
|
||||
} else {
|
||||
|
|
@ -57,16 +53,10 @@ window.datasetteSqlParameters = (() => {
|
|||
value,
|
||||
selectionStart
|
||||
) {
|
||||
const parameter = control.dataset.parameterName;
|
||||
const replacement = createControl(
|
||||
parameter,
|
||||
control.id,
|
||||
{
|
||||
value: value === undefined ? control.value : value,
|
||||
expanded: expand,
|
||||
},
|
||||
manager.namePrefix
|
||||
);
|
||||
const replacement = createControl(control.name, control.id, {
|
||||
value: value === undefined ? control.value : value,
|
||||
expanded: expand,
|
||||
});
|
||||
button.textContent = expand ? "Collapse" : "Expand";
|
||||
button.setAttribute("aria-expanded", expand ? "true" : "false");
|
||||
control.replaceWith(replacement);
|
||||
|
|
@ -74,7 +64,7 @@ window.datasetteSqlParameters = (() => {
|
|||
if (selectionStart !== undefined && replacement.setSelectionRange) {
|
||||
replacement.setSelectionRange(selectionStart, selectionStart);
|
||||
}
|
||||
manager.parameterState.set(parameter, controlState(replacement));
|
||||
manager.parameterState.set(replacement.name, controlState(replacement));
|
||||
}
|
||||
|
||||
function renderParameters(manager, parameters) {
|
||||
|
|
@ -109,7 +99,7 @@ window.datasetteSqlParameters = (() => {
|
|||
label.htmlFor = id;
|
||||
label.textContent = parameter;
|
||||
|
||||
const control = createControl(parameter, id, state, manager.namePrefix);
|
||||
const control = createControl(parameter, id, state);
|
||||
|
||||
row.append(label, control);
|
||||
if (manager.allowExpand) {
|
||||
|
|
@ -134,10 +124,7 @@ window.datasetteSqlParameters = (() => {
|
|||
if (!control.matches || !control.matches("[data-parameter-control]")) {
|
||||
return;
|
||||
}
|
||||
manager.parameterState.set(
|
||||
control.dataset.parameterName,
|
||||
controlState(control)
|
||||
);
|
||||
manager.parameterState.set(control.name, controlState(control));
|
||||
});
|
||||
|
||||
if (!manager.allowExpand) {
|
||||
|
|
@ -243,7 +230,6 @@ window.datasetteSqlParameters = (() => {
|
|||
? section.dataset.allowExpand === "1"
|
||||
: false
|
||||
: options.allowExpand,
|
||||
namePrefix: section ? section.dataset.parameterNamePrefix || "" : "",
|
||||
parameterState: new Map(),
|
||||
};
|
||||
if (section) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
{% 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 %}>
|
||||
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% 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>
|
||||
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control>{% 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>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}>
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,29 @@
|
|||
{% block title %}Debug allow rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
<style>
|
||||
textarea {
|
||||
height: 10em;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border: 2px dotted black;
|
||||
}
|
||||
.two-col {
|
||||
display: inline-block;
|
||||
width: 48%;
|
||||
}
|
||||
.two-col label {
|
||||
width: 48%;
|
||||
}
|
||||
p.message-warning {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -20,28 +38,24 @@ p.message-warning {
|
|||
|
||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
|
||||
<div class="permission-form-grid">
|
||||
<div class="form-section">
|
||||
<label for="allow-block">Allow block</label>
|
||||
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="allow-actor">Actor</label>
|
||||
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">Apply allow block to actor</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||
<div class="two-col">
|
||||
<p><label>Allow block</label></p>
|
||||
<textarea name="allow">{{ allow_input }}</textarea>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<p><label>Actor</label></p>
|
||||
<textarea name="actor">{{ actor_input }}</textarea>
|
||||
</div>
|
||||
<div style="margin-top: 1em;">
|
||||
<input type="submit" value="Apply allow block to actor">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
|
||||
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
|
||||
|
||||
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
|
||||
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
|
||||
|
||||
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
|
||||
</div>
|
||||
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{% block title %}API Explorer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ static('app.css') }}">
|
||||
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
{% for url in extra_css_urls %}
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
{% endfor %}
|
||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
||||
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
||||
{% for url in extra_js_urls %}
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
{% endfor %}
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
{% endfor %}
|
||||
|
||||
{% 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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@
|
|||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% if database_page_data.createTable %}
|
||||
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
|
||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||
|
|
@ -76,7 +72,7 @@
|
|||
<div class="db-table">
|
||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}>{{ "{:,}".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 %}>{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p style="margin-bottom: 2em;">
|
||||
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
|
||||
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
|
||||
Actions are used by the permission system to control access to different features.
|
||||
</p>
|
||||
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for action in data.actions %}
|
||||
{% for action in data %}
|
||||
<tr>
|
||||
<td><strong>{{ action.name }}</strong></td>
|
||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{% block title %}Allowed Resources{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('_page');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== '_size' && key !== '_page') {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('_page', page.toString());
|
||||
params.append('_size', pageSize);
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Explain a permission decision{% endblock %}
|
||||
{% block title %}Permission Check{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
<style>
|
||||
|
|
@ -13,35 +13,29 @@
|
|||
border-radius: 5px;
|
||||
}
|
||||
#output.allowed {
|
||||
background-color: #f3fbf4;
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
#output.denied {
|
||||
background-color: #fff7f7;
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
#output h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
#output h3 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
#output .result-badge,
|
||||
.effect-badge,
|
||||
.rule-status {
|
||||
#output .result-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.5em;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
#output .allowed-badge,
|
||||
.effect-allow {
|
||||
background-color: #2e7d32;
|
||||
#output .allowed-badge {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
#output .denied-badge,
|
||||
.effect-deny {
|
||||
background-color: #c62828;
|
||||
#output .denied-badge {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.details-section {
|
||||
|
|
@ -54,130 +48,70 @@
|
|||
.details-section dd {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.explanation-section {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
padding: 0 1em 1em;
|
||||
}
|
||||
.rules-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.rules-table th,
|
||||
.rules-table td {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rule-status {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
.rule-ignored {
|
||||
background: #eee;
|
||||
color: #555;
|
||||
font-weight: normal;
|
||||
}
|
||||
.requirement-allowed {
|
||||
color: #1b5e20;
|
||||
}
|
||||
.requirement-denied {
|
||||
color: #b71c1c;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.rules-table,
|
||||
.rules-table tbody,
|
||||
.rules-table tr,
|
||||
.rules-table td {
|
||||
display: block;
|
||||
}
|
||||
.rules-table thead {
|
||||
display: none;
|
||||
}
|
||||
.rules-table td::before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Explain a permission decision</h1>
|
||||
<h1>Permission check</h1>
|
||||
|
||||
{% set current_tab = "check" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p>
|
||||
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
|
||||
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
||||
<div class="form-section">
|
||||
<label for="actor">Actor JSON:</label>
|
||||
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
|
||||
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="action">Action:</label>
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action in actions %}
|
||||
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small id="action-help">The operation to evaluate</small>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section" id="parent-section">
|
||||
<label for="parent">Parent resource:</label>
|
||||
<div class="form-section">
|
||||
<label for="parent">Parent resource (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>The database or other parent resource</small>
|
||||
<small>For database-level permissions, specify the database name</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section" id="child-section">
|
||||
<label for="child">Child resource:</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
|
||||
<small>The table, query or other child resource</small>
|
||||
<div class="form-section">
|
||||
<label for="child">Child resource (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="output" style="display: none;">
|
||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||
<p id="result-summary"></p>
|
||||
|
||||
<dl class="details-section">
|
||||
<dt>Actor:</dt>
|
||||
<dd><code id="result-actor"></code></dd>
|
||||
<dt>Action:</dt>
|
||||
<dd><code id="result-action"></code></dd>
|
||||
<dt>Resource:</dt>
|
||||
<dd><code id="result-resource"></code></dd>
|
||||
<dd id="result-action"></dd>
|
||||
|
||||
<dt>Resource Path:</dt>
|
||||
<dd id="result-resource"></dd>
|
||||
|
||||
<dt>Actor ID:</dt>
|
||||
<dd id="result-actor"></dd>
|
||||
|
||||
<div id="additional-details"></div>
|
||||
</dl>
|
||||
|
||||
<section class="explanation-section">
|
||||
<h3>Matching rules</h3>
|
||||
<div id="matching-rules"></div>
|
||||
</section>
|
||||
|
||||
<section class="explanation-section" id="restrictions-section">
|
||||
<h3>Actor restrictions</h3>
|
||||
<div id="restriction-results"></div>
|
||||
</section>
|
||||
|
||||
<section class="explanation-section" id="requirements-section">
|
||||
<h3>Required actions</h3>
|
||||
<div id="requirement-results"></div>
|
||||
</section>
|
||||
|
||||
<details style="margin-top: 1em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
|
|
@ -185,134 +119,152 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
|
||||
const form = document.getElementById('check-form');
|
||||
const output = document.getElementById('output');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const actionSelect = document.getElementById('action');
|
||||
|
||||
function updateResourceFields() {
|
||||
const action = actions[actionSelect.value];
|
||||
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
|
||||
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
|
||||
let help = action && action.description ? action.description : 'The operation to evaluate';
|
||||
if (action && action.also_requires) {
|
||||
help += `; also requires ${action.also_requires}`;
|
||||
}
|
||||
document.getElementById('action-help').textContent = help;
|
||||
}
|
||||
|
||||
async function performCheck() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Explaining...';
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
submitBtn.textContent = 'Checking...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||
headers: {'Accept': 'application/json'}
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResult(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({error: error.message});
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Explain decision';
|
||||
submitBtn.textContent = 'Check Permission';
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
performCheck();
|
||||
}
|
||||
})();
|
||||
|
||||
function displayResult(data) {
|
||||
output.style.display = 'block';
|
||||
|
||||
// Set badge and styling
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
output.className = data.allowed ? 'allowed' : 'denied';
|
||||
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
|
||||
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
||||
document.getElementById('result-summary').textContent = data.explanation.summary;
|
||||
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
|
||||
document.getElementById('result-action').textContent = data.action;
|
||||
document.getElementById('result-resource').textContent = data.resource.path;
|
||||
displayRules(data.explanation);
|
||||
displayRestrictions(data.explanation.restrictions);
|
||||
displayRequirements(data.explanation.required_actions);
|
||||
if (data.allowed) {
|
||||
output.className = 'allowed';
|
||||
resultBadge.className = 'result-badge allowed-badge';
|
||||
resultBadge.textContent = 'ALLOWED ✓';
|
||||
} else {
|
||||
output.className = 'denied';
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'DENIED ✗';
|
||||
}
|
||||
|
||||
// Basic details
|
||||
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||
|
||||
// Additional details
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '';
|
||||
|
||||
if (data.reason !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Reason:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.reason || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.source_plugin !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Source Plugin:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.source_plugin || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.used_default !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Used Default:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.depth !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Depth:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.depth;
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
// Raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
function displayRules(explanation) {
|
||||
const container = document.getElementById('matching-rules');
|
||||
if (!explanation.matched_rules.length) {
|
||||
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
|
||||
for (const rule of explanation.matched_rules) {
|
||||
const status = rule.decisive
|
||||
? '<span class="rule-status">Decisive</span>'
|
||||
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
|
||||
html += '<tr>';
|
||||
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
|
||||
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
|
||||
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
|
||||
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
|
||||
html += `<td data-label="Role in decision">${status}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
container.innerHTML = html + '</tbody></table>';
|
||||
}
|
||||
|
||||
function displayRestrictions(restrictions) {
|
||||
const section = document.getElementById('restrictions-section');
|
||||
const container = document.getElementById('restriction-results');
|
||||
section.style.display = restrictions.length ? 'block' : 'none';
|
||||
container.innerHTML = restrictions.map(restriction => {
|
||||
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
|
||||
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
|
||||
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function displayRequirements(requirements) {
|
||||
const section = document.getElementById('requirements-section');
|
||||
const container = document.getElementById('requirement-results');
|
||||
section.style.display = requirements.length ? 'block' : 'none';
|
||||
container.innerHTML = requirements.map(requirement => {
|
||||
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
|
||||
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
||||
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
|
||||
}).join('');
|
||||
// Scroll to output
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
output.style.display = 'block';
|
||||
output.className = 'denied';
|
||||
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'ERROR';
|
||||
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
|
||||
document.getElementById('result-actor').textContent = '—';
|
||||
document.getElementById('result-action').textContent = '—';
|
||||
document.getElementById('result-resource').textContent = '—';
|
||||
document.getElementById('matching-rules').innerHTML = '';
|
||||
document.getElementById('restrictions-section').style.display = 'none';
|
||||
document.getElementById('requirements-section').style.display = 'none';
|
||||
|
||||
document.getElementById('result-action').textContent = 'N/A';
|
||||
document.getElementById('result-resource').textContent = 'N/A';
|
||||
document.getElementById('result-actor').textContent = 'N/A';
|
||||
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
performCheck();
|
||||
});
|
||||
actionSelect.addEventListener('change', updateResourceFields);
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
(function initializeFromUrl() {
|
||||
const params = populateFormFromURL();
|
||||
updateResourceFields();
|
||||
if (params.get('action')) {
|
||||
performCheck();
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before adding a child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission activity{% endblock %}
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
|
|
@ -20,45 +20,60 @@
|
|||
.check-action, .check-when, .check-result {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
textarea {
|
||||
height: 10em;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border: 2px dotted black;
|
||||
}
|
||||
.two-col {
|
||||
display: inline-block;
|
||||
width: 48%;
|
||||
}
|
||||
.two-col label {
|
||||
width: 48%;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission activity</h1>
|
||||
<h1>Permission playground</h1>
|
||||
|
||||
{% set current_tab = "permissions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<h2>Raw simulator</h2>
|
||||
|
||||
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
|
||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||
<div class="permission-form-grid">
|
||||
<div>
|
||||
<div class="form-section">
|
||||
<label for="activity-actor">Actor</label>
|
||||
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="form-section">
|
||||
<label>Actor</label>
|
||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col" style="vertical-align: top">
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
|
|
@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) {
|
|||
});
|
||||
</script>
|
||||
|
||||
<h2>Recent permission checks</h2>
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
<p>
|
||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{% block title %}Permission Rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('_page');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== '_size' && key !== '_page') {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('_page', page.toString());
|
||||
params.append('_size', pageSize);
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||
|
|
|
|||
|
|
@ -56,11 +56,6 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.execute-write form.sql .sql-editor-min-lines .cm-content,
|
||||
.execute-write form.sql .sql-editor-min-lines .cm-gutter {
|
||||
/* Four visible editor lines without adding blank lines to the SQL value. */
|
||||
min-height: calc(5.6em + 8px);
|
||||
}
|
||||
.execute-write-disabled-reason {
|
||||
color: #4f5b6d;
|
||||
font-size: 0.85rem;
|
||||
|
|
@ -98,25 +93,20 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
{% endif %}
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
|
||||
{% if write_create_table_template_sql or write_template_tables %}
|
||||
{% if 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>
|
||||
<label for="execute-write-template-table">Table</label>
|
||||
<select id="execute-write-template-table">
|
||||
{% for table_name, table in write_template_tables|dictsort %}
|
||||
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
{% for operation in write_template_operations %}
|
||||
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
|
||||
{% endfor %}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
|
@ -124,7 +114,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="sql-editor{% 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>
|
||||
<p class="sql-editor"><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 %}
|
||||
|
|
@ -169,13 +159,19 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
</p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (executeWriteSqlInput && !executeWriteSqlInput.value) {
|
||||
executeWriteSqlInput.value = "\n\n\n";
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
||||
const submitButton = form
|
||||
|
|
@ -256,12 +252,11 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
});
|
||||
</script>
|
||||
|
||||
{% if write_create_table_template_sql or write_template_tables %}
|
||||
{% if 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`;
|
||||
|
|
@ -271,59 +266,26 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
|
||||
}
|
||||
|
||||
function templateSql(button) {
|
||||
if (button.dataset.templateSql) {
|
||||
return button.dataset.templateSql;
|
||||
}
|
||||
const operation = button.dataset.sqlTemplate;
|
||||
function templateSql(operation) {
|
||||
const option = selectedOption();
|
||||
return option ? option.dataset[dataKey(operation)] || "" : "";
|
||||
}
|
||||
|
||||
function updateTemplateButtons() {
|
||||
templateButtons.forEach((button) => {
|
||||
button.hidden = !templateSql(button);
|
||||
button.hidden = !templateSql(button.dataset.sqlTemplate);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const sql = templateSql(button.dataset.sqlTemplate);
|
||||
if (!sql) {
|
||||
return;
|
||||
}
|
||||
setEditorSql(sql);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sql", sql);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
});
|
||||
if (tableSelect) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title>Datasette: Pattern Portfolio</title>
|
||||
<link rel="stylesheet" href="{{ static('app.css') }}">
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex">
|
||||
<style></style>
|
||||
|
|
@ -202,9 +202,9 @@
|
|||
<h3>3 rows
|
||||
where characteristic_id = 2
|
||||
</h3>
|
||||
<form class="core filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
||||
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column_1">
|
||||
<option value="">- remove filter -</option>
|
||||
|
|
@ -238,7 +238,7 @@
|
|||
</select>
|
||||
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
|
||||
</div>
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column">
|
||||
<option value="">- column -</option>
|
||||
|
|
@ -272,8 +272,8 @@
|
|||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
</div>
|
||||
<div class="filter-row filter-actions-row">
|
||||
<div class="select-wrapper">
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper small-screen-only">
|
||||
<select name="_sort" id="sort_by">
|
||||
<option value="">Sort...</option>
|
||||
<option value="rowid" selected>Sort by rowid</option>
|
||||
|
|
@ -281,8 +281,8 @@
|
|||
<option value="characteristic_id">Sort by characteristic_id</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
||||
<input type="submit" value="Apply filters">
|
||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
||||
<input type="submit" value="Apply">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,139 @@
|
|||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_execute_write_analysis_styles.html" %}
|
||||
{% include "_query_form_styles.html" %}
|
||||
<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-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>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -205,32 +205,32 @@
|
|||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
|
||||
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
|
|
|
|||
|
|
@ -4,13 +4,6 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% if row_mutation_ui %}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in columns %}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
<script src="{{ static('column-chooser.js') }}" defer></script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||
<script src="{{ static('table.js') }}" defer></script>
|
||||
<script src="{{ static('mobile-column-actions.js') }}" defer></script>
|
||||
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
@ -48,19 +43,19 @@
|
|||
|
||||
{% if count or human_description_en %}
|
||||
<h3>
|
||||
{% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows
|
||||
{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows
|
||||
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
{% if supports_search %}
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
||||
{% endif %}
|
||||
{% for column, lookup, value in filters.selections() %}
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column_{{ loop.index }}">
|
||||
<option value="">- remove filter -</option>
|
||||
|
|
@ -77,7 +72,7 @@
|
|||
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column">
|
||||
<option value="">- column -</option>
|
||||
|
|
@ -93,9 +88,9 @@
|
|||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
</div>
|
||||
<div class="filter-row filter-actions-row">
|
||||
<div class="filter-row">
|
||||
{% if is_sortable %}
|
||||
<div class="select-wrapper">
|
||||
<div class="select-wrapper small-screen-only">
|
||||
<select name="_sort" id="sort_by">
|
||||
<option value="">Sort...</option>
|
||||
{% for column in display_columns %}
|
||||
|
|
@ -105,12 +100,12 @@
|
|||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
|
||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
|
||||
{% endif %}
|
||||
{% for key, value in form_hidden_args %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<input type="submit" value="Apply filters" tabindex="0">
|
||||
<input type="submit" value="Apply">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
@ -163,19 +158,6 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
|||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if table_insert_ui %}
|
||||
<div class="table-row-toolbar">
|
||||
<button type="button" class="core table-insert-row" data-table-action="insert-row">
|
||||
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
||||
<path d="M8 12h8"></path>
|
||||
<path d="M12 8v8"></path>
|
||||
</svg>
|
||||
<span>Insert row</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
{% if next_url %}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||
|
||||
import dataclasses
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import itsdangerous
|
||||
|
||||
|
|
@ -18,21 +18,6 @@ if TYPE_CHECKING:
|
|||
from datasette.app import Datasette
|
||||
|
||||
|
||||
class TokenInvalid(Exception):
|
||||
"""
|
||||
Raised by a TokenHandler when a token it recognizes is invalid -
|
||||
for example a bad signature, malformed payload or expired token.
|
||||
|
||||
Datasette responds to this with an HTTP 401 error. Handlers should
|
||||
return None instead for tokens they do not recognize at all, so that
|
||||
other registered handlers get a chance to verify them.
|
||||
"""
|
||||
|
||||
def __init__(self, message="Invalid token"):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TokenRestrictions:
|
||||
"""
|
||||
|
|
@ -50,24 +35,24 @@ class TokenRestrictions:
|
|||
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
||||
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
|
||||
|
||||
def allow_all(self, action: str) -> TokenRestrictions:
|
||||
def allow_all(self, action: str) -> "TokenRestrictions":
|
||||
"""Allow an action across all databases and resources."""
|
||||
self.all.append(action)
|
||||
return self
|
||||
|
||||
def allow_database(self, database: str, action: str) -> TokenRestrictions:
|
||||
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
|
||||
"""Allow an action on a specific database."""
|
||||
self.database.setdefault(database, []).append(action)
|
||||
return self
|
||||
|
||||
def allow_resource(
|
||||
self, database: str, resource: str, action: str
|
||||
) -> TokenRestrictions:
|
||||
) -> "TokenRestrictions":
|
||||
"""Allow an action on a specific resource within a database."""
|
||||
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
||||
return self
|
||||
|
||||
def abbreviated(self, datasette: Datasette) -> dict | None:
|
||||
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
|
||||
"""
|
||||
Return the abbreviated ``_r`` dictionary shape for this set of
|
||||
restrictions, using action abbreviations registered with ``datasette``.
|
||||
|
|
@ -112,23 +97,19 @@ class TokenHandler:
|
|||
|
||||
async def create_token(
|
||||
self,
|
||||
datasette: Datasette,
|
||||
datasette: "Datasette",
|
||||
actor_id: str,
|
||||
*,
|
||||
expires_after: int | None = None,
|
||||
restrictions: TokenRestrictions | None = None,
|
||||
expires_after: Optional[int] = None,
|
||||
restrictions: Optional[TokenRestrictions] = None,
|
||||
) -> str:
|
||||
"""Create and return a token string for the given actor."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
|
||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verify a token and return an actor dict.
|
||||
|
||||
Return None if this handler does not recognize the token at all,
|
||||
so other handlers can try it. Raise TokenInvalid if the token is
|
||||
recognized but invalid (bad signature, malformed, expired) - the
|
||||
request will fail with a 401 error.
|
||||
Verify a token and return an actor dict, or None if this handler
|
||||
does not recognize the token.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
|
@ -142,11 +123,11 @@ class SignedTokenHandler(TokenHandler):
|
|||
|
||||
async def create_token(
|
||||
self,
|
||||
datasette: Datasette,
|
||||
datasette: "Datasette",
|
||||
actor_id: str,
|
||||
*,
|
||||
expires_after: int | None = None,
|
||||
restrictions: TokenRestrictions | None = None,
|
||||
expires_after: Optional[int] = None,
|
||||
restrictions: Optional[TokenRestrictions] = None,
|
||||
) -> str:
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
raise ValueError(
|
||||
|
|
@ -163,35 +144,32 @@ class SignedTokenHandler(TokenHandler):
|
|||
token["_r"] = abbreviated
|
||||
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
||||
|
||||
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
|
||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||
prefix = "dstok_"
|
||||
|
||||
if not token.startswith(prefix):
|
||||
# Not one of our tokens - leave it for other handlers
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
return None
|
||||
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
raise TokenInvalid(
|
||||
"Signed tokens are not enabled for this Datasette instance"
|
||||
)
|
||||
|
||||
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||
|
||||
if not token.startswith(prefix):
|
||||
return None
|
||||
|
||||
raw = token[len(prefix) :]
|
||||
try:
|
||||
decoded = datasette.unsign(raw, namespace="token")
|
||||
except itsdangerous.BadSignature:
|
||||
raise TokenInvalid("Invalid token signature")
|
||||
return None
|
||||
|
||||
if "t" not in decoded:
|
||||
raise TokenInvalid("Invalid token: no timestamp")
|
||||
return None
|
||||
created = decoded["t"]
|
||||
if not isinstance(created, int):
|
||||
raise TokenInvalid("Invalid token: invalid timestamp")
|
||||
return None
|
||||
|
||||
duration = decoded.get("d")
|
||||
if duration is not None and not isinstance(duration, int):
|
||||
raise TokenInvalid("Invalid token: invalid duration")
|
||||
return None
|
||||
|
||||
if (duration is None and max_signed_tokens_ttl) or (
|
||||
duration is not None
|
||||
|
|
@ -200,8 +178,9 @@ class SignedTokenHandler(TokenHandler):
|
|||
):
|
||||
duration = max_signed_tokens_ttl
|
||||
|
||||
if duration and time.time() - created > duration:
|
||||
raise TokenInvalid("Token has expired")
|
||||
if duration:
|
||||
if time.time() - created > duration:
|
||||
return None
|
||||
|
||||
actor = {"id": decoded["a"], "token": "dstok"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from markupsafe import escape
|
||||
import time
|
||||
import json
|
||||
import traceback
|
||||
|
||||
tracers = {}
|
||||
|
||||
|
|
@ -28,10 +27,8 @@ def get_task_id():
|
|||
@contextmanager
|
||||
def trace_child_tasks():
|
||||
token = trace_task_id.set(get_task_id())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
trace_task_id.reset(token)
|
||||
yield
|
||||
trace_task_id.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -133,17 +130,17 @@ class AsgiTracer:
|
|||
"num_traces": len(traces),
|
||||
"traces": traces,
|
||||
}
|
||||
content_type = next(
|
||||
(
|
||||
try:
|
||||
content_type = [
|
||||
v.decode("utf8")
|
||||
for k, v in response_headers
|
||||
if k.lower() == b"content-type"
|
||||
),
|
||||
"",
|
||||
)
|
||||
][0]
|
||||
except IndexError:
|
||||
content_type = ""
|
||||
if "text/html" in content_type and b"</body>" in accumulated_body:
|
||||
extra = escape(json.dumps(trace_info, indent=2))
|
||||
extra_html = f"<pre>{extra}</pre></body>".encode()
|
||||
extra_html = f"<pre>{extra}</pre></body>".encode("utf8")
|
||||
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
|
||||
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
||||
data = json.loads(accumulated_body.decode("utf8"))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from .utils import tilde_encode, path_with_format, PrefixedUrlString
|
||||
import urllib
|
||||
|
||||
from .utils import PrefixedUrlString, path_with_format, tilde_encode
|
||||
|
||||
|
||||
class Urls:
|
||||
def __init__(self, ds):
|
||||
|
|
@ -9,7 +8,8 @@ class Urls:
|
|||
|
||||
def path(self, path, format=None):
|
||||
if not isinstance(path, PrefixedUrlString):
|
||||
path = path.removeprefix("/")
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
path = self.ds.setting("base_url") + path
|
||||
if format is not None:
|
||||
path = path_with_format(path=path, format=format)
|
||||
|
|
@ -56,7 +56,6 @@ class Urls:
|
|||
return PrefixedUrlString(path)
|
||||
|
||||
def row_blob(self, database, table, row_path, column):
|
||||
return (
|
||||
self.table(database, table)
|
||||
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
|
||||
return self.table(database, table) + "/{}.blob?_blob_column={}".format(
|
||||
row_path, urllib.parse.quote_plus(column)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,28 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
from contextlib import contextmanager
|
||||
import aiofiles
|
||||
import click
|
||||
from collections import OrderedDict, namedtuple, Counter
|
||||
import copy
|
||||
import dataclasses
|
||||
import base64
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import typing
|
||||
import urllib
|
||||
from collections import Counter, OrderedDict, namedtuple
|
||||
from collections.abc import Iterable
|
||||
from contextlib import contextmanager
|
||||
|
||||
import aiofiles
|
||||
import click
|
||||
import markupsafe
|
||||
import mergedeep
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import tempfile
|
||||
import typing
|
||||
import time
|
||||
import types
|
||||
import secrets
|
||||
import shutil
|
||||
from typing import Iterable, List, Tuple
|
||||
import urllib
|
||||
import yaml
|
||||
|
||||
from .shutil_backport import copytree
|
||||
from .sqlite import sqlite3, supports_table_xinfo
|
||||
|
||||
|
|
@ -38,7 +35,7 @@ if typing.TYPE_CHECKING:
|
|||
class PaginatedResources:
|
||||
"""Paginated results from allowed_resources query."""
|
||||
|
||||
resources: list["Resource"]
|
||||
resources: List["Resource"]
|
||||
next: str | None # Keyset token for next page (None if no more results)
|
||||
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
||||
_action: str = dataclasses.field(default=None, repr=False)
|
||||
|
|
@ -85,132 +82,22 @@ class PaginatedResources:
|
|||
|
||||
|
||||
# From https://www.sqlite.org/lang_keywords.html
|
||||
reserved_words = {
|
||||
"abort",
|
||||
"action",
|
||||
"add",
|
||||
"after",
|
||||
"all",
|
||||
"alter",
|
||||
"analyze",
|
||||
"and",
|
||||
"as",
|
||||
"asc",
|
||||
"attach",
|
||||
"autoincrement",
|
||||
"before",
|
||||
"begin",
|
||||
"between",
|
||||
"by",
|
||||
"cascade",
|
||||
"case",
|
||||
"cast",
|
||||
"check",
|
||||
"collate",
|
||||
"column",
|
||||
"commit",
|
||||
"conflict",
|
||||
"constraint",
|
||||
"create",
|
||||
"cross",
|
||||
"current_date",
|
||||
"current_time",
|
||||
"current_timestamp",
|
||||
"database",
|
||||
"default",
|
||||
"deferrable",
|
||||
"deferred",
|
||||
"delete",
|
||||
"desc",
|
||||
"detach",
|
||||
"distinct",
|
||||
"drop",
|
||||
"each",
|
||||
"else",
|
||||
"end",
|
||||
"escape",
|
||||
"except",
|
||||
"exclusive",
|
||||
"exists",
|
||||
"explain",
|
||||
"fail",
|
||||
"for",
|
||||
"foreign",
|
||||
"from",
|
||||
"full",
|
||||
"glob",
|
||||
"group",
|
||||
"having",
|
||||
"if",
|
||||
"ignore",
|
||||
"immediate",
|
||||
"in",
|
||||
"index",
|
||||
"indexed",
|
||||
"initially",
|
||||
"inner",
|
||||
"insert",
|
||||
"instead",
|
||||
"intersect",
|
||||
"into",
|
||||
"is",
|
||||
"isnull",
|
||||
"join",
|
||||
"key",
|
||||
"left",
|
||||
"like",
|
||||
"limit",
|
||||
"match",
|
||||
"natural",
|
||||
"no",
|
||||
"not",
|
||||
"notnull",
|
||||
"null",
|
||||
"of",
|
||||
"offset",
|
||||
"on",
|
||||
"or",
|
||||
"order",
|
||||
"outer",
|
||||
"plan",
|
||||
"pragma",
|
||||
"primary",
|
||||
"query",
|
||||
"raise",
|
||||
"recursive",
|
||||
"references",
|
||||
"regexp",
|
||||
"reindex",
|
||||
"release",
|
||||
"rename",
|
||||
"replace",
|
||||
"restrict",
|
||||
"right",
|
||||
"rollback",
|
||||
"row",
|
||||
"savepoint",
|
||||
"select",
|
||||
"set",
|
||||
"table",
|
||||
"temp",
|
||||
"temporary",
|
||||
"then",
|
||||
"to",
|
||||
"transaction",
|
||||
"trigger",
|
||||
"union",
|
||||
"unique",
|
||||
"update",
|
||||
"using",
|
||||
"vacuum",
|
||||
"values",
|
||||
"view",
|
||||
"virtual",
|
||||
"when",
|
||||
"where",
|
||||
"with",
|
||||
"without",
|
||||
}
|
||||
reserved_words = set(
|
||||
(
|
||||
"abort action add after all alter analyze and as asc attach autoincrement "
|
||||
"before begin between by cascade case cast check collate column commit "
|
||||
"conflict constraint create cross current_date current_time "
|
||||
"current_timestamp database default deferrable deferred delete desc detach "
|
||||
"distinct drop each else end escape except exclusive exists explain fail "
|
||||
"for foreign from full glob group having if ignore immediate in index "
|
||||
"indexed initially inner insert instead intersect into is isnull join key "
|
||||
"left like limit match natural no not notnull null of offset on or order "
|
||||
"outer plan pragma primary query raise recursive references regexp reindex "
|
||||
"release rename replace restrict right rollback row savepoint select set "
|
||||
"table temp temporary then to transaction trigger union unique update using "
|
||||
"vacuum values view virtual when where with without"
|
||||
).split()
|
||||
)
|
||||
|
||||
APT_GET_DOCKERFILE_EXTRAS = r"""
|
||||
RUN apt-get update && \
|
||||
|
|
@ -270,7 +157,7 @@ functions_marked_as_documented = []
|
|||
|
||||
def documented(fn=None, *, label=None):
|
||||
def decorate(fn):
|
||||
fn._datasette_docs_label = label or f"internals_utils_{fn.__name__}"
|
||||
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
|
||||
functions_marked_as_documented.append(fn)
|
||||
return fn
|
||||
|
||||
|
|
@ -337,71 +224,24 @@ def compound_keys_after_sql(pks, start_index=0):
|
|||
return "({})".format("\n or\n".join(or_clauses))
|
||||
|
||||
|
||||
@documented
|
||||
class CustomJSONEncoder(json.JSONEncoder):
|
||||
"""
|
||||
The CustomJSONEncoder class handles serialization for objects commonly used by Datasette,
|
||||
including SQLite cursors and binary blobs. Datasette uses it internally to serve .json endpoints,
|
||||
and plugins that return JSON can use it to match Datasette's own handling.
|
||||
|
||||
Built-in types (text, numbers, lists, etc) are encoded the same as Python's built-in ``json`` module.
|
||||
|
||||
- ``sqlite3.Row`` becomes a tuple
|
||||
- ``sqlite3.Cursor`` becomes a list
|
||||
|
||||
Binary blobs are encoded as an object, with the actual data base64-encoded,
|
||||
like so: ::
|
||||
|
||||
{
|
||||
"$base64": True,
|
||||
"encoded": ...,
|
||||
}
|
||||
|
||||
Example: https://latest.datasette.io/fixtures/binary_data.json
|
||||
"""
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, sqlite3.Row):
|
||||
return tuple(obj)
|
||||
if isinstance(obj, sqlite3.Cursor):
|
||||
return list(obj)
|
||||
if isinstance(obj, bytes):
|
||||
return {
|
||||
"$base64": True,
|
||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||
}
|
||||
# Does it encode to utf8?
|
||||
try:
|
||||
return obj.decode("utf8")
|
||||
except UnicodeDecodeError:
|
||||
return {
|
||||
"$base64": True,
|
||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||
}
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
class WriteJsonValueError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def decode_write_json_cell(value):
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
keys = set(value.keys())
|
||||
if keys == {"$raw"}:
|
||||
return value["$raw"]
|
||||
if keys == {"$base64", "encoded"} and value.get("$base64") is True:
|
||||
encoded = value["encoded"]
|
||||
if not isinstance(encoded, str):
|
||||
raise WriteJsonValueError("$base64 encoded value must be a string")
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except binascii.Error as ex:
|
||||
raise WriteJsonValueError("Invalid $base64 encoded value") from ex
|
||||
return value
|
||||
|
||||
|
||||
def decode_write_json_row(row):
|
||||
return {key: decode_write_json_cell(value) for key, value in row.items()}
|
||||
|
||||
|
||||
def decode_write_json_rows(rows):
|
||||
return [decode_write_json_row(row) for row in rows]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sqlite_timelimit(conn, ms):
|
||||
deadline = time.perf_counter() + (ms / 1000)
|
||||
|
|
@ -472,7 +312,7 @@ disallawed_sql_res = [
|
|||
(
|
||||
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
||||
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
|
||||
", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas)
|
||||
", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -570,7 +410,8 @@ def escape_css_string(s):
|
|||
def escape_sqlite(s):
|
||||
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
||||
return s
|
||||
return '"{}"'.format(s.replace('"', '""'))
|
||||
else:
|
||||
return f"[{s}]"
|
||||
|
||||
|
||||
def make_dockerfile(
|
||||
|
|
@ -646,7 +487,10 @@ CMD {cmd}""".format(
|
|||
else ""
|
||||
),
|
||||
environment_variables="\n".join(
|
||||
[f"ENV {key} '{value}'" for key, value in environment_variables.items()]
|
||||
[
|
||||
"ENV {} '{}'".format(key, value)
|
||||
for key, value in environment_variables.items()
|
||||
]
|
||||
),
|
||||
install_from=" ".join(install),
|
||||
files=" ".join(files),
|
||||
|
|
@ -745,11 +589,11 @@ def detect_primary_keys(conn, table):
|
|||
|
||||
|
||||
def get_outbound_foreign_keys(conn, table):
|
||||
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
|
||||
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
|
||||
fks = []
|
||||
for info in infos:
|
||||
if info is not None:
|
||||
id, seq, table_name, from_, to_, _on_update, _on_delete, _match = info
|
||||
id, seq, table_name, from_, to_, on_update, on_delete, match = info
|
||||
fks.append(
|
||||
{
|
||||
"column": from_,
|
||||
|
|
@ -850,7 +694,7 @@ def detect_json1(conn=None):
|
|||
try:
|
||||
conn.execute("SELECT json('{}')")
|
||||
return True
|
||||
except sqlite3.Error:
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
if close_conn:
|
||||
|
|
@ -930,7 +774,9 @@ def is_url(value):
|
|||
if not value.startswith("http://") and not value.startswith("https://"):
|
||||
return False
|
||||
# Any whitespace at all is invalid
|
||||
return not whitespace_re.search(value)
|
||||
if whitespace_re.search(value):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
|
||||
|
|
@ -983,9 +829,7 @@ def module_from_path(path, name):
|
|||
mod.__file__ = path
|
||||
with open(path, "r") as file:
|
||||
code = compile(file.read(), path, "exec", dont_inherit=True)
|
||||
# Executing the file is the whole point - this is how --plugins-dir loads
|
||||
# plugins and how metadata/config .py files are evaluated
|
||||
exec(code, mod.__dict__) # noqa: S102
|
||||
exec(code, mod.__dict__)
|
||||
return mod
|
||||
|
||||
|
||||
|
|
@ -1142,7 +986,9 @@ def escape_fts(query):
|
|||
query += '"'
|
||||
bits = _escape_fts_re.split(query)
|
||||
bits = [b for b in bits if b and b != '""']
|
||||
return " ".join(f'"{bit}"' if not bit.startswith('"') else bit for bit in bits)
|
||||
return " ".join(
|
||||
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
|
||||
)
|
||||
|
||||
|
||||
class MultiParams:
|
||||
|
|
@ -1154,7 +1000,7 @@ class MultiParams:
|
|||
data[key], (list, tuple)
|
||||
), "dictionary data should be a dictionary of key => [list]"
|
||||
self._data = data
|
||||
elif isinstance(data, (list, tuple)):
|
||||
elif isinstance(data, list) or isinstance(data, tuple):
|
||||
new_data = {}
|
||||
for item in data:
|
||||
assert (
|
||||
|
|
@ -1244,7 +1090,9 @@ def _gather_arguments(fn, kwargs):
|
|||
for parameter in parameters:
|
||||
if parameter not in kwargs:
|
||||
raise TypeError(
|
||||
f"{fn} requires parameters {tuple(parameters)}, missing: {set(parameters) - set(kwargs.keys())}"
|
||||
"{} requires parameters {}, missing: {}".format(
|
||||
fn, tuple(parameters), set(parameters) - set(kwargs.keys())
|
||||
)
|
||||
)
|
||||
call_with.append(kwargs[parameter])
|
||||
return call_with
|
||||
|
|
@ -1313,9 +1161,9 @@ def resolve_env_secrets(config, environ):
|
|||
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
||||
if isinstance(config, dict):
|
||||
if list(config.keys()) == ["$env"]:
|
||||
return environ.get(next(iter(config.values())))
|
||||
return environ.get(list(config.values())[0])
|
||||
elif list(config.keys()) == ["$file"]:
|
||||
with open(next(iter(config.values()))) as fp:
|
||||
with open(list(config.values())[0]) as fp:
|
||||
return fp.read()
|
||||
else:
|
||||
return {
|
||||
|
|
@ -1393,38 +1241,29 @@ class StartupError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
# Comments and string literals, matched in a single pass so that whichever
|
||||
# construct starts first "wins" - this ensures a comment marker inside a string
|
||||
# literal (or a quote inside a comment) does not confuse the parameter scan.
|
||||
_comments_and_strings_re = re.compile(
|
||||
r"""
|
||||
--[^\n]* # single line comment
|
||||
| /\*.*?(?:\*/|\Z) # multi line comment, possibly to end-of-input
|
||||
| '(?:''|[^'])*' # single quoted string ('' escapes a quote)
|
||||
| "(?:""|[^"])*" # double quoted identifier ("" escapes a quote)
|
||||
| \[(?:[^\]])*\] # square-bracket quoted identifier
|
||||
| `(?:``|[^`])*` # backtick quoted identifier
|
||||
""",
|
||||
re.DOTALL | re.VERBOSE,
|
||||
)
|
||||
_single_line_comment_re = re.compile(r"--.*")
|
||||
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
|
||||
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
|
||||
_named_param_re = re.compile(r":(\w+)")
|
||||
|
||||
|
||||
@documented
|
||||
def named_parameters(sql: str) -> list[str]:
|
||||
def named_parameters(sql: str) -> List[str]:
|
||||
"""
|
||||
Given a SQL statement, return a list of named parameters that are used in the statement
|
||||
|
||||
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
||||
"""
|
||||
# Strip comments and string literals first so that any ":name" sequences
|
||||
# inside them are not mistaken for named parameters
|
||||
sql = _comments_and_strings_re.sub("", sql)
|
||||
sql = _single_line_comment_re.sub("", sql)
|
||||
sql = _multi_line_comment_re.sub("", sql)
|
||||
sql = _single_quote_re.sub("", sql)
|
||||
sql = _double_quote_re.sub("", sql)
|
||||
# Extract parameters from what is left
|
||||
return _named_param_re.findall(sql)
|
||||
|
||||
|
||||
async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
|
||||
async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
|
||||
"""
|
||||
This undocumented but stable method exists for backwards compatibility
|
||||
with plugins that were using it before it switched to named_parameters()
|
||||
|
|
@ -1432,54 +1271,6 @@ async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
|
|||
return named_parameters(sql)
|
||||
|
||||
|
||||
def parse_size_limit(value, default, maximum, name="_size"):
|
||||
"""
|
||||
Parse a page-size parameter using the same semantics as the table
|
||||
view's ?_size=: blank means default, "max" means maximum, integers
|
||||
must be 0 or greater and no larger than maximum. Raises ValueError
|
||||
with a message suitable for a 400 response.
|
||||
"""
|
||||
if value in (None, ""):
|
||||
return default
|
||||
if value == "max":
|
||||
return maximum
|
||||
try:
|
||||
size = int(value)
|
||||
if size < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} must be a positive integer")
|
||||
if size > maximum:
|
||||
raise ValueError(f"{name} must be <= {maximum}")
|
||||
return size
|
||||
|
||||
|
||||
UNSTABLE_API_MESSAGE = (
|
||||
"This API is not part of Datasette's stable interface and may change at any time"
|
||||
)
|
||||
|
||||
|
||||
def error_body(messages, status):
|
||||
"""
|
||||
The canonical JSON error body used by every Datasette JSON error response:
|
||||
|
||||
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
|
||||
|
||||
"error" is all of the messages joined with "; ", "errors" is the full
|
||||
list, "status" matches the HTTP status code. Callers may add extra
|
||||
context keys to the returned dictionary but must not remove these four.
|
||||
"""
|
||||
if isinstance(messages, str):
|
||||
messages = [messages]
|
||||
messages = [str(message) for message in messages]
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "; ".join(messages),
|
||||
"errors": messages,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def add_cors_headers(headers):
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||
|
|
@ -1508,7 +1299,7 @@ class TildeEncoder(dict):
|
|||
elif b == _space:
|
||||
res = "+"
|
||||
else:
|
||||
res = f"~{b:02X}"
|
||||
res = "~{:02X}".format(b)
|
||||
self[b] = res
|
||||
return res
|
||||
|
||||
|
|
@ -1603,7 +1394,7 @@ def _combine(base: dict, update: dict) -> dict:
|
|||
return base
|
||||
|
||||
|
||||
def pairs_to_nested_config(pairs: list[tuple[str, typing.Any]]) -> dict:
|
||||
def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
|
||||
"""
|
||||
Parse a list of key-value pairs into a nested dictionary.
|
||||
"""
|
||||
|
|
@ -1618,7 +1409,7 @@ def make_slot_function(name, datasette, request, **kwargs):
|
|||
from datasette.plugins import pm
|
||||
|
||||
method = getattr(pm.hook, name, None)
|
||||
assert method is not None, f"No hook found for {name}"
|
||||
assert method is not None, "No hook found for {}".format(name)
|
||||
|
||||
async def inner():
|
||||
html_bits = []
|
||||
|
|
@ -1642,7 +1433,7 @@ def prune_empty_dicts(d: dict):
|
|||
d.pop(key, None)
|
||||
|
||||
|
||||
def move_plugins_and_allow(source: dict, destination: dict) -> tuple[dict, dict]:
|
||||
def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
|
||||
"""
|
||||
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
|
||||
hierarchy in destination if needed. After moving, recursively remove any keys
|
||||
|
|
@ -1753,17 +1544,6 @@ def md5_not_usedforsecurity(s):
|
|||
_etag_cache = {}
|
||||
|
||||
|
||||
def sha256_file(filepath, chunk_size=4096):
|
||||
hasher = hashlib.sha256()
|
||||
with open(filepath, "rb") as fp:
|
||||
while True:
|
||||
chunk = fp.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
async def calculate_etag(filepath, chunk_size=4096):
|
||||
if filepath in _etag_cache:
|
||||
return _etag_cache[filepath]
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ The core pattern is:
|
|||
- Across levels, child beats parent beats global
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||
|
|
@ -252,62 +250,88 @@ async def _build_single_action_sql(
|
|||
]
|
||||
)
|
||||
|
||||
# Continue with the cascading logic.
|
||||
# Aggregate the RULES by cascade level (small), rather than grouping
|
||||
# base x rules (which scales with the number of resources).
|
||||
def _agg(select_key, where, group_by):
|
||||
parts = [
|
||||
f" SELECT {select_key}",
|
||||
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
|
||||
f" FROM all_rules WHERE {where}",
|
||||
]
|
||||
if group_by:
|
||||
parts.append(f" GROUP BY {group_by}")
|
||||
return parts
|
||||
|
||||
# Continue with the cascading logic
|
||||
query_parts.extend(
|
||||
["child_agg AS ("]
|
||||
+ _agg(
|
||||
"parent, child,",
|
||||
"parent IS NOT NULL AND child IS NOT NULL",
|
||||
"parent, child",
|
||||
)
|
||||
+ ["),", "parent_agg AS ("]
|
||||
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
||||
+ ["),", "global_agg AS ("]
|
||||
+ _agg("", "parent IS NULL AND child IS NULL", None)
|
||||
+ ["),"]
|
||||
[
|
||||
"child_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"parent_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"global_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Add anonymous decision logic if needed
|
||||
if include_is_private:
|
||||
|
||||
def _anon_agg(select_key, where, group_by):
|
||||
parts = [
|
||||
f" SELECT {select_key}",
|
||||
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
f" FROM anon_rules WHERE {where}",
|
||||
]
|
||||
if group_by:
|
||||
parts.append(f" GROUP BY {group_by}")
|
||||
return parts
|
||||
|
||||
query_parts.extend(
|
||||
["anon_child_agg AS ("]
|
||||
+ _anon_agg(
|
||||
"parent, child,",
|
||||
"parent IS NOT NULL AND child IS NOT NULL",
|
||||
"parent, child",
|
||||
)
|
||||
+ ["),", "anon_parent_agg AS ("]
|
||||
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
||||
+ ["),", "anon_global_agg AS ("]
|
||||
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
|
||||
+ ["),"]
|
||||
[
|
||||
"anon_child_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_parent_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_global_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" CASE",
|
||||
" WHEN acl.any_deny = 1 THEN 0",
|
||||
" WHEN acl.any_allow = 1 THEN 1",
|
||||
" WHEN apl.any_deny = 1 THEN 0",
|
||||
" WHEN apl.any_allow = 1 THEN 1",
|
||||
" WHEN agl.any_deny = 1 THEN 0",
|
||||
" WHEN agl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS anon_is_allowed",
|
||||
" FROM base b",
|
||||
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
|
||||
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
|
||||
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Final decisions
|
||||
|
|
@ -316,28 +340,31 @@ async def _build_single_action_sql(
|
|||
"decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
|
||||
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
|
||||
" -- Priority order:",
|
||||
" -- 1. Child-level deny 2. Child-level allow",
|
||||
" -- 3. Parent-level deny 4. Parent-level allow",
|
||||
" -- 5. Global-level deny 6. Global-level allow",
|
||||
" -- 1. Child-level deny (most specific, blocks access)",
|
||||
" -- 2. Child-level allow (most specific, grants access)",
|
||||
" -- 3. Parent-level deny (intermediate, blocks access)",
|
||||
" -- 4. Parent-level allow (intermediate, grants access)",
|
||||
" -- 5. Global-level deny (least specific, blocks access)",
|
||||
" -- 6. Global-level allow (least specific, grants access)",
|
||||
" -- 7. Default deny (no rules match)",
|
||||
" CASE",
|
||||
" WHEN ca.any_deny = 1 THEN 0",
|
||||
" WHEN ca.any_allow = 1 THEN 1",
|
||||
" WHEN pa.any_deny = 1 THEN 0",
|
||||
" WHEN pa.any_allow = 1 THEN 1",
|
||||
" WHEN ga.any_deny = 1 THEN 0",
|
||||
" WHEN ga.any_allow = 1 THEN 1",
|
||||
" WHEN cl.any_deny = 1 THEN 0",
|
||||
" WHEN cl.any_allow = 1 THEN 1",
|
||||
" WHEN pl.any_deny = 1 THEN 0",
|
||||
" WHEN pl.any_allow = 1 THEN 1",
|
||||
" WHEN gl.any_deny = 1 THEN 0",
|
||||
" WHEN gl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS is_allowed,",
|
||||
" CASE",
|
||||
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
|
||||
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
|
||||
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
|
||||
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
|
||||
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
|
||||
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
|
||||
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
|
||||
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
|
||||
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
|
||||
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
|
||||
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
|
||||
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
|
||||
" ELSE '[]'",
|
||||
" END AS reason",
|
||||
]
|
||||
|
|
@ -345,34 +372,21 @@ async def _build_single_action_sql(
|
|||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" , CASE WHEN ("
|
||||
"CASE"
|
||||
" WHEN aca.any_deny = 1 THEN 0"
|
||||
" WHEN aca.any_allow = 1 THEN 1"
|
||||
" WHEN apa.any_deny = 1 THEN 0"
|
||||
" WHEN apa.any_allow = 1 THEN 1"
|
||||
" WHEN aga.any_deny = 1 THEN 0"
|
||||
" WHEN aga.any_allow = 1 THEN 1"
|
||||
" ELSE 0 END"
|
||||
") = 0 THEN 1 ELSE 0 END AS is_private"
|
||||
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
||||
)
|
||||
|
||||
query_parts.extend(
|
||||
[
|
||||
" FROM base b",
|
||||
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
|
||||
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
|
||||
" CROSS JOIN global_agg ga",
|
||||
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
||||
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
||||
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
|
||||
]
|
||||
)
|
||||
|
||||
if include_is_private:
|
||||
query_parts.extend(
|
||||
[
|
||||
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
|
||||
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
|
||||
" CROSS JOIN anon_global_agg aga",
|
||||
]
|
||||
query_parts.append(
|
||||
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
|
||||
)
|
||||
|
||||
query_parts.append(")")
|
||||
|
|
@ -384,28 +398,8 @@ async def _build_single_action_sql(
|
|||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
# Decompose by NULL-pattern so the final filter can use pure-equality
|
||||
# EXISTS lookups (satisfiable via automatic indexes) instead of a
|
||||
# correlated OR-scan over the whole list.
|
||||
query_parts.extend(
|
||||
[
|
||||
",",
|
||||
"restriction_list AS (",
|
||||
f" {restriction_intersect}",
|
||||
"),",
|
||||
"restriction_exact AS (",
|
||||
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
|
||||
"),",
|
||||
"restriction_parent_any AS (",
|
||||
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
|
||||
"),",
|
||||
"restriction_child_any AS (",
|
||||
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
|
||||
"),",
|
||||
"restriction_all AS (",
|
||||
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
|
||||
")",
|
||||
]
|
||||
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
|
||||
)
|
||||
|
||||
# Final SELECT
|
||||
|
|
@ -420,11 +414,10 @@ async def _build_single_action_sql(
|
|||
# Add restriction filter if there are restrictions
|
||||
if restriction_sqls:
|
||||
query_parts.append("""
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM restriction_all)
|
||||
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
|
||||
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
|
||||
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM restriction_list r
|
||||
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
|
||||
AND (r.child = decisions.child OR r.child IS NULL)
|
||||
)""")
|
||||
|
||||
# Add parent filter if specified
|
||||
|
|
@ -502,153 +495,6 @@ async def build_permission_rules_sql(
|
|||
return rules_union, all_params, restriction_sqls
|
||||
|
||||
|
||||
async def check_permissions_for_actions(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
actions: list[str],
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Check several actions for one actor and resource in a single query.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
actor: The actor dict (or None)
|
||||
actions: List of action names to check
|
||||
parent: The parent resource identifier (e.g., database name, or None)
|
||||
child: The child resource identifier (e.g., table name, or None)
|
||||
|
||||
Returns:
|
||||
Dict mapping each action name to True (allowed) or False (denied)
|
||||
|
||||
Each action contributes its own tagged block of permission rules
|
||||
(gathered from the permission_resources_sql hook, with parameters
|
||||
namespaced per action to avoid collisions) plus an optional
|
||||
restriction allowlist CTE. One internal database query resolves
|
||||
the winning rule per action using the same specificity-then-deny
|
||||
ordering as the rest of the permission system.
|
||||
|
||||
Note: this resolves each action independently - also_requires
|
||||
dependencies are handled by the caller (Datasette.allowed_many).
|
||||
"""
|
||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||
|
||||
for action in actions:
|
||||
if not datasette.actions.get(action):
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
# Dedupe while preserving order
|
||||
unique_actions = list(dict.fromkeys(actions))
|
||||
if not unique_actions:
|
||||
return {}
|
||||
|
||||
# Gather hook results for each action concurrently - hooks within a
|
||||
# single action still run sequentially, preserving existing semantics
|
||||
gathered = await asyncio.gather(
|
||||
*(
|
||||
gather_permission_sql_from_hooks(
|
||||
datasette=datasette, actor=actor, action=action
|
||||
)
|
||||
for action in unique_actions
|
||||
)
|
||||
)
|
||||
|
||||
if any(result is SKIP_PERMISSION_CHECKS for result in gathered):
|
||||
return {action: True for action in unique_actions}
|
||||
|
||||
params = {"_check_parent": parent, "_check_child": child}
|
||||
ctes = []
|
||||
result_rows = []
|
||||
verdicts = {}
|
||||
|
||||
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
||||
prefix = f"a{i}_"
|
||||
rule_parts = []
|
||||
restriction_parts = []
|
||||
|
||||
for permission_sql in permission_sqls:
|
||||
sql = permission_sql.sql
|
||||
restriction_sql = permission_sql.restriction_sql
|
||||
# Namespace this block's params so identical names used for
|
||||
# different actions cannot collide
|
||||
for key in permission_sql.params or {}:
|
||||
new_key = prefix + key
|
||||
params[new_key] = permission_sql.params[key]
|
||||
pattern = re.compile(":" + re.escape(key) + r"(?![A-Za-z0-9_])")
|
||||
if sql:
|
||||
sql = pattern.sub(":" + new_key, sql)
|
||||
if restriction_sql:
|
||||
restriction_sql = pattern.sub(":" + new_key, restriction_sql)
|
||||
|
||||
if restriction_sql:
|
||||
restriction_parts.append(restriction_sql)
|
||||
|
||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
||||
if sql is None:
|
||||
continue
|
||||
rule_parts.append(
|
||||
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
|
||||
)
|
||||
|
||||
if not rule_parts:
|
||||
# No rules from any plugin - default deny. Restrictions can
|
||||
# only restrict, never grant, so no SQL is needed at all
|
||||
verdicts[action] = False
|
||||
continue
|
||||
ctes.append(f"a{i}_rules AS (\n" + "\nUNION ALL\n".join(rule_parts) + "\n)")
|
||||
|
||||
# Winning rule for this action: most specific depth first, then
|
||||
# deny-beats-allow, then source_plugin as a stable tie-break
|
||||
verdict_sql = f"""COALESCE((
|
||||
SELECT allow FROM (
|
||||
SELECT allow, source_plugin,
|
||||
CASE
|
||||
WHEN child IS NOT NULL THEN 2
|
||||
WHEN parent IS NOT NULL THEN 1
|
||||
ELSE 0
|
||||
END AS depth
|
||||
FROM a{i}_rules
|
||||
WHERE (parent IS NULL OR parent = :_check_parent)
|
||||
AND (child IS NULL OR child = :_check_child)
|
||||
ORDER BY
|
||||
depth DESC,
|
||||
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
|
||||
source_plugin
|
||||
LIMIT 1
|
||||
)
|
||||
), 0)"""
|
||||
|
||||
if restriction_parts:
|
||||
# Database-level restrictions (parent, NULL) match all children
|
||||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_parts
|
||||
)
|
||||
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
||||
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
||||
SELECT 1 FROM a{i}_restriction r
|
||||
WHERE (r.parent = :_check_parent OR r.parent IS NULL)
|
||||
AND (r.child = :_check_child OR r.child IS NULL)
|
||||
)"""
|
||||
|
||||
result_rows.append(f"({i}, ({verdict_sql}))")
|
||||
|
||||
if result_rows:
|
||||
ctes.append(
|
||||
"results(action_idx, is_allowed) AS (VALUES\n"
|
||||
+ ",\n".join(result_rows)
|
||||
+ "\n)"
|
||||
)
|
||||
query = (
|
||||
"WITH\n" + ",\n".join(ctes) + "\nSELECT action_idx, is_allowed FROM results"
|
||||
)
|
||||
result = await datasette.get_internal_database().execute(query, params)
|
||||
for row in result.rows:
|
||||
verdicts[unique_actions[row[0]]] = bool(row[1])
|
||||
return verdicts
|
||||
|
||||
|
||||
async def check_permission_for_resource(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
|
|
@ -669,248 +515,77 @@ async def check_permission_for_resource(
|
|||
|
||||
Returns:
|
||||
True if the actor is allowed, False otherwise
|
||||
|
||||
This builds the cascading permission query and checks if the specific
|
||||
resource is in the allowed set.
|
||||
"""
|
||||
results = await check_permissions_for_actions(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
actions=[action],
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
return results[action]
|
||||
|
||||
|
||||
async def explain_permission_for_resource(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> dict:
|
||||
"""Explain a permission decision for one action and resource.
|
||||
|
||||
This is intended for Datasette's permission debugging tools. It uses the
|
||||
same ``permission_resources_sql`` hook results and the same resolution
|
||||
rules as :func:`check_permissions_for_actions`, but also returns the
|
||||
matching rules, actor restriction results and ``also_requires`` chain.
|
||||
|
||||
The returned dictionary is part of Datasette's unstable debugging API.
|
||||
"""
|
||||
|
||||
action_obj = datasette.actions.get(action)
|
||||
if action_obj is None:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
explanation = await _explain_single_action(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
parent=parent,
|
||||
child=child,
|
||||
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
|
||||
datasette, actor, action
|
||||
)
|
||||
|
||||
required_actions = []
|
||||
if action_obj.also_requires:
|
||||
required = await explain_permission_for_resource(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action_obj.also_requires,
|
||||
parent=parent,
|
||||
child=child,
|
||||
# If no rules (empty SQL), default deny
|
||||
if not rules_union:
|
||||
return False
|
||||
|
||||
# Add parameters for the resource we're checking
|
||||
all_params["_check_parent"] = parent
|
||||
all_params["_check_child"] = child
|
||||
|
||||
# If there are restriction filters, check if the resource passes them first
|
||||
if restriction_sqls:
|
||||
# Check if resource is in restriction allowlist
|
||||
# Database-level restrictions (parent, NULL) should match all children (parent, *)
|
||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||
restriction_check = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
required_actions.append(required)
|
||||
|
||||
explanation["required_actions"] = required_actions
|
||||
explanation["allowed"] = bool(
|
||||
explanation["rule_allowed"]
|
||||
and explanation["restriction_allowed"]
|
||||
and all(required["allowed"] for required in required_actions)
|
||||
)
|
||||
explanation["summary"] = _permission_explanation_summary(explanation)
|
||||
return explanation
|
||||
|
||||
|
||||
async def _explain_single_action(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> dict:
|
||||
"""Return matching rules and restrictions for a single action."""
|
||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||
|
||||
permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
)
|
||||
|
||||
if permission_sqls is SKIP_PERMISSION_CHECKS:
|
||||
return {
|
||||
"action": action,
|
||||
"rule_allowed": True,
|
||||
"restriction_allowed": True,
|
||||
"winning_scope": "global",
|
||||
"matched_rules": [
|
||||
{
|
||||
"scope": "global",
|
||||
"effect": "allow",
|
||||
"source": "skip_permission_checks",
|
||||
"reason": "Permission checks were explicitly skipped",
|
||||
"decisive": True,
|
||||
"ignored_because": None,
|
||||
}
|
||||
],
|
||||
"restrictions": [],
|
||||
}
|
||||
|
||||
db = datasette.get_internal_database()
|
||||
matched_rules = []
|
||||
restrictions = []
|
||||
|
||||
for permission_sql in permission_sqls:
|
||||
params = dict(permission_sql.params or {})
|
||||
parent_param = _unused_parameter_name(params, "_explain_parent")
|
||||
params[parent_param] = parent
|
||||
child_param = _unused_parameter_name(params, "_explain_child")
|
||||
params[child_param] = child
|
||||
|
||||
if permission_sql.sql:
|
||||
rows = await db.execute(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason
|
||||
FROM ({permission_sql.sql}) AS permission_rules
|
||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||
AND (child IS NULL OR child = :{child_param})
|
||||
""",
|
||||
params,
|
||||
)
|
||||
for row in rows:
|
||||
specificity = (
|
||||
2
|
||||
if row["child"] is not None
|
||||
else 1 if row["parent"] is not None else 0
|
||||
)
|
||||
matched_rules.append(
|
||||
{
|
||||
"scope": ("resource", "parent", "global")[2 - specificity],
|
||||
"effect": "allow" if row["allow"] else "deny",
|
||||
"source": permission_sql.source,
|
||||
"reason": row["reason"],
|
||||
"_specificity": specificity,
|
||||
}
|
||||
)
|
||||
|
||||
if permission_sql.restriction_sql:
|
||||
restriction_row = (
|
||||
await db.execute(
|
||||
f"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
|
||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||
AND (child IS NULL OR child = :{child_param})
|
||||
) AS resource_is_in_allowlist
|
||||
""",
|
||||
params,
|
||||
)
|
||||
).first()
|
||||
restriction_allowed = bool(restriction_row[0])
|
||||
restrictions.append(
|
||||
{
|
||||
"source": permission_sql.source,
|
||||
"allowed": restriction_allowed,
|
||||
"reason": params.get("deny")
|
||||
or (
|
||||
"Resource is included in this restriction allowlist"
|
||||
if restriction_allowed
|
||||
else "Resource is not included in this restriction allowlist"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
matched_rules.sort(
|
||||
key=lambda rule: (
|
||||
-rule["_specificity"],
|
||||
0 if rule["effect"] == "deny" else 1,
|
||||
rule["source"] or "",
|
||||
rule["reason"] or "",
|
||||
restriction_query = f"""
|
||||
WITH restriction_list AS (
|
||||
{restriction_check}
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM restriction_list
|
||||
WHERE (parent = :_check_parent OR parent IS NULL)
|
||||
AND (child = :_check_child OR child IS NULL)
|
||||
) AS in_allowlist
|
||||
"""
|
||||
result = await datasette.get_internal_database().execute(
|
||||
restriction_query, all_params
|
||||
)
|
||||
)
|
||||
if result.rows and not result.rows[0][0]:
|
||||
# Resource not in restriction allowlist - deny
|
||||
return False
|
||||
|
||||
if matched_rules:
|
||||
winning_specificity = matched_rules[0]["_specificity"]
|
||||
winning_rules = [
|
||||
rule
|
||||
for rule in matched_rules
|
||||
if rule["_specificity"] == winning_specificity
|
||||
]
|
||||
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
|
||||
winning_scope = winning_rules[0]["scope"]
|
||||
else:
|
||||
winning_specificity = None
|
||||
rule_allowed = False
|
||||
winning_scope = None
|
||||
query = f"""
|
||||
WITH
|
||||
all_rules AS (
|
||||
{rules_union}
|
||||
),
|
||||
matched_rules AS (
|
||||
SELECT ar.*,
|
||||
CASE
|
||||
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM all_rules ar
|
||||
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
|
||||
AND (ar.child IS NULL OR ar.child = :_check_child)
|
||||
),
|
||||
winner AS (
|
||||
SELECT *
|
||||
FROM matched_rules
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first (higher depth wins)
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
|
||||
source_plugin -- stable tie-break
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
|
||||
"""
|
||||
|
||||
for rule in matched_rules:
|
||||
specificity = rule.pop("_specificity")
|
||||
if specificity != winning_specificity:
|
||||
rule["decisive"] = False
|
||||
rule["ignored_because"] = "A more specific rule matched"
|
||||
elif not rule_allowed and rule["effect"] == "allow":
|
||||
rule["decisive"] = False
|
||||
rule["ignored_because"] = "A deny rule matched at the same scope"
|
||||
else:
|
||||
rule["decisive"] = True
|
||||
rule["ignored_because"] = None
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"rule_allowed": rule_allowed,
|
||||
"restriction_allowed": all(
|
||||
restriction["allowed"] for restriction in restrictions
|
||||
),
|
||||
"winning_scope": winning_scope,
|
||||
"matched_rules": matched_rules,
|
||||
"restrictions": restrictions,
|
||||
}
|
||||
|
||||
|
||||
def _unused_parameter_name(params: dict, preferred: str) -> str:
|
||||
"""Return a SQL parameter name that is not already in ``params``."""
|
||||
candidate = preferred
|
||||
suffix = 2
|
||||
while candidate in params:
|
||||
candidate = f"{preferred}_{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _permission_explanation_summary(explanation: dict) -> str:
|
||||
denied_requirement = next(
|
||||
(
|
||||
required
|
||||
for required in explanation["required_actions"]
|
||||
if not required["allowed"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if denied_requirement:
|
||||
return (
|
||||
f"Denied because {explanation['action']} also requires "
|
||||
f"{denied_requirement['action']}, which was denied."
|
||||
)
|
||||
if not explanation["matched_rules"]:
|
||||
return "Denied because no permission rule matched this actor and resource."
|
||||
if not explanation["rule_allowed"]:
|
||||
return (
|
||||
f"Denied by a {explanation['winning_scope']}-level rule. "
|
||||
"Deny rules take precedence over allow rules at the same scope."
|
||||
)
|
||||
if not explanation["restriction_allowed"]:
|
||||
return (
|
||||
"Denied because the resource is not included in the actor's restrictions."
|
||||
)
|
||||
return f"Allowed by the matching {explanation['winning_scope']}-level rule."
|
||||
# Execute the query against the internal database
|
||||
result = await datasette.get_internal_database().execute(query, all_params)
|
||||
if result.rows:
|
||||
return bool(result.rows[0][0])
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,29 +1,28 @@
|
|||
import json
|
||||
import re
|
||||
from http.cookies import Morsel, SimpleCookie
|
||||
from mimetypes import guess_type
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, parse_qsl, urlunparse
|
||||
|
||||
import aiofiles
|
||||
import aiofiles.os
|
||||
|
||||
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
|
||||
from typing import Optional
|
||||
from datasette.utils import MultiParams, calculate_etag
|
||||
from datasette.utils.multipart import (
|
||||
DEFAULT_MAX_FIELD_SIZE,
|
||||
DEFAULT_MAX_FIELDS,
|
||||
parse_form_data,
|
||||
MultipartParseError,
|
||||
FormData,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_REQUEST_SIZE,
|
||||
DEFAULT_MAX_FIELDS,
|
||||
DEFAULT_MAX_FILES,
|
||||
DEFAULT_MAX_PARTS,
|
||||
DEFAULT_MAX_FIELD_SIZE,
|
||||
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||
DEFAULT_MAX_PART_HEADER_BYTES,
|
||||
DEFAULT_MAX_PART_HEADER_LINES,
|
||||
DEFAULT_MAX_PARTS,
|
||||
DEFAULT_MAX_REQUEST_SIZE,
|
||||
DEFAULT_MIN_FREE_DISK_BYTES,
|
||||
FormData,
|
||||
MultipartParseError,
|
||||
parse_form_data,
|
||||
)
|
||||
from mimetypes import guess_type
|
||||
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
||||
from pathlib import Path
|
||||
from http.cookies import SimpleCookie, Morsel
|
||||
import aiofiles
|
||||
import aiofiles.os
|
||||
import re
|
||||
|
||||
# Workaround for adding samesite support to pre 3.8 python
|
||||
Morsel._reserved["samesite"] = "SameSite"
|
||||
|
|
@ -68,28 +67,16 @@ class BadRequest(Base400):
|
|||
status = 400
|
||||
|
||||
|
||||
class PayloadTooLarge(Base400):
|
||||
status = 413
|
||||
|
||||
|
||||
SAMESITE_VALUES = ("strict", "lax", "none")
|
||||
|
||||
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
|
||||
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
|
||||
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
|
||||
# while these bodies are held in RAM and json.loads() can multiply their
|
||||
# footprint several times over.
|
||||
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
class Request:
|
||||
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
||||
def __init__(self, scope, receive):
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.max_post_body_bytes = max_post_body_bytes
|
||||
|
||||
def __repr__(self):
|
||||
return f'<asgi.Request method="{self.method}" url="{self.url}">'
|
||||
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
|
|
@ -154,52 +141,20 @@ class Request:
|
|||
def actor(self):
|
||||
return self.scope.get("actor", None)
|
||||
|
||||
async def post_body(self, max_bytes=None):
|
||||
"""
|
||||
Read the request body fully into memory.
|
||||
|
||||
The body is capped at max_bytes - or self.max_post_body_bytes
|
||||
(default 2MB, set from the max_post_body_bytes setting for requests
|
||||
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
|
||||
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
|
||||
oversized bodies are rejected as soon as the limit is passed, without
|
||||
buffering the rest.
|
||||
"""
|
||||
if max_bytes is None:
|
||||
max_bytes = self.max_post_body_bytes
|
||||
too_large = PayloadTooLarge(
|
||||
f"Request body exceeded maximum size of {max_bytes} bytes"
|
||||
)
|
||||
if max_bytes:
|
||||
# Reject early if the client declares an oversized body
|
||||
try:
|
||||
if int(self.headers.get("content-length", "")) > max_bytes:
|
||||
raise too_large
|
||||
except ValueError:
|
||||
# Missing or malformed - the streaming check below still applies
|
||||
pass
|
||||
chunks = []
|
||||
received = 0
|
||||
async def post_body(self):
|
||||
body = b""
|
||||
more_body = True
|
||||
while more_body:
|
||||
message = await self.receive()
|
||||
assert message["type"] == "http.request", message
|
||||
chunk = message.get("body", b"")
|
||||
received += len(chunk)
|
||||
if max_bytes and received > max_bytes:
|
||||
raise too_large
|
||||
chunks.append(chunk)
|
||||
body += message.get("body", b"")
|
||||
more_body = message.get("more_body", False)
|
||||
return b"".join(chunks)
|
||||
return body
|
||||
|
||||
async def post_vars(self):
|
||||
body = await self.post_body()
|
||||
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
|
||||
|
||||
async def json(self):
|
||||
body = await self.post_body()
|
||||
return json.loads(body)
|
||||
|
||||
async def form(
|
||||
self,
|
||||
files: bool = False,
|
||||
|
|
@ -207,7 +162,7 @@ class Request:
|
|||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||
max_files: int = DEFAULT_MAX_FILES,
|
||||
max_parts: int | None = DEFAULT_MAX_PARTS,
|
||||
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
|
||||
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||
|
|
@ -375,11 +330,9 @@ async def asgi_send_html(send, html, status=200, headers=None):
|
|||
|
||||
|
||||
async def asgi_send_redirect(send, location, status=302):
|
||||
# Prevent open redirect vulnerability: collapse leading slashes and
|
||||
# backslashes down to a single slash. //example.com is a protocol-relative
|
||||
# URL, and browsers normalise backslashes to slashes so /\example.com would
|
||||
# be treated as //example.com - https://github.com/simonw/datasette/issues/2680
|
||||
location = re.sub(r"^[/\\]+", "/", location)
|
||||
# Prevent open redirect vulnerability: strip multiple leading slashes
|
||||
# //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)
|
||||
location = re.sub(r"^/+", "/", location)
|
||||
await asgi_send(
|
||||
send,
|
||||
"",
|
||||
|
|
@ -438,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):
|
||||
root_path = Path(root_path)
|
||||
static_headers = {}
|
||||
|
|
@ -467,17 +417,11 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
|||
return
|
||||
try:
|
||||
# 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)
|
||||
headers["ETag"] = etag
|
||||
if_none_match = request.headers.get("if-none-match")
|
||||
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(
|
||||
send, full_path, chunk_size=chunk_size, headers=headers
|
||||
)
|
||||
|
|
@ -530,9 +474,9 @@ class Response:
|
|||
httponly=False,
|
||||
samesite="lax",
|
||||
):
|
||||
assert (
|
||||
samesite in SAMESITE_VALUES
|
||||
), f"samesite should be one of {SAMESITE_VALUES}"
|
||||
assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
|
||||
SAMESITE_VALUES
|
||||
)
|
||||
cookie = SimpleCookie()
|
||||
cookie[key] = value
|
||||
for prop_name, prop_value in (
|
||||
|
|
@ -576,18 +520,6 @@ class Response:
|
|||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def error(cls, messages, status=400, headers=None):
|
||||
"""
|
||||
A JSON error response using Datasette's standard error format.
|
||||
|
||||
messages can be a single string or a list of strings. For errors
|
||||
that should content-negotiate between JSON and HTML, raise
|
||||
Forbidden, NotFound, BadRequest or DatasetteError instead and let
|
||||
Datasette's error handling hooks build the response.
|
||||
"""
|
||||
return cls.json(error_body(messages, status), status=status, headers=headers)
|
||||
|
||||
@classmethod
|
||||
def redirect(cls, path, status=302, headers=None):
|
||||
headers = headers or {}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
|
|||
"""
|
||||
|
||||
|
||||
class BaseConverter:
|
||||
class BaseConverter(object):
|
||||
decimal_digits = "0123456789"
|
||||
|
||||
def __init__(self, digits):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import inspect
|
||||
import types
|
||||
from typing import Any, NamedTuple
|
||||
from typing import NamedTuple, Any
|
||||
|
||||
|
||||
class CallableStatus(NamedTuple):
|
||||
|
|
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
|
|||
if isinstance(obj, types.FunctionType):
|
||||
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
||||
|
||||
if callable(obj):
|
||||
if hasattr(obj, "__call__"):
|
||||
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
|
||||
|
||||
assert False, f"obj {obj!r} is somehow callable with no __call__ method"
|
||||
assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))
|
||||
|
|
|
|||
|
|
@ -1,30 +1,9 @@
|
|||
import textwrap
|
||||
from datasette.utils import table_column_details
|
||||
|
||||
from sqlite_utils import Database as SQLiteUtilsDatabase
|
||||
from sqlite_utils import Migrations
|
||||
|
||||
from datasette.utils import escape_sqlite, table_column_details
|
||||
|
||||
INTERNAL_DB_SCHEMA_TABLES = {
|
||||
"catalog_databases",
|
||||
"catalog_tables",
|
||||
"catalog_views",
|
||||
"catalog_columns",
|
||||
"catalog_indexes",
|
||||
"catalog_foreign_keys",
|
||||
"metadata_instance",
|
||||
"metadata_databases",
|
||||
"metadata_resources",
|
||||
"metadata_columns",
|
||||
"column_types",
|
||||
"queries",
|
||||
}
|
||||
|
||||
INTERNAL_DB_SCHEMA_INDEXES = {
|
||||
"queries_owner_idx",
|
||||
}
|
||||
|
||||
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
|
||||
async def init_internal_db(db):
|
||||
create_tables_sql = textwrap.dedent("""
|
||||
CREATE TABLE IF NOT EXISTS catalog_databases (
|
||||
database_name TEXT PRIMARY KEY,
|
||||
path TEXT,
|
||||
|
|
@ -88,101 +67,99 @@ INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
|
|||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_instance (
|
||||
key text,
|
||||
value text,
|
||||
unique(key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_databases (
|
||||
database_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_resources (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_columns (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
column_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, column_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS column_types (
|
||||
database_name TEXT NOT NULL,
|
||||
resource_name TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
column_type TEXT NOT NULL,
|
||||
config TEXT,
|
||||
PRIMARY KEY (database_name, resource_name, column_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queries (
|
||||
database_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sql TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
description_html TEXT,
|
||||
options TEXT NOT NULL DEFAULT '{}',
|
||||
parameters TEXT NOT NULL DEFAULT '[]',
|
||||
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
|
||||
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
|
||||
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
owner_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (database_name, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS queries_owner_idx
|
||||
ON queries(owner_id);
|
||||
""").strip()
|
||||
await db.execute_write_script(create_tables_sql)
|
||||
await initialize_metadata_tables(db)
|
||||
|
||||
|
||||
internal_migrations = Migrations("datasette_internal")
|
||||
async def initialize_metadata_tables(db):
|
||||
await db.execute_write_script(textwrap.dedent("""
|
||||
CREATE TABLE IF NOT EXISTS metadata_instance (
|
||||
key text,
|
||||
value text,
|
||||
unique(key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_databases (
|
||||
database_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_resources (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_columns (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
column_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, column_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS column_types (
|
||||
database_name TEXT NOT NULL,
|
||||
resource_name TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
column_type TEXT NOT NULL,
|
||||
config TEXT,
|
||||
PRIMARY KEY (database_name, resource_name, column_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queries (
|
||||
database_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sql TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
description_html TEXT,
|
||||
options TEXT NOT NULL DEFAULT '{}',
|
||||
parameters TEXT NOT NULL DEFAULT '[]',
|
||||
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
|
||||
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
|
||||
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
owner_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (database_name, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS queries_owner_idx
|
||||
ON queries(owner_id);
|
||||
"""))
|
||||
|
||||
|
||||
def _internal_schema_exists(db):
|
||||
table_names = set(db.table_names())
|
||||
if not INTERNAL_DB_SCHEMA_TABLES.issubset(table_names):
|
||||
return False
|
||||
index_names = {
|
||||
row[0]
|
||||
for row in db.execute("select name from sqlite_master where type = 'index'")
|
||||
}
|
||||
return INTERNAL_DB_SCHEMA_INDEXES.issubset(index_names)
|
||||
|
||||
|
||||
@internal_migrations(name="0001_initial")
|
||||
def initial_internal_schema(db):
|
||||
if _internal_schema_exists(db):
|
||||
return
|
||||
db.executescript(INTERNAL_DB_SCHEMA_SQL)
|
||||
|
||||
|
||||
async def init_internal_db(db):
|
||||
def apply_migrations(conn):
|
||||
internal_migrations.apply(SQLiteUtilsDatabase(conn, execute_plugins=False))
|
||||
|
||||
await db.execute_write_fn(apply_migrations, transaction=False)
|
||||
|
||||
|
||||
async def populate_schema_tables(internal_db, db, schema_version):
|
||||
async def populate_schema_tables(internal_db, db):
|
||||
database_name = db.name
|
||||
|
||||
def delete_everything(conn):
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
|
||||
[database_name],
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
|
||||
)
|
||||
|
||||
await internal_db.execute_write_fn(delete_everything)
|
||||
|
||||
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
||||
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
||||
|
||||
|
|
@ -207,30 +184,25 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
|||
columns = table_column_details(conn, table_name)
|
||||
columns_to_insert.extend(
|
||||
{
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
**{"database_name": database_name, "table_name": table_name},
|
||||
**column._asdict(),
|
||||
}
|
||||
for column in columns
|
||||
)
|
||||
foreign_keys = conn.execute(
|
||||
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
|
||||
f"PRAGMA foreign_key_list([{table_name}])"
|
||||
).fetchall()
|
||||
foreign_keys_to_insert.extend(
|
||||
{
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
**{"database_name": database_name, "table_name": table_name},
|
||||
**dict(foreign_key),
|
||||
}
|
||||
for foreign_key in foreign_keys
|
||||
)
|
||||
indexes = conn.execute(
|
||||
f"PRAGMA index_list({escape_sqlite(table_name)})"
|
||||
).fetchall()
|
||||
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
|
||||
indexes_to_insert.extend(
|
||||
{
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
**{"database_name": database_name, "table_name": table_name},
|
||||
**dict(index),
|
||||
}
|
||||
for index in indexes
|
||||
|
|
@ -251,76 +223,47 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
|||
indexes_to_insert,
|
||||
) = await db.execute_fn(collect_info)
|
||||
|
||||
def replace_catalog(conn):
|
||||
# Delete child rows before their catalog_tables parents so this also
|
||||
# works if a prepare_connection plugin enables foreign key enforcement.
|
||||
for table in (
|
||||
"catalog_columns",
|
||||
"catalog_foreign_keys",
|
||||
"catalog_indexes",
|
||||
"catalog_views",
|
||||
"catalog_tables",
|
||||
):
|
||||
conn.execute(
|
||||
f"DELETE FROM {table} WHERE database_name = ?",
|
||||
[database_name],
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO catalog_databases (
|
||||
database_name, path, is_memory, schema_version
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
database_name,
|
||||
str(db.path) if db.path is not None else None,
|
||||
db.is_memory,
|
||||
schema_version,
|
||||
],
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
tables_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
views_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_columns (
|
||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||
) VALUES (
|
||||
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
tables_to_insert,
|
||||
""",
|
||||
columns_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_foreign_keys (
|
||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||
) VALUES (
|
||||
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
views_to_insert,
|
||||
""",
|
||||
foreign_keys_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_indexes (
|
||||
database_name, table_name, seq, name, "unique", origin, partial
|
||||
) VALUES (
|
||||
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_columns (
|
||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||
) VALUES (
|
||||
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
||||
)
|
||||
""",
|
||||
columns_to_insert,
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_foreign_keys (
|
||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||
) VALUES (
|
||||
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
||||
)
|
||||
""",
|
||||
foreign_keys_to_insert,
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_indexes (
|
||||
database_name, table_name, seq, name, "unique", origin, partial
|
||||
) VALUES (
|
||||
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
||||
)
|
||||
""",
|
||||
indexes_to_insert,
|
||||
)
|
||||
|
||||
await internal_db.execute_write_fn(replace_catalog)
|
||||
""",
|
||||
indexes_to_insert,
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue