mirror of
https://github.com/simonw/datasette.git
synced 2026-09-27 20:34:08 +02:00
Compare commits
11 commits
main
...
asg017/cod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec0f94ed8 | ||
|
|
866b2d34e1 | ||
|
|
343e8258fb | ||
|
|
174099b707 | ||
|
|
cc1a24fb4f | ||
|
|
49f1660dcd | ||
|
|
b9716d4278 | ||
|
|
7e555b01f6 | ||
|
|
d12f0d2c59 | ||
|
|
28d811320b | ||
|
|
79f6ac3f47 |
238 changed files with 6325 additions and 18066 deletions
54
.github/workflows/deploy-latest.yml
vendored
54
.github/workflows/deploy-latest.yml
vendored
|
|
@ -14,46 +14,24 @@ jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check deployment prerequisites
|
|
||||||
id: deployment-prerequisites
|
|
||||||
env:
|
|
||||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
|
||||||
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
|
||||||
run: |
|
|
||||||
missing=()
|
|
||||||
for variable in GCP_SA_KEY LATEST_DATASETTE_SECRET; do
|
|
||||||
if [[ -z "${!variable:-}" ]]; then
|
|
||||||
missing+=("$variable")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if (( ${#missing[@]} )); then
|
|
||||||
echo "::notice::Skipping deployment because required environment variables are missing: ${missing[*]}"
|
|
||||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Check out datasette
|
- name: Check out datasette
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
cache: pip
|
cache: pip
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install . --group dev
|
python -m pip install . --group dev
|
||||||
python -m pip install sphinx-to-sqlite==0.1a1 "s3-credentials>=0.17"
|
python -m pip install sphinx-to-sqlite==0.1a1
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
run: |
|
run: |
|
||||||
pytest -n auto -m "not serial"
|
pytest -n auto -m "not serial"
|
||||||
pytest -m "serial"
|
pytest -m "serial"
|
||||||
- name: Build fixtures.db and other files needed to deploy the demo
|
- name: Build fixtures.db and other files needed to deploy the demo
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |-
|
run: |-
|
||||||
python tests/fixtures.py \
|
python tests/fixtures.py \
|
||||||
fixtures.db \
|
fixtures.db \
|
||||||
|
|
@ -61,18 +39,14 @@ jobs:
|
||||||
fixtures-metadata.json \
|
fixtures-metadata.json \
|
||||||
plugins \
|
plugins \
|
||||||
--extra-db-filename extra_database.db
|
--extra-db-filename extra_database.db
|
||||||
# Package the config with the plugins, excluding test-only plugin secrets
|
|
||||||
# that reference temporary files outside the deployed container.
|
|
||||||
jq 'del(.plugins)' fixtures-config.json > plugins/fixtures-config.json
|
|
||||||
- name: Build docs.db
|
- name: Build docs.db
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
run: |-
|
run: |-
|
||||||
cd docs
|
cd docs
|
||||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||||
sphinx-to-sqlite ../docs.db _build
|
sphinx-to-sqlite ../docs.db _build
|
||||||
cd ..
|
cd ..
|
||||||
- name: Set up the alternate-route demo
|
- name: Set up the alternate-route demo
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |
|
run: |
|
||||||
echo '
|
echo '
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
|
@ -84,7 +58,6 @@ jobs:
|
||||||
' > plugins/alternative_route.py
|
' > plugins/alternative_route.py
|
||||||
cp fixtures.db fixtures2.db
|
cp fixtures.db fixtures2.db
|
||||||
- name: And the counters writable stored query demo
|
- name: And the counters writable stored query demo
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
run: |
|
run: |
|
||||||
cat > plugins/counters.py <<EOF
|
cat > plugins/counters.py <<EOF
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
|
@ -124,15 +97,12 @@ jobs:
|
||||||
# cat metadata.json
|
# cat metadata.json
|
||||||
- id: auth
|
- id: auth
|
||||||
name: Authenticate to Google Cloud
|
name: Authenticate to Google Cloud
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: google-github-actions/auth@v3
|
uses: google-github-actions/auth@v3
|
||||||
with:
|
with:
|
||||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||||
- name: Set up Cloud SDK
|
- name: Set up Cloud SDK
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
uses: google-github-actions/setup-gcloud@v3
|
uses: google-github-actions/setup-gcloud@v3
|
||||||
- name: Deploy to Cloud Run
|
- name: Deploy to Cloud Run
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
|
|
||||||
env:
|
env:
|
||||||
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
||||||
run: |-
|
run: |-
|
||||||
|
|
@ -147,16 +117,16 @@ jobs:
|
||||||
--plugins-dir=plugins \
|
--plugins-dir=plugins \
|
||||||
--branch=$GITHUB_SHA \
|
--branch=$GITHUB_SHA \
|
||||||
--version-note=$GITHUB_SHA \
|
--version-note=$GITHUB_SHA \
|
||||||
--extra-options="--config plugins/fixtures-config.json --setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
||||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||||
--service "datasette-latest$SUFFIX" \
|
--service "datasette-latest$SUFFIX" \
|
||||||
--secret $LATEST_DATASETTE_SECRET
|
--secret $LATEST_DATASETTE_SECRET
|
||||||
- name: Upload latest documentation database to S3 (only for main)
|
- name: Deploy to docs as well (only for main)
|
||||||
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_DATASETTE_DOCS_ACCESS_KEY }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_DATASETTE_DOCS_SECRET_KEY }}
|
|
||||||
run: |-
|
run: |-
|
||||||
# Keep development documentation separate from the stable release database.
|
# Deploy docs.db to a different service
|
||||||
s3-credentials put-object datasette-docs latest/docs.db docs.db \
|
datasette publish cloudrun docs.db \
|
||||||
--content-type application/octet-stream
|
--branch=$GITHUB_SHA \
|
||||||
|
--version-note=$GITHUB_SHA \
|
||||||
|
--extra-options="--setting template_debug 1" \
|
||||||
|
--service=datasette-docs-latest
|
||||||
|
|
|
||||||
6
.github/workflows/playwright.yml
vendored
6
.github/workflows/playwright.yml
vendored
|
|
@ -2,15 +2,9 @@ name: Playwright
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|
|
||||||
11
.github/workflows/prettier.yml
vendored
11
.github/workflows/prettier.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Check JavaScript for conformance with Prettier
|
name: Check JavaScript for conformance with Prettier
|
||||||
|
|
||||||
on:
|
on: [push]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
24
.github/workflows/publish.yml
vendored
24
.github/workflows/publish.yml
vendored
|
|
@ -2,7 +2,7 @@ name: Publish Python Package
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [created]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -51,8 +51,6 @@ jobs:
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
|
||||||
# After the first non-prerelease 1.0 release, disable this job on 0.65.x,
|
|
||||||
# even for later 0.65 releases, so they cannot overwrite the 1.0 stable docs.
|
|
||||||
deploy_static_docs:
|
deploy_static_docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
|
|
@ -68,20 +66,26 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install . --group dev
|
python -m pip install . --group dev
|
||||||
python -m pip install sphinx-to-sqlite==0.1a1 "s3-credentials>=0.17"
|
python -m pip install sphinx-to-sqlite==0.1a1
|
||||||
- name: Build docs.db
|
- name: Build docs.db
|
||||||
run: |-
|
run: |-
|
||||||
cd docs
|
cd docs
|
||||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||||
sphinx-to-sqlite ../docs.db _build
|
sphinx-to-sqlite ../docs.db _build
|
||||||
cd ..
|
cd ..
|
||||||
- name: Upload stable documentation database to S3
|
- id: auth
|
||||||
env:
|
name: Authenticate to Google Cloud
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_DATASETTE_DOCS_ACCESS_KEY }}
|
uses: google-github-actions/auth@v2
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_DATASETTE_DOCS_SECRET_KEY }}
|
with:
|
||||||
|
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||||
|
- name: Set up Cloud SDK
|
||||||
|
uses: google-github-actions/setup-gcloud@v3
|
||||||
|
- name: Deploy stable-docs.datasette.io to Cloud Run
|
||||||
run: |-
|
run: |-
|
||||||
s3-credentials put-object datasette-docs docs.db docs.db \
|
gcloud config set run/region us-central1
|
||||||
--content-type application/octet-stream
|
gcloud config set project datasette-222320
|
||||||
|
datasette publish cloudrun docs.db \
|
||||||
|
--service=datasette-docs-stable
|
||||||
|
|
||||||
deploy_docker:
|
deploy_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
11
.github/workflows/spellcheck.yml
vendored
11
.github/workflows/spellcheck.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Check spelling in documentation
|
name: Check spelling in documentation
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
40
.github/workflows/test-coverage.yml
vendored
Normal file
40
.github/workflows/test-coverage.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
name: Calculate test coverage
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out datasette
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: '**/pyproject.toml'
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install . --group dev
|
||||||
|
python -m pip install pytest-cov
|
||||||
|
- name: Run tests
|
||||||
|
run: |-
|
||||||
|
ls -lah
|
||||||
|
cat .coveragerc
|
||||||
|
pytest -m "not serial" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x
|
||||||
|
ls -lah
|
||||||
|
- name: Upload coverage report
|
||||||
|
uses: codecov/codecov-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
file: coverage.xml
|
||||||
6
.github/workflows/test-pyodide.yml
vendored
6
.github/workflows/test-pyodide.yml
vendored
|
|
@ -2,15 +2,9 @@ name: Test in Pyodide with shot-scraper
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|
|
||||||
15
.github/workflows/test-sqlite-support.yml
vendored
15
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Test SQLite versions
|
name: Test SQLite versions
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -21,10 +12,10 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ubuntu-latest]
|
platform: [ubuntu-latest]
|
||||||
python-version: ["3.13"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
sqlite-version: [
|
sqlite-version: [
|
||||||
#"3", # latest version
|
#"3", # latest version
|
||||||
#"3.46",
|
"3.46",
|
||||||
#"3.45",
|
#"3.45",
|
||||||
#"3.27",
|
#"3.27",
|
||||||
#"3.26",
|
#"3.26",
|
||||||
|
|
|
||||||
34
.github/workflows/test.yml
vendored
34
.github/workflows/test.yml
vendored
|
|
@ -1,15 +1,6 @@
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
on:
|
on: [push, pull_request]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -20,20 +11,16 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
include:
|
|
||||||
- python-version: "3.14"
|
|
||||||
coverage: true
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v7
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
allow-prereleases: true
|
allow-prereleases: true
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: pyproject.toml
|
cache-dependency-path: pyproject.toml
|
||||||
check-latest: true
|
|
||||||
- name: Build extension for --load-extension test
|
- name: Build extension for --load-extension test
|
||||||
run: |-
|
run: |-
|
||||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||||
|
|
@ -41,27 +28,12 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
pip install . --group dev
|
pip install . --group dev
|
||||||
pip freeze
|
pip freeze
|
||||||
- name: Install pytest-cov
|
|
||||||
if: ${{ matrix.coverage }}
|
|
||||||
run: pip install pytest-cov
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ matrix.coverage }}" = "true" ]; then
|
|
||||||
COV="--cov=datasette --cov-config=.coveragerc"
|
|
||||||
pytest -n auto -m "not serial" $COV --cov-report=
|
|
||||||
pytest -m "serial" $COV --cov-append --cov-report xml:coverage.xml --cov-report term
|
|
||||||
else
|
|
||||||
pytest -n auto -m "not serial"
|
pytest -n auto -m "not serial"
|
||||||
pytest -m "serial"
|
pytest -m "serial"
|
||||||
fi
|
|
||||||
# And the test that exceeds a localhost HTTPS server
|
# And the test that exceeds a localhost HTTPS server
|
||||||
tests/test_datasette_https_server.sh
|
tests/test_datasette_https_server.sh
|
||||||
- name: Upload coverage report
|
|
||||||
if: ${{ matrix.coverage }}
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
files: coverage.xml
|
|
||||||
- name: Black
|
- name: Black
|
||||||
run: |
|
run: |
|
||||||
black --version
|
black --version
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.11-slim-bookworm AS build
|
FROM python:3.11.0-slim-bullseye as build
|
||||||
|
|
||||||
# Version of Datasette to install, e.g. 0.55
|
# Version of Datasette to install, e.g. 0.55
|
||||||
# docker build . -t datasette --build-arg VERSION=0.55
|
# docker build . -t datasette --build-arg VERSION=0.55
|
||||||
|
|
|
||||||
7
Justfile
7
Justfile
|
|
@ -49,18 +49,13 @@ export DATASETTE_SECRET := "not_a_secret"
|
||||||
uv run cog -r README.md docs/*.rst
|
uv run cog -r README.md docs/*.rst
|
||||||
|
|
||||||
# Serve live docs on localhost:8000
|
# Serve live docs on localhost:8000
|
||||||
@docs: shots cog blacken-docs
|
@docs: cog blacken-docs
|
||||||
uv run make -C docs livehtml
|
uv run make -C docs livehtml
|
||||||
|
|
||||||
# Build docs as static HTML
|
# Build docs as static HTML
|
||||||
@docs-build: cog blacken-docs
|
@docs-build: cog blacken-docs
|
||||||
rm -rf docs/_build && cd docs && uv run make html
|
rm -rf docs/_build && cd docs && uv run make html
|
||||||
|
|
||||||
# Take any missing documentation screenshots defined in docs/shots.yml
|
|
||||||
@shots:
|
|
||||||
uv run --group shots shot-scraper install
|
|
||||||
cd docs && uv run --group shots shot-scraper multi shots.yml --no-clobber --reduced-motion --retina
|
|
||||||
|
|
||||||
# Apply Black
|
# Apply Black
|
||||||
@black:
|
@black:
|
||||||
uv run black datasette tests
|
uv run black datasette tests
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ You can also install it using `pip` or `pipx`:
|
||||||
|
|
||||||
pip install datasette
|
pip install datasette
|
||||||
|
|
||||||
Datasette requires Python 3.10 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||||
|
|
||||||
## Basic usage
|
## Basic usage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from datasette.permissions import Permission # noqa
|
from datasette.permissions import Permission # noqa
|
||||||
from datasette.version import __version_info__, __version__ # noqa
|
from datasette.version import __version_info__, __version__ # noqa
|
||||||
from datasette.events import Event # noqa
|
from datasette.events import Event # noqa
|
||||||
from datasette.background_tasks import BackgroundTask, BackgroundTaskSupervisor # noqa
|
|
||||||
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
||||||
from datasette.utils.asgi import ( # noqa
|
from datasette.utils.asgi import ( # noqa
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,7 @@ def pytest_runtest_protocol(item, nextitem):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
ds.close()
|
ds.close()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# Surfaced as a pytest warning; teardown must not fail the run
|
|
||||||
item.warn(
|
item.warn(
|
||||||
pytest.PytestUnraisableExceptionWarning(
|
pytest.PytestUnraisableExceptionWarning(
|
||||||
f"Error closing Datasette instance: {e!r}"
|
f"Error closing Datasette instance: {e!r}"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import time
|
|
||||||
|
|
||||||
from itsdangerous import BadSignature
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
from itsdangerous import BadSignature
|
||||||
from datasette.utils import baseconv
|
from datasette.utils import baseconv
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
|
||||||
774
datasette/app.py
774
datasette/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,227 +0,0 @@
|
||||||
"""
|
|
||||||
Supervised background-task registration for Datasette core.
|
|
||||||
|
|
||||||
Plugins that need long-lived background work (a polling loop, a queue
|
|
||||||
consumer, a scheduled job runner) register it with
|
|
||||||
``datasette.add_background_task(func, name=None)`` - typically from a
|
|
||||||
``startup`` plugin hook - instead of fire-and-forgetting their own
|
|
||||||
``asyncio.create_task()``. Core owns:
|
|
||||||
|
|
||||||
- **references**: every launched ``asyncio.Task`` is kept alive on a
|
|
||||||
:class:`BackgroundTaskSupervisor`, so it can never be silently garbage
|
|
||||||
collected the way an unreferenced ``create_task()`` call can be;
|
|
||||||
- **launch timing**: registered work is buffered until
|
|
||||||
:meth:`BackgroundTaskSupervisor.launch_all` runs, which core arranges to
|
|
||||||
happen only after *every* plugin's ``startup`` hook has finished - so
|
|
||||||
a task that depends on another plugin having registered something first
|
|
||||||
doesn't need ``tryfirst=True`` ordering tricks;
|
|
||||||
- **crash surfacing**: an unhandled exception in a background task is
|
|
||||||
logged with its full traceback to the ``datasette.background_tasks``
|
|
||||||
logger and recorded on the handle, instead of becoming an "Task
|
|
||||||
exception was never retrieved" warning nobody sees;
|
|
||||||
- **cancellation**: :meth:`BackgroundTaskSupervisor.cancel_all` cancels
|
|
||||||
every task still running and waits (with a grace period) for them to
|
|
||||||
actually stop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import functools
|
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger("datasette.background_tasks")
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow_iso() -> str:
|
|
||||||
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _function_path(func: Callable) -> str:
|
|
||||||
"""Describe the callable without guessing which plugin registered it."""
|
|
||||||
while isinstance(func, functools.partial):
|
|
||||||
func = func.func
|
|
||||||
if not hasattr(func, "__qualname__"):
|
|
||||||
func = type(func).__call__
|
|
||||||
return f"{func.__module__}.{func.__qualname__}"
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundTask:
|
|
||||||
"""A handle to a single piece of supervised background work.
|
|
||||||
|
|
||||||
States: ``registered`` (added but not yet launched) -> ``running`` ->
|
|
||||||
one of ``completed`` (returned cleanly), ``crashed`` (raised an
|
|
||||||
exception other than ``CancelledError`` - see ``.exception``), or
|
|
||||||
``cancelled`` (``.cancel()`` was called, or it was still running at
|
|
||||||
shutdown).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
func: Callable[[object], Awaitable[None]],
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.state = "registered"
|
|
||||||
self.task: asyncio.Task | None = None
|
|
||||||
self.exception: BaseException | None = None
|
|
||||||
self.started_at: str | None = None
|
|
||||||
self.function = _function_path(func)
|
|
||||||
self._func = func
|
|
||||||
self._supervisor: BackgroundTaskSupervisor | None = None
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
"""Cancel this task.
|
|
||||||
|
|
||||||
If it has already been launched, cancels the underlying
|
|
||||||
``asyncio.Task`` - its state becomes ``cancelled`` once the
|
|
||||||
cancellation is observed (asynchronously, via the task's done
|
|
||||||
callback). If it has not been launched yet, this is a no-op as
|
|
||||||
far as asyncio is concerned (there's no task to cancel) but it
|
|
||||||
deregisters the handle from its supervisor so it never runs.
|
|
||||||
"""
|
|
||||||
if self.task is not None:
|
|
||||||
self.task.cancel()
|
|
||||||
elif self._supervisor is not None:
|
|
||||||
self._supervisor._deregister(self)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<BackgroundTask name={self.name!r} state={self.state!r}>"
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundTaskSupervisor:
|
|
||||||
"""Owns registration and launch of every :class:`BackgroundTask` for a
|
|
||||||
single ``Datasette`` instance.
|
|
||||||
|
|
||||||
Registration (:meth:`add`) is separate from launch
|
|
||||||
(:meth:`launch_all`): plugins register work whenever convenient
|
|
||||||
(typically from a ``startup`` hook, but request handlers can register
|
|
||||||
dynamic per-job work too), and it either sits buffered until
|
|
||||||
:meth:`launch_all` runs, or - if :meth:`launch_all` has already run -
|
|
||||||
starts immediately.
|
|
||||||
|
|
||||||
Strong references to every :class:`BackgroundTask` (and its
|
|
||||||
``asyncio.Task``) are kept for the life of the instance, by design -
|
|
||||||
that's what makes the enrichments-style "fire-and-forget task gets
|
|
||||||
garbage collected mid-flight" bug impossible here. There is currently
|
|
||||||
no pruning of completed/crashed/cancelled tasks, so a plugin that
|
|
||||||
dynamically registers many short-lived tasks over a long process
|
|
||||||
lifetime (a per-job registration pattern, e.g. one task per queued
|
|
||||||
job) will grow this list without bound. That's an accepted v1
|
|
||||||
trade-off in favour of full introspection (``/-/tasks``); revisit
|
|
||||||
with a pruning or capping policy if unbounded growth is reported in
|
|
||||||
practice.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, datasette):
|
|
||||||
self._datasette = datasette
|
|
||||||
self._tasks: list[BackgroundTask] = []
|
|
||||||
self._names = set()
|
|
||||||
self._launched = False
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
def add(self, func, name=None) -> BackgroundTask:
|
|
||||||
base_name = name or getattr(func, "__qualname__", None) or repr(func)
|
|
||||||
actual_name = self._unique_name(base_name)
|
|
||||||
handle = BackgroundTask(actual_name, func)
|
|
||||||
handle._supervisor = self
|
|
||||||
self._tasks.append(handle)
|
|
||||||
self._names.add(actual_name)
|
|
||||||
if self._launched:
|
|
||||||
self._launch_one(handle)
|
|
||||||
return handle
|
|
||||||
|
|
||||||
def _unique_name(self, base_name: str) -> str:
|
|
||||||
if base_name not in self._names:
|
|
||||||
return base_name
|
|
||||||
n = 2
|
|
||||||
while f"{base_name}-{n}" in self._names:
|
|
||||||
n += 1
|
|
||||||
return f"{base_name}-{n}"
|
|
||||||
|
|
||||||
def _deregister(self, handle: BackgroundTask) -> None:
|
|
||||||
try:
|
|
||||||
self._tasks.remove(handle)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
self._names.discard(handle.name)
|
|
||||||
|
|
||||||
def _launch_one(self, handle: BackgroundTask) -> None:
|
|
||||||
handle.state = "running"
|
|
||||||
handle.started_at = _utcnow_iso()
|
|
||||||
handle.task = asyncio.create_task(
|
|
||||||
handle._func(self._datasette), name=handle.name
|
|
||||||
)
|
|
||||||
handle.task.add_done_callback(functools.partial(_on_task_done, handle))
|
|
||||||
|
|
||||||
async def launch_all(self) -> None:
|
|
||||||
"""Launch every currently-registered task that hasn't launched
|
|
||||||
yet. Idempotent and safe to call concurrently: subsequent (or
|
|
||||||
racing) calls are no-ops once the first has set ``self._launched``.
|
|
||||||
"""
|
|
||||||
if self._launched:
|
|
||||||
return
|
|
||||||
async with self._lock:
|
|
||||||
if self._launched:
|
|
||||||
return
|
|
||||||
self._launched = True
|
|
||||||
for handle in list(self._tasks):
|
|
||||||
if handle.task is None:
|
|
||||||
self._launch_one(handle)
|
|
||||||
|
|
||||||
async def cancel_all(self, grace: float = 5.0) -> None:
|
|
||||||
"""Cancel every task that isn't already done, then wait up to
|
|
||||||
``grace`` seconds for them to actually finish. Stragglers still
|
|
||||||
running after that are logged by name (but left to finish or not
|
|
||||||
on their own - this does not forcibly kill them, asyncio has no
|
|
||||||
mechanism for that).
|
|
||||||
"""
|
|
||||||
handles_by_task = {
|
|
||||||
handle.task: handle for handle in self._tasks if handle.task is not None
|
|
||||||
}
|
|
||||||
pending = [task for task in handles_by_task if not task.done()]
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
if not pending:
|
|
||||||
return
|
|
||||||
_done, not_done = await asyncio.wait(pending, timeout=grace)
|
|
||||||
if not_done:
|
|
||||||
names = sorted(handles_by_task[task].name for task in not_done)
|
|
||||||
logger.warning(
|
|
||||||
"%d background task(s) did not finish within the %.1fs grace "
|
|
||||||
"period after cancellation: %s",
|
|
||||||
len(names),
|
|
||||||
grace,
|
|
||||||
", ".join(names),
|
|
||||||
)
|
|
||||||
|
|
||||||
def tasks(self) -> list[BackgroundTask]:
|
|
||||||
"""Return every registered :class:`BackgroundTask`, launched or
|
|
||||||
not, in registration order. Used by the ``/-/tasks`` debug
|
|
||||||
endpoint.
|
|
||||||
"""
|
|
||||||
return list(self._tasks)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def launched(self) -> bool:
|
|
||||||
"""Whether :meth:`launch_all` has run yet - lets ``/-/tasks``
|
|
||||||
distinguish "no tasks registered" from "tasks registered but
|
|
||||||
nothing has armed the launch yet" without reaching for the
|
|
||||||
private ``_launched`` attribute.
|
|
||||||
"""
|
|
||||||
return self._launched
|
|
||||||
|
|
||||||
|
|
||||||
def _on_task_done(handle: BackgroundTask, task: asyncio.Task) -> None:
|
|
||||||
if task.cancelled():
|
|
||||||
handle.state = "cancelled"
|
|
||||||
return
|
|
||||||
exc = task.exception()
|
|
||||||
if exc is not None:
|
|
||||||
handle.state = "crashed"
|
|
||||||
handle.exception = exc
|
|
||||||
logger.error("Background task %r crashed", handle.name, exc_info=exc)
|
|
||||||
return
|
|
||||||
handle.state = "completed"
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
from datasette.utils.asgi import Response, BadRequest
|
||||||
from datasette.utils import to_css_class
|
from datasette.utils import to_css_class
|
||||||
from datasette.utils.asgi import BadRequest, Response
|
import hashlib
|
||||||
|
|
||||||
_BLOB_COLUMN = "_blob_column"
|
_BLOB_COLUMN = "_blob_column"
|
||||||
_BLOB_HASH = "_blob_hash"
|
_BLOB_HASH = "_blob_hash"
|
||||||
|
|
|
||||||
153
datasette/cli.py
153
datasette/cli.py
|
|
@ -1,45 +1,43 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import uvicorn
|
||||||
|
import click
|
||||||
|
from click import formatting
|
||||||
|
from click.types import CompositeParamType
|
||||||
|
from click_default_group import DefaultGroup
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from runpy import run_module
|
||||||
import shutil
|
import shutil
|
||||||
|
from subprocess import call
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
import webbrowser
|
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 (
|
from .app import (
|
||||||
|
Datasette,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SQLITE_LIMIT_ATTACHED,
|
SQLITE_LIMIT_ATTACHED,
|
||||||
Datasette,
|
|
||||||
pm,
|
pm,
|
||||||
)
|
)
|
||||||
from .inspect import inspect_tables
|
from .inspect import inspect_tables
|
||||||
from .utils import (
|
from .utils import (
|
||||||
ConnectionProblem,
|
|
||||||
LoadExtension,
|
LoadExtension,
|
||||||
SpatialiteConnectionProblem,
|
|
||||||
SpatialiteNotFound,
|
|
||||||
StartupError,
|
StartupError,
|
||||||
StaticMount,
|
|
||||||
ValueAsBooleanError,
|
|
||||||
check_connection,
|
check_connection,
|
||||||
deep_dict_update,
|
deep_dict_update,
|
||||||
find_spatialite,
|
find_spatialite,
|
||||||
|
parse_metadata,
|
||||||
|
ConnectionProblem,
|
||||||
|
SpatialiteConnectionProblem,
|
||||||
initial_path_for_datasette,
|
initial_path_for_datasette,
|
||||||
pairs_to_nested_config,
|
pairs_to_nested_config,
|
||||||
parse_metadata,
|
|
||||||
temporary_docker_directory,
|
temporary_docker_directory,
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
|
SpatialiteNotFound,
|
||||||
|
StaticMount,
|
||||||
|
ValueAsBooleanError,
|
||||||
)
|
)
|
||||||
from .utils.sqlite import sqlite3
|
from .utils.sqlite import sqlite3
|
||||||
from .utils.testing import TestClient
|
from .utils.testing import TestClient
|
||||||
|
|
@ -77,7 +75,7 @@ class Setting(CompositeParamType):
|
||||||
# Datasette 1.0, we turn bare setting names into setting.name
|
# Datasette 1.0, we turn bare setting names into setting.name
|
||||||
# Type checking for those older settings
|
# Type checking for those older settings
|
||||||
default = DEFAULT_SETTINGS[name]
|
default = DEFAULT_SETTINGS[name]
|
||||||
name = f"settings.{name}"
|
name = "settings.{}".format(name)
|
||||||
if isinstance(default, bool):
|
if isinstance(default, bool):
|
||||||
try:
|
try:
|
||||||
return name, "true" if value_as_boolean(value) else "false"
|
return name, "true" if value_as_boolean(value) else "false"
|
||||||
|
|
@ -157,11 +155,7 @@ async def inspect_(files, sqlite_extensions):
|
||||||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||||
data = {}
|
data = {}
|
||||||
for name, database in app.databases.items():
|
for name, database in app.databases.items():
|
||||||
|
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
|
||||||
def _inspect_tables(conn):
|
|
||||||
return inspect_tables(conn, {})
|
|
||||||
|
|
||||||
tables = await database.execute_fn(_inspect_tables)
|
|
||||||
data[name] = {
|
data[name] = {
|
||||||
"hash": database.hash,
|
"hash": database.hash,
|
||||||
"size": database.size,
|
"size": database.size,
|
||||||
|
|
@ -177,6 +171,7 @@ async def inspect_(files, sqlite_extensions):
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def publish():
|
def publish():
|
||||||
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Register publish plugins
|
# Register publish plugins
|
||||||
|
|
@ -501,7 +496,6 @@ def uninstall(packages, yes):
|
||||||
"--internal",
|
"--internal",
|
||||||
type=click.Path(),
|
type=click.Path(),
|
||||||
help="Path to a persistent Datasette internal SQLite database",
|
help="Path to a persistent Datasette internal SQLite database",
|
||||||
envvar="DATASETTE_INTERNAL",
|
|
||||||
)
|
)
|
||||||
def serve(
|
def serve(
|
||||||
files,
|
files,
|
||||||
|
|
@ -584,27 +578,27 @@ def serve(
|
||||||
# https://github.com/simonw/datasette/issues/2389
|
# https://github.com/simonw/datasette/issues/2389
|
||||||
deep_dict_update(config_data, settings_updates)
|
deep_dict_update(config_data, settings_updates)
|
||||||
|
|
||||||
kwargs = {
|
kwargs = dict(
|
||||||
"immutables": immutable,
|
immutables=immutable,
|
||||||
"cache_headers": not reload,
|
cache_headers=not reload,
|
||||||
"cors": cors,
|
cors=cors,
|
||||||
"inspect_data": inspect_data,
|
inspect_data=inspect_data,
|
||||||
"config": config_data,
|
config=config_data,
|
||||||
"metadata": metadata_data,
|
metadata=metadata_data,
|
||||||
"sqlite_extensions": sqlite_extensions,
|
sqlite_extensions=sqlite_extensions,
|
||||||
"template_dir": template_dir,
|
template_dir=template_dir,
|
||||||
"plugins_dir": plugins_dir,
|
plugins_dir=plugins_dir,
|
||||||
"static_mounts": static,
|
static_mounts=static,
|
||||||
"settings": None, # These are passed in config= now
|
settings=None, # These are passed in config= now
|
||||||
"memory": memory,
|
memory=memory,
|
||||||
"secret": secret,
|
secret=secret,
|
||||||
"version_note": version_note,
|
version_note=version_note,
|
||||||
"pdb": pdb,
|
pdb=pdb,
|
||||||
"crossdb": crossdb,
|
crossdb=crossdb,
|
||||||
"nolock": nolock,
|
nolock=nolock,
|
||||||
"internal": internal,
|
internal=internal,
|
||||||
"default_deny": default_deny,
|
default_deny=default_deny,
|
||||||
}
|
)
|
||||||
|
|
||||||
# Separate directories from files
|
# Separate directories from files
|
||||||
directories = [f for f in files if os.path.isdir(f)]
|
directories = [f for f in files if os.path.isdir(f)]
|
||||||
|
|
@ -627,7 +621,9 @@ def serve(
|
||||||
conn.close()
|
conn.close()
|
||||||
else:
|
else:
|
||||||
raise click.ClickException(
|
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
|
# Check for duplicate files by resolving all paths to their absolute forms
|
||||||
|
|
@ -668,6 +664,16 @@ def serve(
|
||||||
# Private utility mechanism for writing unit tests
|
# Private utility mechanism for writing unit tests
|
||||||
return ds
|
return ds
|
||||||
|
|
||||||
|
# Run async soundness checks before startup hooks, since invoke_startup
|
||||||
|
# now populates internal tables which requires querying each database
|
||||||
|
run_sync(lambda: check_databases(ds))
|
||||||
|
|
||||||
|
# Run the "startup" plugin hooks
|
||||||
|
try:
|
||||||
|
run_sync(ds.invoke_startup)
|
||||||
|
except StartupError as e:
|
||||||
|
raise click.ClickException(e.args[0])
|
||||||
|
|
||||||
if headers and not get:
|
if headers and not get:
|
||||||
raise click.ClickException("--headers can only be used with --get")
|
raise click.ClickException("--headers can only be used with --get")
|
||||||
|
|
||||||
|
|
@ -675,23 +681,10 @@ def serve(
|
||||||
raise click.ClickException("--token can only be used with --get")
|
raise click.ClickException("--token can only be used with --get")
|
||||||
|
|
||||||
if get:
|
if get:
|
||||||
# --get means we don't run Uvicorn at all
|
|
||||||
run_sync(lambda: check_databases(ds))
|
|
||||||
|
|
||||||
try:
|
|
||||||
run_sync(ds.invoke_startup)
|
|
||||||
except StartupError as e:
|
|
||||||
raise click.ClickException(e.args[0])
|
|
||||||
|
|
||||||
# --get never launches background tasks: TestClient's request below
|
|
||||||
# flows through the full ASGI stack, including the
|
|
||||||
# AsgiRunOnFirstRequest fallback, which would otherwise launch them.
|
|
||||||
ds._suppress_background_tasks = True
|
|
||||||
|
|
||||||
client = TestClient(ds)
|
client = TestClient(ds)
|
||||||
request_headers = {}
|
request_headers = {}
|
||||||
if token:
|
if token:
|
||||||
request_headers["Authorization"] = f"Bearer {token}"
|
request_headers["Authorization"] = "Bearer {}".format(token)
|
||||||
cookies = {}
|
cookies = {}
|
||||||
if actor:
|
if actor:
|
||||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||||
|
|
@ -712,23 +705,6 @@ def serve(
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
return
|
return
|
||||||
|
|
||||||
# check_databases, invoke_startup() and the uvicorn server all run on a
|
|
||||||
# single event loop, so that anything a plugin's "startup" hook schedules
|
|
||||||
# on the loop (asyncio.create_task, Lock/Queue/Event objects, ...) is
|
|
||||||
# still alive when the server starts handling requests.
|
|
||||||
async def _serve_async():
|
|
||||||
# Populate internal catalog tables before invoke_startup
|
|
||||||
await check_databases(ds)
|
|
||||||
|
|
||||||
# Run the full startup sequence (immutable-database table-count
|
|
||||||
# precompute + the "startup" plugin hooks) via the same entry point
|
|
||||||
# AsgiLifespan/AsgiRunOnFirstRequest use, so it's not skipped when
|
|
||||||
# uvicorn's lifespan.startup fires moments later.
|
|
||||||
try:
|
|
||||||
await ds._startup_sequence()
|
|
||||||
except StartupError as e:
|
|
||||||
raise click.ClickException(e.args[0])
|
|
||||||
|
|
||||||
# Start the server
|
# Start the server
|
||||||
url = None
|
url = None
|
||||||
if root:
|
if root:
|
||||||
|
|
@ -740,26 +716,19 @@ def serve(
|
||||||
if open_browser:
|
if open_browser:
|
||||||
if url is None:
|
if url is None:
|
||||||
# Figure out most convenient URL - to table, database or homepage
|
# Figure out most convenient URL - to table, database or homepage
|
||||||
path = await initial_path_for_datasette(ds)
|
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||||
url = f"http://{host}:{port}{path}"
|
url = f"http://{host}:{port}{path}"
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
uvicorn_kwargs = {
|
uvicorn_kwargs = dict(
|
||||||
"host": host,
|
host=host, port=port, log_level="info", lifespan="on", workers=1
|
||||||
"port": port,
|
)
|
||||||
"log_level": "info",
|
|
||||||
"lifespan": "on",
|
|
||||||
"workers": 1,
|
|
||||||
}
|
|
||||||
if uds:
|
if uds:
|
||||||
uvicorn_kwargs["uds"] = uds
|
uvicorn_kwargs["uds"] = uds
|
||||||
if ssl_keyfile:
|
if ssl_keyfile:
|
||||||
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||||
if ssl_certfile:
|
if ssl_certfile:
|
||||||
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
||||||
server = uvicorn.Server(uvicorn.Config(ds.app(), **uvicorn_kwargs))
|
uvicorn.run(ds.app(), **uvicorn_kwargs)
|
||||||
await server.serve()
|
|
||||||
|
|
||||||
asyncio.run(_serve_async())
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
@ -916,7 +885,7 @@ async def check_databases(ds):
|
||||||
)
|
)
|
||||||
except ConnectionProblem as e:
|
except ConnectionProblem as e:
|
||||||
raise click.UsageError(
|
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 --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
|
||||||
if (
|
if (
|
||||||
|
|
@ -924,5 +893,9 @@ async def check_databases(ds):
|
||||||
and len([db for db in ds.databases.values() if not db.is_memory])
|
and len([db for db in ds.databases.values() if not db.is_memory])
|
||||||
> SQLITE_LIMIT_ATTACHED
|
> 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)
|
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)
|
||||||
|
|
|
||||||
|
|
@ -64,14 +64,14 @@ class ColumnType:
|
||||||
Return an HTML string to render this cell value, or None to
|
Return an HTML string to render this cell value, or None to
|
||||||
fall through to the default render_cell plugin hook chain.
|
fall through to the default render_cell plugin hook chain.
|
||||||
"""
|
"""
|
||||||
return
|
return None
|
||||||
|
|
||||||
async def validate(self, value, datasette):
|
async def validate(self, value, datasette):
|
||||||
"""
|
"""
|
||||||
Validate a value before it is written. Return None if valid,
|
Validate a value before it is written. Return None if valid,
|
||||||
or a string error message if invalid.
|
or a string error message if invalid.
|
||||||
"""
|
"""
|
||||||
return
|
return None
|
||||||
|
|
||||||
async def transform_value(self, value, datasette):
|
async def transform_value(self, value, datasette):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@ def _origin_tuple(value):
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
if not scheme or not host:
|
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
|
port = parsed.port # may raise ValueError on bad ports
|
||||||
if port is None:
|
if port is None:
|
||||||
port = DEFAULT_PORTS.get(scheme)
|
port = DEFAULT_PORTS.get(scheme)
|
||||||
if port is None:
|
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
|
return scheme, host, port
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -125,7 +125,9 @@ class CrossOriginProtectionMiddleware:
|
||||||
return
|
return
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
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
|
return
|
||||||
|
|
||||||
|
|
@ -139,11 +141,11 @@ class CrossOriginProtectionMiddleware:
|
||||||
request_scheme = self._request_scheme(scope)
|
request_scheme = self._request_scheme(scope)
|
||||||
try:
|
try:
|
||||||
origin_tuple = _origin_tuple(origin)
|
origin_tuple = _origin_tuple(origin)
|
||||||
expected_tuple = _origin_tuple(f"{request_scheme}://{host}")
|
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
send,
|
||||||
f"Malformed Origin {origin!r} or Host {host!r}",
|
"Malformed Origin {!r} or Host {!r}".format(origin, host),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -153,7 +155,7 @@ class CrossOriginProtectionMiddleware:
|
||||||
|
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
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):
|
def _request_scheme(self, scope):
|
||||||
|
|
@ -161,8 +163,7 @@ class CrossOriginProtectionMiddleware:
|
||||||
try:
|
try:
|
||||||
if self.datasette.setting("force_https_urls"):
|
if self.datasette.setting("force_https_urls"):
|
||||||
return "https"
|
return "https"
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Settings may not be readable this early; fall back to the ASGI scheme
|
|
||||||
pass
|
pass
|
||||||
return scope.get("scheme") or "http"
|
return scope.get("scheme") or "http"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,33 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import contextvars
|
from collections import namedtuple
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import queue
|
import queue
|
||||||
|
import sqlite_utils
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections import namedtuple
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import sqlite_utils
|
|
||||||
from opentelemetry import context as otel_context_api
|
|
||||||
from opentelemetry.trace import Status, StatusCode
|
|
||||||
|
|
||||||
from .inspect import inspect_hash
|
|
||||||
from .telemetry import (
|
|
||||||
callback_name,
|
|
||||||
linked_root_span_kwargs,
|
|
||||||
record_operation_duration,
|
|
||||||
record_query_interrupted,
|
|
||||||
record_write_queue_wait,
|
|
||||||
sql_attribute,
|
|
||||||
sql_operation_name,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
from .telemetry_registry import (
|
|
||||||
CALLBACK,
|
|
||||||
DB_NAMESPACE,
|
|
||||||
DB_OPERATION_NAME,
|
|
||||||
DB_QUERY,
|
|
||||||
DB_QUERY_EXECUTE,
|
|
||||||
DB_QUERY_TEXT,
|
|
||||||
DB_SYSTEM,
|
|
||||||
DB_WRITE_EXECUTE,
|
|
||||||
DB_WRITE_QUEUE_WAIT,
|
|
||||||
EXECUTEMANY,
|
|
||||||
EXECUTESCRIPT,
|
|
||||||
INTERRUPTED,
|
|
||||||
ISOLATED_CONNECTION,
|
|
||||||
PARAM_COUNT,
|
|
||||||
PARAM_SETS,
|
|
||||||
ROWS_RETURNED,
|
|
||||||
SQL_ERROR_SUPPRESSED,
|
|
||||||
TIME_LIMIT_MS,
|
|
||||||
TRANSACTION,
|
|
||||||
TRUNCATED,
|
|
||||||
)
|
|
||||||
from .tracer import trace
|
from .tracer import trace
|
||||||
from .utils import (
|
from .utils import (
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
detect_fts,
|
detect_fts,
|
||||||
detect_primary_keys,
|
detect_primary_keys,
|
||||||
detect_spatialite,
|
detect_spatialite,
|
||||||
escape_sqlite,
|
|
||||||
get_all_foreign_keys,
|
get_all_foreign_keys,
|
||||||
get_outbound_foreign_keys,
|
get_outbound_foreign_keys,
|
||||||
md5_not_usedforsecurity,
|
md5_not_usedforsecurity,
|
||||||
sqlite3,
|
|
||||||
sqlite_timelimit,
|
sqlite_timelimit,
|
||||||
table_column_details,
|
sqlite3,
|
||||||
table_columns,
|
table_columns,
|
||||||
|
table_column_details,
|
||||||
)
|
)
|
||||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||||
from .utils.sqlite import sqlite_derived_table_dependencies, sqlite_hidden_table_names
|
from .utils.sqlite import sqlite_hidden_table_names
|
||||||
|
from .inspect import inspect_hash
|
||||||
|
|
||||||
connections = threading.local()
|
connections = threading.local()
|
||||||
|
|
||||||
|
|
@ -121,7 +83,6 @@ class Database:
|
||||||
self.cached_hash = None
|
self.cached_hash = None
|
||||||
self.cached_size = None
|
self.cached_size = None
|
||||||
self._cached_table_counts = None
|
self._cached_table_counts = None
|
||||||
self._cached_derived_table_dependencies = None
|
|
||||||
self._write_thread = None
|
self._write_thread = None
|
||||||
self._write_queue = None
|
self._write_queue = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
@ -130,15 +91,16 @@ class Database:
|
||||||
# These are used when in non-threaded mode:
|
# These are used when in non-threaded mode:
|
||||||
self._read_connection = None
|
self._read_connection = None
|
||||||
self._write_connection = None
|
self._write_connection = None
|
||||||
# Track file and memory connections, including reads on worker threads,
|
# This is used to track all file connections so they can be closed
|
||||||
# so close() can release all of them from the calling thread.
|
self._all_file_connections = []
|
||||||
self._all_connections = []
|
|
||||||
if not is_temp_disk:
|
if not is_temp_disk:
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
|
||||||
def _check_not_closed(self):
|
def _check_not_closed(self):
|
||||||
if self._closed:
|
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):
|
def _remove_pending_execute_future(self, future):
|
||||||
with self._pending_execute_futures_lock:
|
with self._pending_execute_futures_lock:
|
||||||
|
|
@ -177,18 +139,15 @@ class Database:
|
||||||
if write:
|
if write:
|
||||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||||
if self.memory_name:
|
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(
|
conn = sqlite3.connect(
|
||||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||||
)
|
)
|
||||||
if not write:
|
if not write:
|
||||||
conn.execute("PRAGMA query_only=1")
|
conn.execute("PRAGMA query_only=1")
|
||||||
self._all_connections.append(conn)
|
|
||||||
return conn
|
return conn
|
||||||
if self.is_memory:
|
if self.is_memory:
|
||||||
conn = sqlite3.connect(":memory:", uri=True, check_same_thread=False)
|
return sqlite3.connect(":memory:", uri=True)
|
||||||
self._all_connections.append(conn)
|
|
||||||
return conn
|
|
||||||
|
|
||||||
# mode=ro or immutable=1?
|
# mode=ro or immutable=1?
|
||||||
if self.is_mutable:
|
if self.is_mutable:
|
||||||
|
|
@ -205,7 +164,7 @@ class Database:
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
|
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
|
||||||
)
|
)
|
||||||
self._all_connections.append(conn)
|
self._all_file_connections.append(conn)
|
||||||
if self.is_temp_disk and not self._wal_enabled:
|
if self.is_temp_disk and not self._wal_enabled:
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
self._wal_enabled = True
|
self._wal_enabled = True
|
||||||
|
|
@ -233,22 +192,23 @@ class Database:
|
||||||
write_thread.join(timeout=10)
|
write_thread.join(timeout=10)
|
||||||
if write_thread.is_alive():
|
if write_thread.is_alive():
|
||||||
sys.stderr.write(
|
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()
|
sys.stderr.flush()
|
||||||
for future in pending_execute_futures:
|
for future in pending_execute_futures:
|
||||||
try:
|
try:
|
||||||
future.result()
|
future.result()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Shutdown teardown - a failed pending write must not block close()
|
|
||||||
pass
|
pass
|
||||||
# Close anything still tracked in _all_connections
|
# Close anything still tracked in _all_file_connections
|
||||||
for connection in self._all_connections:
|
for connection in self._all_file_connections:
|
||||||
try:
|
try:
|
||||||
connection.close()
|
connection.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._all_connections = []
|
self._all_file_connections = []
|
||||||
# Drop per-thread cached read connections we can reach
|
# Drop per-thread cached read connections we can reach
|
||||||
try:
|
try:
|
||||||
delattr(connections, self._thread_local_id)
|
delattr(connections, self._thread_local_id)
|
||||||
|
|
@ -258,13 +218,13 @@ class Database:
|
||||||
if self._read_connection is not None:
|
if self._read_connection is not None:
|
||||||
try:
|
try:
|
||||||
self._read_connection.close()
|
self._read_connection.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._read_connection = None
|
self._read_connection = None
|
||||||
if self._write_connection is not None:
|
if self._write_connection is not None:
|
||||||
try:
|
try:
|
||||||
self._write_connection.close()
|
self._write_connection.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._write_connection = None
|
self._write_connection = None
|
||||||
if self.is_temp_disk:
|
if self.is_temp_disk:
|
||||||
|
|
@ -286,46 +246,19 @@ class Database:
|
||||||
request=None,
|
request=None,
|
||||||
return_all=False,
|
return_all=False,
|
||||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||||
transaction=True,
|
|
||||||
time_limit_ms=2000,
|
|
||||||
):
|
):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
if returning_limit < 0:
|
if returning_limit < 0:
|
||||||
raise ValueError("returning_limit must be >= 0")
|
raise ValueError("returning_limit must be >= 0")
|
||||||
|
|
||||||
def execute_sql(conn):
|
def _inner(conn):
|
||||||
cursor = conn.execute(sql, params or [])
|
cursor = conn.execute(sql, params or [])
|
||||||
return ExecuteWriteResult.from_cursor(
|
return ExecuteWriteResult.from_cursor(
|
||||||
cursor, return_all=return_all, returning_limit=returning_limit
|
cursor, return_all=return_all, returning_limit=returning_limit
|
||||||
)
|
)
|
||||||
|
|
||||||
def _inner(conn):
|
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||||
try:
|
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||||
if time_limit_ms is None:
|
|
||||||
return execute_sql(conn)
|
|
||||||
with sqlite_timelimit(conn, time_limit_ms):
|
|
||||||
return execute_sql(conn)
|
|
||||||
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
|
|
||||||
if e.args == ("interrupted",):
|
|
||||||
raise QueryInterrupted(e, sql, params)
|
|
||||||
raise
|
|
||||||
|
|
||||||
with trace( # noqa: SIM117
|
|
||||||
"sql", database=self.name, sql=sql.strip(), params=params
|
|
||||||
):
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
operation_name = sql_operation_name(sql)
|
|
||||||
if operation_name:
|
|
||||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
|
||||||
if params:
|
|
||||||
span.set_attribute(PARAM_COUNT, len(params))
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
results = await self._execute_write_fn(
|
|
||||||
_inner, block=block, request=request, transaction=transaction
|
|
||||||
)
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def execute_write_script(self, sql, block=True, request=None):
|
async def execute_write_script(self, sql, block=True, request=None):
|
||||||
|
|
@ -334,17 +267,8 @@ class Database:
|
||||||
def _inner(conn):
|
def _inner(conn):
|
||||||
return conn.executescript(sql)
|
return conn.executescript(sql)
|
||||||
|
|
||||||
with trace( # noqa: SIM117
|
with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
|
||||||
"sql", database=self.name, sql=sql.strip(), executescript=True
|
results = await self.execute_write_fn(
|
||||||
):
|
|
||||||
# No db.operation.name, since the script can contain multiple statements
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
span.set_attribute(EXECUTESCRIPT, True)
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
results = await self._execute_write_fn(
|
|
||||||
_inner, block=block, transaction=False, request=request
|
_inner, block=block, transaction=False, request=request
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
@ -366,19 +290,9 @@ class Database:
|
||||||
with trace(
|
with trace(
|
||||||
"sql", database=self.name, sql=sql.strip(), executemany=True
|
"sql", database=self.name, sql=sql.strip(), executemany=True
|
||||||
) as kwargs:
|
) as kwargs:
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
results, count = await self.execute_write_fn(
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
span.set_attribute(EXECUTEMANY, True)
|
|
||||||
operation_name = sql_operation_name(sql)
|
|
||||||
if operation_name:
|
|
||||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
results, count = await self._execute_write_fn(
|
|
||||||
_inner, block=block, request=request
|
_inner, block=block, request=request
|
||||||
)
|
)
|
||||||
span.set_attribute(PARAM_SETS, count)
|
|
||||||
kwargs["count"] = count
|
kwargs["count"] = count
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -395,27 +309,19 @@ class Database:
|
||||||
finally:
|
finally:
|
||||||
isolated_connection.close()
|
isolated_connection.close()
|
||||||
try:
|
try:
|
||||||
self._all_connections.remove(isolated_connection)
|
self._all_file_connections.remove(isolated_connection)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# May already have been cleared by close().
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(CALLBACK, callback_name(fn))
|
|
||||||
# Immutable databases run this on the read pool, not the write queue
|
|
||||||
with record_operation_duration(self.name, "write" if write else "read"):
|
|
||||||
if self.ds.executor is None:
|
if self.ds.executor is None:
|
||||||
# non-threaded mode
|
# non-threaded mode
|
||||||
return _run()
|
return _run()
|
||||||
if not write:
|
if not write:
|
||||||
# Immutable database - no writes can ever occur, so there
|
# Immutable database - no writes can ever occur, so there is no
|
||||||
# is no write queue to block; run against a fresh
|
# write queue to block; run against a fresh read-only connection
|
||||||
# read-only connection
|
|
||||||
ctx = contextvars.copy_context()
|
|
||||||
return await asyncio.get_running_loop().run_in_executor(
|
return await asyncio.get_running_loop().run_in_executor(
|
||||||
self.ds.executor, ctx.run, _run
|
self.ds.executor, _run
|
||||||
)
|
)
|
||||||
# Threaded mode - send to write thread
|
# Threaded mode - send to write thread
|
||||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||||
|
|
@ -423,30 +329,11 @@ class Database:
|
||||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
|
|
||||||
def _analyze_sql(conn):
|
return await self.execute_isolated_fn(
|
||||||
return analyze_sql_tables(conn, sql, params, database_name=self.name)
|
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
||||||
|
|
||||||
return await self.execute_isolated_fn(_analyze_sql)
|
|
||||||
|
|
||||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
|
||||||
"""Run `fn(conn)` on the write connection, traced as a `db.query` span.
|
|
||||||
|
|
||||||
The SQL-string write methods call `_execute_write_fn()` directly to
|
|
||||||
avoid creating a second span.
|
|
||||||
"""
|
|
||||||
self._check_not_closed()
|
|
||||||
# Record the name before _wrap_fn_with_hooks() wraps fn
|
|
||||||
name = callback_name(fn)
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(CALLBACK, name)
|
|
||||||
with record_operation_duration(self.name, "write"):
|
|
||||||
return await self._execute_write_fn(
|
|
||||||
fn, block=block, transaction=transaction, request=request
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
pending_events = []
|
pending_events = []
|
||||||
|
|
||||||
|
|
@ -461,19 +348,9 @@ class Database:
|
||||||
self.ds._prepare_connection(self._write_connection, self.name)
|
self.ds._prepare_connection(self._write_connection, self.name)
|
||||||
if transaction:
|
if transaction:
|
||||||
with self._write_connection:
|
with self._write_connection:
|
||||||
self._write_connection.execute("BEGIN IMMEDIATE")
|
|
||||||
result = fn(self._write_connection)
|
result = fn(self._write_connection)
|
||||||
else:
|
else:
|
||||||
result = fn(self._write_connection)
|
result = fn(self._write_connection)
|
||||||
if not block:
|
|
||||||
# There is no write thread here, so the write has already
|
|
||||||
# finished. Hand back the same (task_id, reply_future) shape
|
|
||||||
# _send_to_write_thread() returns, with the future already
|
|
||||||
# resolved, so the block=False path below is identical in
|
|
||||||
# both modes.
|
|
||||||
reply_future = asyncio.get_running_loop().create_future()
|
|
||||||
reply_future.set_result(result)
|
|
||||||
result = (uuid.uuid4(), reply_future)
|
|
||||||
else:
|
else:
|
||||||
result = await self._send_to_write_thread(
|
result = await self._send_to_write_thread(
|
||||||
fn, block=block, transaction=transaction
|
fn, block=block, transaction=transaction
|
||||||
|
|
@ -489,8 +366,7 @@ class Database:
|
||||||
async def _dispatch_events_after_write():
|
async def _dispatch_events_after_write():
|
||||||
try:
|
try:
|
||||||
await reply_future
|
await reply_future
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
# The write failed; skip success events regardless of why
|
|
||||||
# if the write failed, don't emit success events
|
# if the write failed, don't emit success events
|
||||||
return
|
return
|
||||||
for event in pending_events:
|
for event in pending_events:
|
||||||
|
|
@ -543,24 +419,15 @@ class Database:
|
||||||
self._write_thread = threading.Thread(
|
self._write_thread = threading.Thread(
|
||||||
target=self._execute_writes, daemon=True
|
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()
|
self._write_thread.start()
|
||||||
task_id = uuid.uuid4()
|
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
reply_future = loop.create_future()
|
reply_future = loop.create_future()
|
||||||
# Capture the OpenTelemetry context and enqueue time for the write thread
|
|
||||||
self._write_queue.put(
|
self._write_queue.put(
|
||||||
WriteTask(
|
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
|
||||||
fn,
|
|
||||||
task_id,
|
|
||||||
loop,
|
|
||||||
reply_future,
|
|
||||||
isolated_connection,
|
|
||||||
transaction,
|
|
||||||
otel_context_api.get_current(),
|
|
||||||
time.time_ns(),
|
|
||||||
block,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if block:
|
if block:
|
||||||
return await reply_future
|
return await reply_future
|
||||||
|
|
@ -574,11 +441,8 @@ class Database:
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = self.connect(write=True)
|
conn = self.connect(write=True)
|
||||||
# Threads do not inherit the caller's context, so any spans
|
|
||||||
# created by prepare_connection hooks here are root spans
|
|
||||||
self.ds._prepare_connection(conn, self.name)
|
self.ds._prepare_connection(conn, self.name)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# Stored and re-raised to whoever queues the next write
|
|
||||||
conn_exception = e
|
conn_exception = e
|
||||||
while True:
|
while True:
|
||||||
task = self._write_queue.get()
|
task = self._write_queue.get()
|
||||||
|
|
@ -586,105 +450,43 @@ class Database:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Best-effort close as the write thread exits
|
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
# block=True: the caller awaits the result, so the write spans
|
|
||||||
# are children of the caller's span. The token must be detached
|
|
||||||
# in the finally block or the context leaks into later writes.
|
|
||||||
# block=False: the caller may finish first, so the write spans
|
|
||||||
# are root spans with a link back to the caller's span.
|
|
||||||
token = None
|
|
||||||
write_span_kwargs = {}
|
|
||||||
if task.block:
|
|
||||||
token = otel_context_api.attach(task.otel_context)
|
|
||||||
else:
|
|
||||||
write_span_kwargs = linked_root_span_kwargs(task.otel_context)
|
|
||||||
try:
|
|
||||||
exception = None
|
exception = None
|
||||||
result = None
|
result = None
|
||||||
# Span covers the time from enqueue to dequeue
|
|
||||||
dequeued_at_ns = time.time_ns()
|
|
||||||
tracer.start_span(
|
|
||||||
DB_WRITE_QUEUE_WAIT,
|
|
||||||
start_time=task.enqueued_at_ns,
|
|
||||||
**write_span_kwargs,
|
|
||||||
).end(end_time=dequeued_at_ns)
|
|
||||||
record_write_queue_wait(self.name, dequeued_at_ns - task.enqueued_at_ns)
|
|
||||||
if conn_exception is not None:
|
if conn_exception is not None:
|
||||||
exception = conn_exception
|
exception = conn_exception
|
||||||
elif task.isolated_connection:
|
elif task.isolated_connection:
|
||||||
try:
|
try:
|
||||||
with tracer.start_as_current_span(
|
|
||||||
DB_WRITE_EXECUTE, **write_span_kwargs
|
|
||||||
) as span:
|
|
||||||
span.set_attribute(
|
|
||||||
ISOLATED_CONNECTION,
|
|
||||||
task.isolated_connection,
|
|
||||||
)
|
|
||||||
span.set_attribute(TRANSACTION, task.transaction)
|
|
||||||
isolated_connection = self.connect(write=True)
|
isolated_connection = self.connect(write=True)
|
||||||
try:
|
try:
|
||||||
result = task.fn(isolated_connection)
|
result = task.fn(isolated_connection)
|
||||||
finally:
|
finally:
|
||||||
isolated_connection.close()
|
isolated_connection.close()
|
||||||
try:
|
try:
|
||||||
self._all_connections.remove(isolated_connection)
|
self._all_file_connections.remove(isolated_connection)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# May already have been cleared by close().
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# Write thread must survive any task failure or the database wedges
|
sys.stderr.write("{}\n".format(e))
|
||||||
sys.stderr.write(f"{e}\n")
|
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
exception = e
|
exception = e
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with tracer.start_as_current_span(
|
|
||||||
DB_WRITE_EXECUTE, **write_span_kwargs
|
|
||||||
) as span:
|
|
||||||
span.set_attribute(
|
|
||||||
ISOLATED_CONNECTION,
|
|
||||||
task.isolated_connection,
|
|
||||||
)
|
|
||||||
span.set_attribute(TRANSACTION, task.transaction)
|
|
||||||
if task.transaction:
|
if task.transaction:
|
||||||
with conn:
|
with conn:
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
|
||||||
result = task.fn(conn)
|
result = task.fn(conn)
|
||||||
else:
|
else:
|
||||||
result = task.fn(conn)
|
result = task.fn(conn)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
sys.stderr.write(f"{e}\n")
|
sys.stderr.write("{}\n".format(e))
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
exception = e
|
exception = e
|
||||||
_deliver_write_result(task, result, exception)
|
_deliver_write_result(task, result, exception)
|
||||||
finally:
|
|
||||||
if token is not None:
|
|
||||||
otel_context_api.detach(token)
|
|
||||||
|
|
||||||
async def execute_fn(self, fn):
|
async def execute_fn(self, fn):
|
||||||
"""Run `fn(conn)` on a read connection, traced as a `db.query` span.
|
|
||||||
|
|
||||||
`execute()` calls `_execute_fn()` directly to avoid creating a second
|
|
||||||
span.
|
|
||||||
"""
|
|
||||||
self._check_not_closed()
|
|
||||||
|
|
||||||
def fn_in_execute_span(conn):
|
|
||||||
# Runs on the worker thread
|
|
||||||
with tracer.start_as_current_span(DB_QUERY_EXECUTE):
|
|
||||||
return fn(conn)
|
|
||||||
|
|
||||||
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(CALLBACK, callback_name(fn))
|
|
||||||
with record_operation_duration(self.name, "read"):
|
|
||||||
return await self._execute_fn(fn_in_execute_span)
|
|
||||||
|
|
||||||
async def _execute_fn(self, fn):
|
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
if self.ds.executor is None:
|
if self.ds.executor is None:
|
||||||
# non-threaded mode
|
# non-threaded mode
|
||||||
|
|
@ -704,11 +506,7 @@ class Database:
|
||||||
|
|
||||||
with self._pending_execute_futures_lock:
|
with self._pending_execute_futures_lock:
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
# Run in a copy of the caller's context so spans created in the
|
future = self.ds.executor.submit(in_thread)
|
||||||
# thread have the correct parent. This needs a fresh copy for
|
|
||||||
# each submit, since a Context cannot be entered concurrently.
|
|
||||||
ctx = contextvars.copy_context()
|
|
||||||
future = self.ds.executor.submit(ctx.run, in_thread)
|
|
||||||
self._pending_execute_futures.add(future)
|
self._pending_execute_futures.add(future)
|
||||||
future.add_done_callback(self._remove_pending_execute_future)
|
future.add_done_callback(self._remove_pending_execute_future)
|
||||||
return await asyncio.wrap_future(future)
|
return await asyncio.wrap_future(future)
|
||||||
|
|
@ -725,22 +523,12 @@ class Database:
|
||||||
"""Executes sql against db_name in a thread"""
|
"""Executes sql against db_name in a thread"""
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
page_size = page_size or self.ds.page_size
|
page_size = page_size or self.ds.page_size
|
||||||
time_limit_ms = self.ds.sql_time_limit_ms
|
|
||||||
# Callers that pass a shorter custom_time_limit, such as table counts
|
|
||||||
# and facet suggestions, expect timeouts, so they are not span errors
|
|
||||||
timeout_expected = bool(custom_time_limit) and custom_time_limit < time_limit_ms
|
|
||||||
if timeout_expected:
|
|
||||||
time_limit_ms = custom_time_limit
|
|
||||||
|
|
||||||
def sql_operation_in_thread(conn):
|
def sql_operation_in_thread(conn):
|
||||||
# Expected timeouts and errors with log_sql_errors=False are not
|
time_limit_ms = self.ds.sql_time_limit_ms
|
||||||
# recorded as span errors, so exceptions are handled explicitly
|
if custom_time_limit and custom_time_limit < time_limit_ms:
|
||||||
with tracer.start_as_current_span(
|
time_limit_ms = custom_time_limit
|
||||||
DB_QUERY_EXECUTE,
|
|
||||||
record_exception=False,
|
|
||||||
set_status_on_exception=False,
|
|
||||||
) as execute_span:
|
|
||||||
try:
|
|
||||||
with sqlite_timelimit(conn, time_limit_ms):
|
with sqlite_timelimit(conn, time_limit_ms):
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
@ -760,20 +548,12 @@ class Database:
|
||||||
raise QueryInterrupted(e, sql, params)
|
raise QueryInterrupted(e, sql, params)
|
||||||
if log_sql_errors:
|
if log_sql_errors:
|
||||||
sys.stderr.write(
|
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()
|
sys.stderr.flush()
|
||||||
raise
|
raise
|
||||||
except QueryInterrupted as e:
|
|
||||||
if not timeout_expected:
|
|
||||||
execute_span.record_exception(e)
|
|
||||||
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
if log_sql_errors:
|
|
||||||
execute_span.record_exception(e)
|
|
||||||
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
raise
|
|
||||||
|
|
||||||
if truncate:
|
if truncate:
|
||||||
return Results(rows, truncated, cursor.description)
|
return Results(rows, truncated, cursor.description)
|
||||||
|
|
@ -781,45 +561,8 @@ class Database:
|
||||||
else:
|
else:
|
||||||
return Results(rows, False, cursor.description)
|
return Results(rows, False, cursor.description)
|
||||||
|
|
||||||
with trace( # noqa: SIM117
|
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||||
"sql", database=self.name, sql=sql.strip(), params=params
|
results = await self.execute_fn(sql_operation_in_thread)
|
||||||
):
|
|
||||||
with tracer.start_as_current_span(
|
|
||||||
DB_QUERY,
|
|
||||||
kind=DB_QUERY.kind,
|
|
||||||
record_exception=False,
|
|
||||||
set_status_on_exception=False,
|
|
||||||
) as span:
|
|
||||||
span.set_attribute(DB_SYSTEM, "sqlite")
|
|
||||||
span.set_attribute(DB_NAMESPACE, self.name)
|
|
||||||
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
|
|
||||||
span.set_attribute(TIME_LIMIT_MS, time_limit_ms)
|
|
||||||
operation_name = sql_operation_name(sql)
|
|
||||||
if operation_name:
|
|
||||||
span.set_attribute(DB_OPERATION_NAME, operation_name)
|
|
||||||
if params:
|
|
||||||
span.set_attribute(PARAM_COUNT, len(params))
|
|
||||||
try:
|
|
||||||
with record_operation_duration(self.name, "read"):
|
|
||||||
results = await self._execute_fn(sql_operation_in_thread)
|
|
||||||
except QueryInterrupted as e:
|
|
||||||
span.set_attribute(INTERRUPTED, True)
|
|
||||||
if not timeout_expected:
|
|
||||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
span.record_exception(e)
|
|
||||||
record_query_interrupted(self.name)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# log_sql_errors=False callers, such as facet suggestion,
|
|
||||||
# expect some queries to fail
|
|
||||||
if log_sql_errors:
|
|
||||||
span.record_exception(e)
|
|
||||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
||||||
else:
|
|
||||||
span.set_attribute(SQL_ERROR_SUPPRESSED, True)
|
|
||||||
raise
|
|
||||||
span.set_attribute(TRUNCATED, results.truncated)
|
|
||||||
span.set_attribute(ROWS_RETURNED, len(results.rows))
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -860,7 +603,7 @@ class Database:
|
||||||
try:
|
try:
|
||||||
table_count = (
|
table_count = (
|
||||||
await self.execute(
|
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,
|
custom_time_limit=limit,
|
||||||
)
|
)
|
||||||
).rows[0][0]
|
).rows[0][0]
|
||||||
|
|
@ -910,32 +653,17 @@ class Database:
|
||||||
)
|
)
|
||||||
return [r[0] for r in results.rows]
|
return [r[0] for r in results.rows]
|
||||||
|
|
||||||
# Named functions rather than lambdas give more useful datasette.callback
|
|
||||||
# span attributes
|
|
||||||
|
|
||||||
async def table_columns(self, table):
|
async def table_columns(self, table):
|
||||||
def _table_columns(conn):
|
return await self.execute_fn(lambda conn: table_columns(conn, table))
|
||||||
return table_columns(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_table_columns)
|
|
||||||
|
|
||||||
async def table_column_details(self, table):
|
async def table_column_details(self, table):
|
||||||
def _table_column_details(conn):
|
return await self.execute_fn(lambda conn: table_column_details(conn, table))
|
||||||
return table_column_details(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_table_column_details)
|
|
||||||
|
|
||||||
async def primary_keys(self, table):
|
async def primary_keys(self, table):
|
||||||
def _primary_keys(conn):
|
return await self.execute_fn(lambda conn: detect_primary_keys(conn, table))
|
||||||
return detect_primary_keys(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_primary_keys)
|
|
||||||
|
|
||||||
async def fts_table(self, table):
|
async def fts_table(self, table):
|
||||||
def _fts_table(conn):
|
return await self.execute_fn(lambda conn: detect_fts(conn, table))
|
||||||
return detect_fts(conn, table)
|
|
||||||
|
|
||||||
return await self.execute_fn(_fts_table)
|
|
||||||
|
|
||||||
async def label_column_for_table(self, table):
|
async def label_column_for_table(self, table):
|
||||||
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
||||||
|
|
@ -979,9 +707,9 @@ class Database:
|
||||||
column_names
|
column_names
|
||||||
and len(column_names) == 2
|
and len(column_names) == 2
|
||||||
and ("id" in column_names or "pk" in column_names)
|
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:
|
# Couldn't find a label:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1027,17 +755,6 @@ class Database:
|
||||||
|
|
||||||
return hidden_tables
|
return hidden_tables
|
||||||
|
|
||||||
async def derived_table_dependencies(self):
|
|
||||||
"""Return implementation tables and the tables they derive from."""
|
|
||||||
schema_version = (await self.execute("PRAGMA schema_version")).first()[0]
|
|
||||||
if (
|
|
||||||
self._cached_derived_table_dependencies is None
|
|
||||||
or self._cached_derived_table_dependencies[0] != schema_version
|
|
||||||
):
|
|
||||||
dependencies = await self.execute_fn(sqlite_derived_table_dependencies)
|
|
||||||
self._cached_derived_table_dependencies = (schema_version, dependencies)
|
|
||||||
return self._cached_derived_table_dependencies[1]
|
|
||||||
|
|
||||||
async def view_names(self):
|
async def view_names(self):
|
||||||
results = await self.execute("select name from sqlite_master where type='view'")
|
results = await self.execute("select name from sqlite_master where type='view'")
|
||||||
return [r[0] for r in results.rows]
|
return [r[0] for r in results.rows]
|
||||||
|
|
@ -1133,28 +850,16 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||||
|
|
||||||
class WriteTask:
|
class WriteTask:
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"block",
|
|
||||||
"enqueued_at_ns",
|
|
||||||
"fn",
|
"fn",
|
||||||
"isolated_connection",
|
|
||||||
"loop",
|
|
||||||
"otel_context",
|
|
||||||
"reply_future",
|
|
||||||
"task_id",
|
"task_id",
|
||||||
|
"loop",
|
||||||
|
"reply_future",
|
||||||
|
"isolated_connection",
|
||||||
"transaction",
|
"transaction",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self, fn, task_id, loop, reply_future, isolated_connection, transaction
|
||||||
fn,
|
|
||||||
task_id,
|
|
||||||
loop,
|
|
||||||
reply_future,
|
|
||||||
isolated_connection,
|
|
||||||
transaction,
|
|
||||||
otel_context,
|
|
||||||
enqueued_at_ns,
|
|
||||||
block,
|
|
||||||
):
|
):
|
||||||
self.fn = fn
|
self.fn = fn
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
|
|
@ -1162,9 +867,6 @@ class WriteTask:
|
||||||
self.reply_future = reply_future
|
self.reply_future = reply_future
|
||||||
self.isolated_connection = isolated_connection
|
self.isolated_connection = isolated_connection
|
||||||
self.transaction = transaction
|
self.transaction = transaction
|
||||||
self.otel_context = otel_context
|
|
||||||
self.enqueued_at_ns = enqueued_at_ns
|
|
||||||
self.block = block
|
|
||||||
|
|
||||||
|
|
||||||
def _deliver_write_result(task, result, exception):
|
def _deliver_write_result(task, result, exception):
|
||||||
|
|
@ -1193,7 +895,7 @@ class QueryInterrupted(Exception):
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"QueryInterrupted: {self.e}"
|
return "QueryInterrupted: {}".format(self.e)
|
||||||
|
|
||||||
|
|
||||||
class MultipleValues(Exception):
|
class MultipleValues(Exception):
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ from datasette import hookimpl
|
||||||
from datasette.permissions import Action
|
from datasette.permissions import Action
|
||||||
from datasette.resources import (
|
from datasette.resources import (
|
||||||
DatabaseResource,
|
DatabaseResource,
|
||||||
QueryResource,
|
|
||||||
TableResource,
|
TableResource,
|
||||||
|
QueryResource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,6 @@ import markupsafe
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.column_types import ColumnType, SQLiteType
|
from datasette.column_types import ColumnType, SQLiteType
|
||||||
|
|
||||||
_HTTP_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_http_url(value):
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
if not _HTTP_URL_RE.fullmatch(normalized):
|
|
||||||
return None
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
class UrlColumnType(ColumnType):
|
class UrlColumnType(ColumnType):
|
||||||
name = "url"
|
name = "url"
|
||||||
|
|
@ -26,10 +15,7 @@ class UrlColumnType(ColumnType):
|
||||||
async def render_cell(self, value, column, table, database, datasette, request):
|
async def render_cell(self, value, column, table, database, datasette, request):
|
||||||
if not value or not isinstance(value, str):
|
if not value or not isinstance(value, str):
|
||||||
return None
|
return None
|
||||||
normalized = _normalize_http_url(value)
|
escaped = markupsafe.escape(value.strip())
|
||||||
if normalized is None:
|
|
||||||
return markupsafe.escape(value.strip())
|
|
||||||
escaped = markupsafe.escape(normalized)
|
|
||||||
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
|
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
|
||||||
|
|
||||||
async def validate(self, value, datasette):
|
async def validate(self, value, datasette):
|
||||||
|
|
@ -37,7 +23,7 @@ class UrlColumnType(ColumnType):
|
||||||
return None
|
return None
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return "URL must be a string"
|
return "URL must be a string"
|
||||||
if _normalize_http_url(value) is None:
|
if not re.match(r"^https?://\S+$", value.strip()):
|
||||||
return "Invalid URL"
|
return "Invalid URL"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
|
from datasette import hookimpl
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
|
|
||||||
def header(key, request):
|
def header(key, request):
|
||||||
key = key.replace("_", "-").encode("utf-8")
|
key = key.replace("_", "-").encode("utf-8")
|
||||||
|
|
|
||||||
|
|
@ -17,29 +17,18 @@ UNION/INTERSECT operations. The order of evaluation is:
|
||||||
|
|
||||||
from __future__ import annotations
|
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
|
# Re-export all hooks and public utilities
|
||||||
from .restrictions import (
|
from .restrictions import (
|
||||||
actor_restrictions_sql as actor_restrictions_sql,
|
actor_restrictions_sql as actor_restrictions_sql,
|
||||||
)
|
|
||||||
from .restrictions import (
|
|
||||||
restrictions_allow_action as restrictions_allow_action,
|
restrictions_allow_action as restrictions_allow_action,
|
||||||
|
ActorRestrictions as ActorRestrictions,
|
||||||
)
|
)
|
||||||
from .root import root_user_permissions_sql as root_user_permissions_sql
|
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||||
|
from .config import config_permissions_sql as config_permissions_sql
|
||||||
|
from .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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
):
|
):
|
||||||
self.datasette = datasette
|
self.datasette = datasette
|
||||||
|
|
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
|
||||||
self.restrictions = actor.get("_r", {}) if actor else {}
|
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||||
|
|
||||||
# Pre-compute restriction info for efficiency
|
# Pre-compute restriction info for efficiency
|
||||||
self.restricted_databases: set[str] = set()
|
self.restricted_databases: Set[str] = set()
|
||||||
self.restricted_tables: set[tuple[str, str]] = set()
|
self.restricted_tables: Set[Tuple[str, str]] = set()
|
||||||
|
|
||||||
if self.has_restrictions:
|
if self.has_restrictions:
|
||||||
self.restricted_databases = {
|
self.restricted_databases = {
|
||||||
|
|
@ -92,27 +92,16 @@ class ConfigPermissionProcessor:
|
||||||
# Tables implicitly reference their parent databases
|
# Tables implicitly reference their parent databases
|
||||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
||||||
|
|
||||||
# Resolve identity keys once per action, rather than scanning the
|
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
|
||||||
# restriction allowlist for every configured table's allow block.
|
|
||||||
self.restricted_table_keys = {
|
|
||||||
(db, self.action_obj.normalize_child(table) if self.action_obj else table)
|
|
||||||
for db, table in self.restricted_tables
|
|
||||||
}
|
|
||||||
|
|
||||||
def evaluate_allow_block(self, allow_block: Any) -> bool | None:
|
|
||||||
"""Evaluate an allow block against the current actor."""
|
"""Evaluate an allow block against the current actor."""
|
||||||
if allow_block is None:
|
if allow_block is None:
|
||||||
return 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)
|
return actor_matches_allow(self.actor, allow_block)
|
||||||
|
|
||||||
def is_in_restriction_allowlist(
|
def is_in_restriction_allowlist(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if resource is allowed by actor restrictions."""
|
"""Check if resource is allowed by actor restrictions."""
|
||||||
if not self.has_restrictions:
|
if not self.has_restrictions:
|
||||||
|
|
@ -132,10 +121,8 @@ class ConfigPermissionProcessor:
|
||||||
if parent:
|
if parent:
|
||||||
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
|
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
|
||||||
if child:
|
if child:
|
||||||
child_key = (
|
table_actions = table_restrictions.get(child, [])
|
||||||
self.action_obj.normalize_child(child) if self.action_obj else child
|
if self.action_checks.intersection(table_actions):
|
||||||
)
|
|
||||||
if (parent, child_key) in self.restricted_table_keys:
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Parent query should proceed if any child in this database is allowlisted
|
# Parent query should proceed if any child in this database is allowlisted
|
||||||
|
|
@ -156,9 +143,9 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def add_permissions_rule(
|
def add_permissions_rule(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
permissions_block: dict | None,
|
permissions_block: Optional[dict],
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a rule from a permissions:{action} block."""
|
"""Add a rule from a permissions:{action} block."""
|
||||||
|
|
@ -178,8 +165,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def add_allow_block_rule(
|
def add_allow_block_rule(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
allow_block: Any,
|
allow_block: Any,
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -211,8 +198,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def _add_restriction_gate_denies(
|
def _add_restriction_gate_denies(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
is_allowed: bool,
|
is_allowed: bool,
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -244,7 +231,7 @@ class ConfigPermissionProcessor:
|
||||||
if db_name == parent:
|
if db_name == parent:
|
||||||
self.collector.add(db_name, table_name, False, reason)
|
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."""
|
"""Process all config rules and return combined PermissionSQL."""
|
||||||
self._process_root_permissions()
|
self._process_root_permissions()
|
||||||
self._process_databases()
|
self._process_databases()
|
||||||
|
|
@ -434,10 +421,10 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def config_permissions_sql(
|
async def config_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> list[PermissionSQL] | None:
|
) -> Optional[List[PermissionSQL]]:
|
||||||
"""
|
"""
|
||||||
Apply permission rules from datasette.yaml configuration.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -29,17 +29,18 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_allow_sql_check(
|
async def default_allow_sql_check(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
"""
|
"""
|
||||||
Enforce the default_allow_sql setting.
|
Enforce the default_allow_sql setting.
|
||||||
|
|
||||||
When default_allow_sql is false (the default), execute-sql is denied
|
When default_allow_sql is false (the default), execute-sql is denied
|
||||||
unless explicitly allowed by config or other rules.
|
unless explicitly allowed by config or other rules.
|
||||||
"""
|
"""
|
||||||
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
if action == "execute-sql":
|
||||||
|
if not datasette.setting("default_allow_sql"):
|
||||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
@ -47,10 +48,10 @@ async def default_allow_sql_check(
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_action_permissions_sql(
|
async def default_action_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
"""
|
"""
|
||||||
Provide default allow rules for standard view/execute actions.
|
Provide default allow rules for standard view/execute actions.
|
||||||
|
|
||||||
|
|
@ -70,10 +71,10 @@ async def default_action_permissions_sql(
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_query_permissions_sql(
|
async def default_query_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||||
|
|
||||||
if action not in {"view-query", "update-query", "delete-query"}:
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, List, Optional, Set
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||||
from datasette.permissions import PermissionSQL
|
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).
|
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
|
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."""
|
"""Check if an action (or its abbreviation) is in a list."""
|
||||||
return bool(get_action_name_variants(datasette, action).intersection(action_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:
|
class PermissionRow:
|
||||||
"""A single permission rule row."""
|
"""A single permission rule row."""
|
||||||
|
|
||||||
parent: str | None
|
parent: Optional[str]
|
||||||
child: str | None
|
child: Optional[str]
|
||||||
allow: bool
|
allow: bool
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
@ -46,14 +46,14 @@ class PermissionRowCollector:
|
||||||
"""Collects permission rows and converts them to PermissionSQL."""
|
"""Collects permission rows and converts them to PermissionSQL."""
|
||||||
|
|
||||||
def __init__(self, prefix: str = "row"):
|
def __init__(self, prefix: str = "row"):
|
||||||
self.rows: list[PermissionRow] = []
|
self.rows: List[PermissionRow] = []
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
|
|
||||||
def add(
|
def add(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
allow: bool | None,
|
allow: Optional[bool],
|
||||||
reason: str,
|
reason: str,
|
||||||
if_not_none: bool = False,
|
if_not_none: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -62,7 +62,7 @@ class PermissionRowCollector:
|
||||||
return
|
return
|
||||||
self.rows.append(PermissionRow(parent, child, allow, reason))
|
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."""
|
"""Convert collected rows to a PermissionSQL object."""
|
||||||
if not self.rows:
|
if not self.rows:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
|
||||||
class ActorRestrictions:
|
class ActorRestrictions:
|
||||||
"""Parsed actor restrictions from the _r key."""
|
"""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]}
|
database_actions: dict # _r.d - {db_name: [actions]}
|
||||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||||
|
|
||||||
@classmethod
|
@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."""
|
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
||||||
if not actor:
|
if not actor:
|
||||||
return None
|
return None
|
||||||
|
|
@ -44,11 +44,11 @@ class ActorRestrictions:
|
||||||
table_actions=restrictions.get("r", {}),
|
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."""
|
"""Check if action is in the global allowlist."""
|
||||||
return action_in_list(datasette, action, self.global_actions)
|
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."""
|
"""Get database names where this action is allowed."""
|
||||||
allowed = set()
|
allowed = set()
|
||||||
for db_name, db_actions in self.database_actions.items():
|
for db_name, db_actions in self.database_actions.items():
|
||||||
|
|
@ -57,8 +57,8 @@ class ActorRestrictions:
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
def get_allowed_tables(
|
def get_allowed_tables(
|
||||||
self, datasette: Datasette, action: str
|
self, datasette: "Datasette", action: str
|
||||||
) -> set[tuple[str, str]]:
|
) -> Set[Tuple[str, str]]:
|
||||||
"""Get (database, table) pairs where this action is allowed."""
|
"""Get (database, table) pairs where this action is allowed."""
|
||||||
allowed = set()
|
allowed = set()
|
||||||
for db_name, tables in self.table_actions.items():
|
for db_name, tables in self.table_actions.items():
|
||||||
|
|
@ -70,10 +70,10 @@ class ActorRestrictions:
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def actor_restrictions_sql(
|
async def actor_restrictions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> list[PermissionSQL] | None:
|
) -> Optional[List[PermissionSQL]]:
|
||||||
"""
|
"""
|
||||||
Handle actor restriction-based permission rules.
|
Handle actor restriction-based permission rules.
|
||||||
|
|
||||||
|
|
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
|
||||||
|
|
||||||
|
|
||||||
def restrictions_allow_action(
|
def restrictions_allow_action(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
restrictions: dict,
|
restrictions: dict,
|
||||||
action: str,
|
action: str,
|
||||||
resource: str | tuple[str, str] | None,
|
resource: Optional[str | Tuple[str, str]],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if restrictions allow the requested action on the requested resource.
|
Check if restrictions allow the requested action on the requested resource.
|
||||||
|
|
@ -185,12 +185,8 @@ def restrictions_allow_action(
|
||||||
# Check table/resource level
|
# Check table/resource level
|
||||||
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
||||||
database, table = resource
|
database, table = resource
|
||||||
action_obj = datasette.actions.get(action)
|
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
|
||||||
normalize = action_obj.normalize_child if action_obj else lambda name: name
|
if table_allowed is not None:
|
||||||
for table_name, table_allowed in (
|
|
||||||
restrictions.get("r", {}).get(database, {}).items()
|
|
||||||
):
|
|
||||||
if normalize(table_name) == normalize(table):
|
|
||||||
assert isinstance(table_allowed, list)
|
assert isinstance(table_allowed, list)
|
||||||
if to_check.intersection(table_allowed):
|
if to_check.intersection(table_allowed):
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def root_user_permissions_sql(
|
async def root_user_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
"""
|
"""
|
||||||
Grant root user full permissions when --root flag is used.
|
Grant root user full permissions when --root flag is used.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
"""Default table-access policy for SQLite optimizer statistics."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.permissions import PermissionSQL
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def permission_resources_sql(action):
|
|
||||||
if action != "view-table":
|
|
||||||
return None
|
|
||||||
return PermissionSQL(
|
|
||||||
sql="""
|
|
||||||
SELECT database_name AS parent, value AS child, 0 AS allow,
|
|
||||||
'SQLite statistics tables are denied by default' AS reason
|
|
||||||
FROM catalog_databases
|
|
||||||
CROSS JOIN json_each(:sqlite_statistics_names)
|
|
||||||
""",
|
|
||||||
params={
|
|
||||||
"sqlite_statistics_names": json.dumps(
|
|
||||||
["sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"]
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -17,13 +17,15 @@ from datasette.tokens import SignedTokenHandler
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def register_token_handler(datasette: Datasette):
|
def register_token_handler(datasette: "Datasette"):
|
||||||
"""Register the default signed token handler."""
|
"""Register the default signed token handler."""
|
||||||
return SignedTokenHandler()
|
return SignedTokenHandler()
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(specname="actor_from_request")
|
@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
|
Authenticate requests using API tokens by delegating to all registered
|
||||||
token handlers via datasette.verify_token().
|
token handlers via datasette.verify_token().
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ def table_actions(datasette, actor, database, table, request):
|
||||||
"label": "Alter table",
|
"label": "Alter table",
|
||||||
"description": "Change columns and primary key for this table.",
|
"description": "Change columns and primary key for this table.",
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"aria-label": f"Alter table {table}",
|
"aria-label": "Alter table {}".format(table),
|
||||||
"data-table-action": "alter-table",
|
"data-table-action": "alter-table",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
from abc import ABC, abstractproperty
|
from abc import ABC, abstractproperty
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from datasette.hookspecs import hookimpl
|
from datasette.hookspecs import hookimpl
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import json
|
import json
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
detect_json1,
|
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
|
detect_json1,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -31,7 +30,7 @@ def load_facet_configs(request, table_config):
|
||||||
assert (
|
assert (
|
||||||
len(facet_config.values()) == 1
|
len(facet_config.values()) == 1
|
||||||
), "Metadata config dicts should be {type: config}"
|
), "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):
|
if isinstance(facet_config, str):
|
||||||
facet_config = {"simple": facet_config}
|
facet_config = {"simple": facet_config}
|
||||||
facet_configs.setdefault(type, []).append(
|
facet_configs.setdefault(type, []).append(
|
||||||
|
|
@ -39,7 +38,7 @@ def load_facet_configs(request, table_config):
|
||||||
)
|
)
|
||||||
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
|
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
|
||||||
for key, values in qs_pairs.items():
|
for key, values in qs_pairs.items():
|
||||||
if key == "_facet" or key.startswith("_facet_"):
|
if key.startswith("_facet"):
|
||||||
# Figure out the facet type
|
# Figure out the facet type
|
||||||
if key == "_facet":
|
if key == "_facet":
|
||||||
type = "column"
|
type = "column"
|
||||||
|
|
@ -86,7 +85,7 @@ class Facet:
|
||||||
self.database = database
|
self.database = database
|
||||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||||
self.table = table
|
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.params = params or []
|
||||||
self.table_config = table_config
|
self.table_config = table_config
|
||||||
# row_count can be None, in which case we calculate it ourselves:
|
# row_count can be None, in which case we calculate it ourselves:
|
||||||
|
|
@ -161,13 +160,18 @@ class ColumnFacet(Facet):
|
||||||
for column in columns:
|
for column in columns:
|
||||||
if column in already_enabled:
|
if column in already_enabled:
|
||||||
continue
|
continue
|
||||||
suggested_facet_sql = f"""
|
suggested_facet_sql = """
|
||||||
with limited as (select * from ({self.sql}) limit {self.suggest_consider})
|
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||||
select {escape_sqlite(column)} as value, count(*) as n from limited
|
select {column} as value, count(*) as n from limited
|
||||||
where value is not null
|
where value is not null
|
||||||
group by value
|
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
|
distinct_values = None
|
||||||
try:
|
try:
|
||||||
distinct_values = await self.ds.execute(
|
distinct_values = await self.ds.execute(
|
||||||
|
|
@ -263,16 +267,11 @@ class ColumnFacet(Facet):
|
||||||
for row in facet_rows:
|
for row in facet_rows:
|
||||||
column_qs = column
|
column_qs = column
|
||||||
if column.startswith("_"):
|
if column.startswith("_"):
|
||||||
column_qs = f"{column}__exact"
|
column_qs = "{}__exact".format(column)
|
||||||
selected_args = {
|
selected = (column_qs, str(row["value"])) in qs_pairs
|
||||||
key: str(row["value"])
|
|
||||||
for key in (column_qs, f"{column}__exact")
|
|
||||||
if (key, str(row["value"])) in qs_pairs
|
|
||||||
}
|
|
||||||
selected = bool(selected_args)
|
|
||||||
if selected:
|
if selected:
|
||||||
toggle_path = path_with_removed_args(
|
toggle_path = path_with_removed_args(
|
||||||
self.request, selected_args
|
self.request, {column_qs: str(row["value"])}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
toggle_path = path_with_added_args(
|
toggle_path = path_with_added_args(
|
||||||
|
|
@ -343,12 +342,12 @@ class ArrayFacet(Facet):
|
||||||
for v in await self.ds.execute(
|
for v in await self.ds.execute(
|
||||||
self.database,
|
self.database,
|
||||||
(
|
(
|
||||||
f"select {escape_sqlite(column)} from ({self.sql}) "
|
"select {column} from ({sql}) "
|
||||||
f"where {escape_sqlite(column)} is not null "
|
"where {column} is not null "
|
||||||
f"and {escape_sqlite(column)} != '' "
|
"and {column} != '' "
|
||||||
f"and json_array_length({escape_sqlite(column)}) > 0 "
|
"and json_array_length({column}) > 0 "
|
||||||
"limit 100"
|
"limit 100"
|
||||||
),
|
).format(column=escape_sqlite(column), sql=self.sql),
|
||||||
self.params,
|
self.params,
|
||||||
truncate=False,
|
truncate=False,
|
||||||
custom_time_limit=self.ds.setting(
|
custom_time_limit=self.ds.setting(
|
||||||
|
|
@ -389,14 +388,14 @@ class ArrayFacet(Facet):
|
||||||
source = source_and_config["source"]
|
source = source_and_config["source"]
|
||||||
column = config.get("column") or config["simple"]
|
column = config.get("column") or config["simple"]
|
||||||
# https://github.com/simonw/datasette/issues/448
|
# https://github.com/simonw/datasette/issues/448
|
||||||
facet_sql = f"""
|
facet_sql = """
|
||||||
with inner as ({self.sql}),
|
with inner as ({sql}),
|
||||||
deduped_array_items as (
|
deduped_array_items as (
|
||||||
select
|
select
|
||||||
distinct j.value,
|
distinct j.value,
|
||||||
inner.*
|
inner.*
|
||||||
from
|
from
|
||||||
json_each([inner].{escape_sqlite(column)}) j
|
json_each([inner].{col}) j
|
||||||
join inner
|
join inner
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
|
|
@ -407,8 +406,12 @@ class ArrayFacet(Facet):
|
||||||
group by
|
group by
|
||||||
value
|
value
|
||||||
order by
|
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:
|
try:
|
||||||
facet_rows_results = await self.ds.execute(
|
facet_rows_results = await self.ds.execute(
|
||||||
self.database,
|
self.database,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import json
|
|
||||||
import math
|
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
from datasette.resources import DatabaseResource
|
||||||
from datasette.utils.asgi import BadRequest
|
|
||||||
from datasette.views.base import DatasetteError
|
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
|
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,20 +48,13 @@ def search_filters(request, database, table, datasette):
|
||||||
human_descriptions = []
|
human_descriptions = []
|
||||||
extra_context = {}
|
extra_context = {}
|
||||||
|
|
||||||
# Figure out which trusted fts_table to use. Query string parameters can
|
# Figure out which fts_table to use
|
||||||
# repeat this mapping (for backwards compatibility), but must not select
|
|
||||||
# a different table or primary key.
|
|
||||||
table_metadata = await datasette.table_config(database, table)
|
table_metadata = await datasette.table_config(database, table)
|
||||||
db = datasette.get_database(database)
|
db = datasette.get_database(database)
|
||||||
fts_table = table_metadata.get("fts_table")
|
fts_table = request.args.get("_fts_table")
|
||||||
|
fts_table = fts_table or table_metadata.get("fts_table")
|
||||||
fts_table = fts_table or await db.fts_table(table)
|
fts_table = fts_table or await db.fts_table(table)
|
||||||
fts_pk = table_metadata.get("fts_pk", "rowid")
|
fts_pk = request.args.get("_fts_pk", table_metadata.get("fts_pk", "rowid"))
|
||||||
requested_fts_table = request.args.get("_fts_table")
|
|
||||||
requested_fts_pk = request.args.get("_fts_pk")
|
|
||||||
if (requested_fts_table and requested_fts_table != fts_table) or (
|
|
||||||
requested_fts_pk and requested_fts_pk != fts_pk
|
|
||||||
):
|
|
||||||
raise BadRequest("Invalid _fts_table or _fts_pk")
|
|
||||||
search_args = {
|
search_args = {
|
||||||
key: request.args[key]
|
key: request.args[key]
|
||||||
for key in request.args
|
for key in request.args
|
||||||
|
|
@ -83,11 +72,6 @@ def search_filters(request, database, table, datasette):
|
||||||
extra_context["supports_search"] = bool(fts_table)
|
extra_context["supports_search"] = bool(fts_table)
|
||||||
|
|
||||||
if fts_table and search_args:
|
if fts_table and search_args:
|
||||||
await datasette.ensure_permission(
|
|
||||||
action="view-table",
|
|
||||||
resource=TableResource(database=database, table=fts_table),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
if "_search" in search_args:
|
if "_search" in search_args:
|
||||||
# Simple ?_search=xxx
|
# Simple ?_search=xxx
|
||||||
search = search_args["_search"]
|
search = search_args["_search"]
|
||||||
|
|
@ -115,9 +99,9 @@ def search_filters(request, database, table, datasette):
|
||||||
fts_table=escape_sqlite(fts_table),
|
fts_table=escape_sqlite(fts_table),
|
||||||
search_col=escape_sqlite(search_col),
|
search_col=escape_sqlite(search_col),
|
||||||
match_clause=(
|
match_clause=(
|
||||||
f":search_{i}"
|
":search_{}".format(i)
|
||||||
if search_mode_raw
|
if search_mode_raw
|
||||||
else f"escape_fts(:search_{i})"
|
else "escape_fts(:search_{})".format(i)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -148,18 +132,13 @@ def through_filters(request, database, table, datasette):
|
||||||
through_table = through_data["table"]
|
through_table = through_data["table"]
|
||||||
other_column = through_data["column"]
|
other_column = through_data["column"]
|
||||||
value = through_data["value"]
|
value = through_data["value"]
|
||||||
await datasette.ensure_permission(
|
|
||||||
action="view-table",
|
|
||||||
resource=TableResource(database=database, table=through_table),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
db = datasette.get_database(database)
|
db = datasette.get_database(database)
|
||||||
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
||||||
fk_to_us = next(
|
try:
|
||||||
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table),
|
fk_to_us = [
|
||||||
None,
|
fk for fk in outgoing_foreign_keys if fk["other_table"] == table
|
||||||
)
|
][0]
|
||||||
if fk_to_us is None:
|
except IndexError:
|
||||||
raise DatasetteError(
|
raise DatasetteError(
|
||||||
"Invalid _through - could not find corresponding foreign key"
|
"Invalid _through - could not find corresponding foreign key"
|
||||||
)
|
)
|
||||||
|
|
@ -203,17 +182,6 @@ class Filter:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
def _coerce_numeric_filter_value(value):
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
converted = float(value)
|
|
||||||
except ValueError:
|
|
||||||
return value
|
|
||||||
return converted if math.isfinite(converted) else value
|
|
||||||
|
|
||||||
|
|
||||||
class TemplatedFilter(Filter):
|
class TemplatedFilter(Filter):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -235,17 +203,13 @@ class TemplatedFilter(Filter):
|
||||||
|
|
||||||
def where_clause(self, table, column, value, param_counter):
|
def where_clause(self, table, column, value, param_counter):
|
||||||
converted = self.format.format(value)
|
converted = self.format.format(value)
|
||||||
if self.numeric:
|
if self.numeric and converted.isdigit():
|
||||||
converted = _coerce_numeric_filter_value(converted)
|
converted = int(converted)
|
||||||
if self.no_argument:
|
if self.no_argument:
|
||||||
kwargs = {"c": _quote_sqlite_identifier(column)}
|
kwargs = {"c": column}
|
||||||
converted = None
|
converted = None
|
||||||
else:
|
else:
|
||||||
kwargs = {
|
kwargs = {"c": column, "p": f"p{param_counter}", "t": table}
|
||||||
"c": _quote_sqlite_identifier(column),
|
|
||||||
"p": f"p{param_counter}",
|
|
||||||
"t": _quote_sqlite_identifier(table),
|
|
||||||
}
|
|
||||||
return self.sql_template.format(**kwargs), converted
|
return self.sql_template.format(**kwargs), converted
|
||||||
|
|
||||||
def human_clause(self, column, value):
|
def human_clause(self, column, value):
|
||||||
|
|
@ -259,14 +223,6 @@ class TemplatedFilter(Filter):
|
||||||
return template.format(c=column, v=value)
|
return template.format(c=column, v=value)
|
||||||
|
|
||||||
|
|
||||||
def _quote_sqlite_identifier(identifier):
|
|
||||||
# Preserve the historic always-quoted SQL generated by TemplatedFilter.
|
|
||||||
escaped = escape_sqlite(identifier)
|
|
||||||
if escaped == identifier:
|
|
||||||
return f'"{identifier}"'
|
|
||||||
return escaped
|
|
||||||
|
|
||||||
|
|
||||||
class InFilter(Filter):
|
class InFilter(Filter):
|
||||||
key = "in"
|
key = "in"
|
||||||
display = "in"
|
display = "in"
|
||||||
|
|
@ -308,56 +264,56 @@ class Filters:
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"exact",
|
"exact",
|
||||||
"=",
|
"=",
|
||||||
"{c} = :{p}",
|
'"{c}" = :{p}',
|
||||||
lambda c, v: "{c} = {v}" if v.isdigit() else '{c} = "{v}"',
|
lambda c, v: "{c} = {v}" if v.isdigit() else '{c} = "{v}"',
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"not",
|
"not",
|
||||||
"!=",
|
"!=",
|
||||||
"{c} != :{p}",
|
'"{c}" != :{p}',
|
||||||
lambda c, v: "{c} != {v}" if v.isdigit() else '{c} != "{v}"',
|
lambda c, v: "{c} != {v}" if v.isdigit() else '{c} != "{v}"',
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"contains",
|
"contains",
|
||||||
"contains",
|
"contains",
|
||||||
"{c} like :{p}",
|
'"{c}" like :{p}',
|
||||||
'{c} contains "{v}"',
|
'{c} contains "{v}"',
|
||||||
format="%{}%",
|
format="%{}%",
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"notcontains",
|
"notcontains",
|
||||||
"does not contain",
|
"does not contain",
|
||||||
"{c} not like :{p}",
|
'"{c}" not like :{p}',
|
||||||
'{c} does not contain "{v}"',
|
'{c} does not contain "{v}"',
|
||||||
format="%{}%",
|
format="%{}%",
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"endswith",
|
"endswith",
|
||||||
"ends with",
|
"ends with",
|
||||||
"{c} like :{p}",
|
'"{c}" like :{p}',
|
||||||
'{c} ends with "{v}"',
|
'{c} ends with "{v}"',
|
||||||
format="%{}",
|
format="%{}",
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"startswith",
|
"startswith",
|
||||||
"starts with",
|
"starts with",
|
||||||
"{c} like :{p}",
|
'"{c}" like :{p}',
|
||||||
'{c} starts with "{v}"',
|
'{c} starts with "{v}"',
|
||||||
format="{}%",
|
format="{}%",
|
||||||
),
|
),
|
||||||
TemplatedFilter("gt", ">", "{c} > :{p}", "{c} > {v}", numeric=True),
|
TemplatedFilter("gt", ">", '"{c}" > :{p}', "{c} > {v}", numeric=True),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"gte", "\u2265", "{c} >= :{p}", "{c} \u2265 {v}", numeric=True
|
"gte", "\u2265", '"{c}" >= :{p}', "{c} \u2265 {v}", numeric=True
|
||||||
),
|
),
|
||||||
TemplatedFilter("lt", "<", "{c} < :{p}", "{c} < {v}", numeric=True),
|
TemplatedFilter("lt", "<", '"{c}" < :{p}', "{c} < {v}", numeric=True),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"lte", "\u2264", "{c} <= :{p}", "{c} \u2264 {v}", numeric=True
|
"lte", "\u2264", '"{c}" <= :{p}', "{c} \u2264 {v}", numeric=True
|
||||||
),
|
),
|
||||||
TemplatedFilter("like", "like", "{c} like :{p}", '{c} like "{v}"'),
|
TemplatedFilter("like", "like", '"{c}" like :{p}', '{c} like "{v}"'),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"notlike", "not like", "{c} not like :{p}", '{c} not like "{v}"'
|
"notlike", "not like", '"{c}" not like :{p}', '{c} not like "{v}"'
|
||||||
),
|
),
|
||||||
TemplatedFilter("glob", "glob", "{c} glob :{p}", '{c} glob "{v}"'),
|
TemplatedFilter("glob", "glob", '"{c}" glob :{p}', '{c} glob "{v}"'),
|
||||||
InFilter(),
|
InFilter(),
|
||||||
NotInFilter(),
|
NotInFilter(),
|
||||||
]
|
]
|
||||||
|
|
@ -366,13 +322,13 @@ class Filters:
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"arraycontains",
|
"arraycontains",
|
||||||
"array contains",
|
"array contains",
|
||||||
""":{p} in (select value from json_each({t}.{c}))""",
|
""":{p} in (select value from json_each([{t}].[{c}]))""",
|
||||||
'{c} contains "{v}"',
|
'{c} contains "{v}"',
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"arraynotcontains",
|
"arraynotcontains",
|
||||||
"array does not contain",
|
"array does not contain",
|
||||||
""":{p} not in (select value from json_each({t}.{c}))""",
|
""":{p} not in (select value from json_each([{t}].[{c}]))""",
|
||||||
'{c} does not contain "{v}"',
|
'{c} does not contain "{v}"',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
@ -380,34 +336,36 @@ class Filters:
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
+ [
|
+ [
|
||||||
TemplatedFilter("date", "date", "date({c}) = :{p}", '"{c}" is on date {v}'),
|
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"isnull", "is null", "{c} is null", "{c} is null", no_argument=True
|
"date", "date", 'date("{c}") = :{p}', '"{c}" is on date {v}'
|
||||||
|
),
|
||||||
|
TemplatedFilter(
|
||||||
|
"isnull", "is null", '"{c}" is null', "{c} is null", no_argument=True
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"notnull",
|
"notnull",
|
||||||
"is not null",
|
"is not null",
|
||||||
"{c} is not null",
|
'"{c}" is not null',
|
||||||
"{c} is not null",
|
"{c} is not null",
|
||||||
no_argument=True,
|
no_argument=True,
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"isblank",
|
"isblank",
|
||||||
"is blank",
|
"is blank",
|
||||||
"({c} is null or {c} = '')",
|
'("{c}" is null or "{c}" = "")',
|
||||||
"{c} is blank",
|
"{c} is blank",
|
||||||
no_argument=True,
|
no_argument=True,
|
||||||
),
|
),
|
||||||
TemplatedFilter(
|
TemplatedFilter(
|
||||||
"notblank",
|
"notblank",
|
||||||
"is not blank",
|
"is not blank",
|
||||||
"({c} is not null and {c} != '')",
|
'("{c}" is not null and "{c}" != "")',
|
||||||
"{c} is not blank",
|
"{c} is not blank",
|
||||||
no_argument=True,
|
no_argument=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
_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):
|
def __init__(self, pairs):
|
||||||
self.pairs = pairs
|
self.pairs = pairs
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
|
from datasette.utils.sqlite import sqlite3
|
||||||
|
from datasette.utils import documented
|
||||||
import itertools
|
import itertools
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
|
||||||
from datasette.utils import documented
|
|
||||||
from datasette.utils.sqlite import sqlite3
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EXTRA_DATABASE_SQL",
|
"EXTRA_DATABASE_SQL",
|
||||||
"TABLES",
|
"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'
|
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
||||||
+ "\n".join(
|
+ "\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)
|
for a, b, c, content in generate_compound_rows(1001)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from datasette import Response, hookimpl
|
from datasette import hookimpl, Response
|
||||||
|
|
||||||
from .utils import add_cors_headers
|
from .utils import add_cors_headers
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,16 @@
|
||||||
import traceback
|
from datasette import hookimpl, Response
|
||||||
|
|
||||||
from markupsafe import Markup
|
|
||||||
|
|
||||||
from datasette import Response, hookimpl
|
|
||||||
|
|
||||||
from .utils import add_cors_headers, error_body
|
from .utils import add_cors_headers, error_body
|
||||||
from .utils.asgi import (
|
from .utils.asgi import (
|
||||||
Base400,
|
Base400,
|
||||||
)
|
)
|
||||||
from .views.base import DatasetteError
|
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:
|
try:
|
||||||
import ipdb as pdb # noqa: T100
|
import ipdb as pdb
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import pdb # noqa: T100
|
import pdb
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import rich
|
import rich
|
||||||
|
|
@ -59,10 +54,6 @@ def handle_exception(datasette, request, exception):
|
||||||
body = dict(info)
|
body = dict(info)
|
||||||
body.update(error_body(plain_message or message, status))
|
body.update(error_body(plain_message or message, status))
|
||||||
return Response.json(body, status=status, headers=headers)
|
return Response.json(body, status=status, headers=headers)
|
||||||
if request.path.split("?")[0].endswith(".csv"):
|
|
||||||
return Response.text(
|
|
||||||
plain_message or message, status=status, headers=headers
|
|
||||||
)
|
|
||||||
info.update(
|
info.update(
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
|
|
@ -78,7 +69,7 @@ def handle_exception(datasette, request, exception):
|
||||||
dict(
|
dict(
|
||||||
info,
|
info,
|
||||||
urls=datasette.urls,
|
urls=datasette.urls,
|
||||||
menu_links=list,
|
menu_links=lambda: [],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
status=status,
|
status=status,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from pluggy import HookimplMarker, HookspecMarker
|
from pluggy import HookimplMarker
|
||||||
|
from pluggy import HookspecMarker
|
||||||
|
|
||||||
hookspec = HookspecMarker("datasette")
|
hookspec = HookspecMarker("datasette")
|
||||||
hookimpl = HookimplMarker("datasette")
|
hookimpl = HookimplMarker("datasette")
|
||||||
|
|
@ -9,11 +10,6 @@ def startup(datasette):
|
||||||
"""Fires directly after Datasette first starts running"""
|
"""Fires directly after Datasette first starts running"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
|
||||||
def shutdown(datasette):
|
|
||||||
"""Called once when the Datasette server is shutting down"""
|
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def asgi_wrapper(datasette):
|
def asgi_wrapper(datasette):
|
||||||
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
|
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
|
||||||
|
|
@ -50,7 +46,7 @@ def extra_body_script(
|
||||||
def extra_template_vars(
|
def extra_template_vars(
|
||||||
template, database, table, columns, view_name, request, datasette
|
template, database, table, columns, view_name, request, datasette
|
||||||
):
|
):
|
||||||
"""Extra template variables to be made available to the template - can return dict, None, callable or awaitable"""
|
"""Extra template variables to be made available to the template - can return dict or callable or awaitable"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
|
detect_spatialite,
|
||||||
detect_fts,
|
detect_fts,
|
||||||
detect_primary_keys,
|
detect_primary_keys,
|
||||||
detect_spatialite,
|
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
get_all_foreign_keys,
|
get_all_foreign_keys,
|
||||||
sqlite3,
|
|
||||||
table_columns,
|
table_columns,
|
||||||
|
sqlite3,
|
||||||
)
|
)
|
||||||
|
|
||||||
HASH_BLOCK_SIZE = 1024 * 1024
|
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:
|
for hidden_table in hidden_tables:
|
||||||
if t == hidden_table or t.startswith(hidden_table):
|
if t == hidden_table or t.startswith(hidden_table):
|
||||||
table_info["hidden"] = True
|
tables[t]["hidden"] = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return tables
|
return tables
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class JumpSQL:
|
||||||
search_text: str | None = None,
|
search_text: str | None = None,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
item_type: str = "menu",
|
item_type: str = "menu",
|
||||||
) -> JumpSQL:
|
) -> "JumpSQL":
|
||||||
if search_text is None:
|
if search_text is None:
|
||||||
search_text = " ".join(
|
search_text = " ".join(
|
||||||
text for text in (label, display_name, description) if text is not None
|
text for text in (label, display_name, description) if text is not None
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import contextvars
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
|
import contextvars
|
||||||
_SQLITE_IDENTIFIER_CASE = str.maketrans(
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Context variable to track when permission checks should be skipped
|
# Context variable to track when permission checks should be skipped
|
||||||
_skip_permission_checks = contextvars.ContextVar(
|
_skip_permission_checks = contextvars.ContextVar(
|
||||||
|
|
@ -53,15 +49,6 @@ class Resource(ABC):
|
||||||
# Class-level metadata (subclasses must define these)
|
# Class-level metadata (subclasses must define these)
|
||||||
name: str = None # e.g., "table", "database", "model"
|
name: str = None # e.g., "table", "database", "model"
|
||||||
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
|
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
|
||||||
case_insensitive_child: bool = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def normalize_child(cls, child: str | None) -> str | None:
|
|
||||||
"""Return a comparison key without changing the resource's display name."""
|
|
||||||
if cls.case_insensitive_child and child is not None:
|
|
||||||
# Match SQLite NOCASE: fold ASCII only, not Unicode lower/casefold.
|
|
||||||
return child.translate(_SQLITE_IDENTIFIER_CASE)
|
|
||||||
return child
|
|
||||||
|
|
||||||
# Instance-level optional extra attributes
|
# Instance-level optional extra attributes
|
||||||
reasons: list[str] | None = None
|
reasons: list[str] | None = None
|
||||||
|
|
@ -85,8 +72,8 @@ class Resource(ABC):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return "{}(parent={!r}, child={!r})".format(
|
||||||
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})"
|
self.__class__.__name__, self.parent, self.child
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -142,6 +129,7 @@ class Resource(ABC):
|
||||||
|
|
||||||
Must return two columns: parent, child
|
Must return two columns: parent, child
|
||||||
"""
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AllowedResource(NamedTuple):
|
class AllowedResource(NamedTuple):
|
||||||
|
|
@ -159,11 +147,6 @@ class Action:
|
||||||
resource_class: type[Resource] | None = None
|
resource_class: type[Resource] | None = None
|
||||||
also_requires: str | None = None # Optional action name that must also be allowed
|
also_requires: str | None = None # Optional action name that must also be allowed
|
||||||
|
|
||||||
def normalize_child(self, child: str | None) -> str | None:
|
|
||||||
if self.resource_class is None:
|
|
||||||
return child
|
|
||||||
return self.resource_class.normalize_child(child)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def takes_parent(self) -> bool:
|
def takes_parent(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata as importlib_metadata
|
|
||||||
import importlib.resources as importlib_resources
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
|
from pprint import pprint
|
||||||
|
import sys
|
||||||
from . import hookspecs
|
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 = (
|
DEFAULT_PLUGINS = (
|
||||||
"datasette.publish.heroku",
|
"datasette.publish.heroku",
|
||||||
"datasette.publish.cloudrun",
|
"datasette.publish.cloudrun",
|
||||||
|
|
@ -18,7 +24,6 @@ DEFAULT_PLUGINS = (
|
||||||
"datasette.actor_auth_cookie",
|
"datasette.actor_auth_cookie",
|
||||||
"datasette.default_permissions",
|
"datasette.default_permissions",
|
||||||
"datasette.default_permissions.tokens",
|
"datasette.default_permissions.tokens",
|
||||||
"datasette.default_permissions.sqlite_statistics",
|
|
||||||
"datasette.default_actions",
|
"datasette.default_actions",
|
||||||
"datasette.default_column_types",
|
"datasette.default_column_types",
|
||||||
"datasette.default_magic_parameters",
|
"datasette.default_magic_parameters",
|
||||||
|
|
@ -80,7 +85,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
|
||||||
# Ensure name can be found in plugin_to_distinfo later:
|
# Ensure name can be found in plugin_to_distinfo later:
|
||||||
pm._plugin_distinfo.append((mod, distribution))
|
pm._plugin_distinfo.append((mod, distribution))
|
||||||
except importlib_metadata.PackageNotFoundError:
|
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
|
# Load default plugins
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
|
from datasette import hookimpl
|
||||||
|
import click
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from subprocess import CalledProcessError, check_call, check_output
|
from subprocess import CalledProcessError, check_call, check_output
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
from ..utils import temporary_docker_directory
|
|
||||||
from .common import (
|
from .common import (
|
||||||
add_common_publish_arguments_and_options,
|
add_common_publish_arguments_and_options,
|
||||||
fail_if_publish_binary_not_installed,
|
fail_if_publish_binary_not_installed,
|
||||||
)
|
)
|
||||||
|
from ..utils import temporary_docker_directory
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -221,7 +219,7 @@ def publish_subcommand(publish):
|
||||||
|
|
||||||
check_call(
|
check_call(
|
||||||
"gcloud builds submit --tag {}{}".format(
|
"gcloud builds submit --tag {}{}".format(
|
||||||
image_id, f" --timeout {timeout}" if timeout else ""
|
image_id, " --timeout {}".format(timeout) if timeout else ""
|
||||||
),
|
),
|
||||||
shell=True,
|
shell=True,
|
||||||
)
|
)
|
||||||
|
|
@ -233,7 +231,7 @@ def publish_subcommand(publish):
|
||||||
("--min-instances", min_instances),
|
("--min-instances", min_instances),
|
||||||
):
|
):
|
||||||
if value is not None:
|
if value is not None:
|
||||||
extra_deploy_options.append(f"{option} {value}")
|
extra_deploy_options.append("{} {}".format(option, value))
|
||||||
check_call(
|
check_call(
|
||||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||||
image_id,
|
image_id,
|
||||||
|
|
@ -260,16 +258,24 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
describe_cmd = (
|
describe_cmd = (
|
||||||
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} "
|
"gcloud artifacts repositories describe {repo} --project {project} "
|
||||||
f"--location {artifact_region} --quiet"
|
"--location {location} --quiet"
|
||||||
|
).format(
|
||||||
|
repo=artifact_repository,
|
||||||
|
project=artifact_project,
|
||||||
|
location=artifact_region,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
check_call(describe_cmd, shell=True)
|
check_call(describe_cmd, shell=True)
|
||||||
return
|
return
|
||||||
except CalledProcessError:
|
except CalledProcessError:
|
||||||
create_cmd = (
|
create_cmd = (
|
||||||
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker "
|
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
||||||
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet'
|
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
||||||
|
).format(
|
||||||
|
repo=artifact_repository,
|
||||||
|
location=artifact_region,
|
||||||
|
project=artifact_project,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
check_call(create_cmd, shell=True)
|
check_call(create_cmd, shell=True)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
|
from ..utils import StaticMount
|
||||||
|
import click
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from ..utils import StaticMount
|
|
||||||
|
|
||||||
|
|
||||||
def add_common_publish_arguments_and_options(subcommand):
|
def add_common_publish_arguments_and_options(subcommand):
|
||||||
for decorator in reversed(
|
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"""
|
"""Exit (with error message) if ``binary` isn't installed"""
|
||||||
if not shutil.which(binary):
|
if not shutil.which(binary):
|
||||||
click.secho(
|
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",
|
bg="red",
|
||||||
fg="white",
|
fg="white",
|
||||||
bold=True,
|
bold=True,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datasette import hookimpl
|
||||||
|
import click
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from subprocess import call, check_output
|
from subprocess import call, check_output
|
||||||
|
import tempfile
|
||||||
import click
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
add_common_publish_arguments_and_options,
|
add_common_publish_arguments_and_options,
|
||||||
fail_if_publish_binary_not_installed,
|
fail_if_publish_binary_not_installed,
|
||||||
)
|
)
|
||||||
|
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -236,7 +234,7 @@ def temporary_heroku_directory(
|
||||||
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
||||||
|
|
||||||
quoted_files = " ".join(
|
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(
|
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)
|
quoted_files=quoted_files, extras=" ".join(extras)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from datasette.extras import extra_names_from_request
|
from datasette.extras import extra_names_from_request
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
CustomJSONEncoder,
|
|
||||||
error_body,
|
error_body,
|
||||||
path_from_row_pks,
|
|
||||||
remove_infinites,
|
|
||||||
sqlite3,
|
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
|
remove_infinites,
|
||||||
|
CustomJSONEncoder,
|
||||||
|
path_from_row_pks,
|
||||||
|
sqlite3,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ class TableResource(Resource):
|
||||||
|
|
||||||
name = "table"
|
name = "table"
|
||||||
parent_class = DatabaseResource
|
parent_class = DatabaseResource
|
||||||
case_insensitive_child = True
|
|
||||||
|
|
||||||
def __init__(self, database: str, table: str):
|
def __init__(self, database: str, table: str):
|
||||||
super().__init__(parent=database, child=table)
|
super().__init__(parent=database, child=table)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
|
@ -1,74 +0,0 @@
|
||||||
import { EditorView, basicSetup } from "codemirror";
|
|
||||||
import { keymap } from "@codemirror/view";
|
|
||||||
import { sql, SQLDialect } from "@codemirror/lang-sql";
|
|
||||||
|
|
||||||
// A variation of SQLite from lang-sql https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
|
|
||||||
const SQLite = SQLDialect.define({
|
|
||||||
// Based on https://www.sqlite.org/lang_keywords.html based on likely keywords to be used in select queries
|
|
||||||
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
|
|
||||||
keywords:
|
|
||||||
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
|
|
||||||
// https://www.sqlite.org/datatype3.html
|
|
||||||
types: "null integer real text blob",
|
|
||||||
builtin: "",
|
|
||||||
operatorChars: "*+-%<>!=&|/~",
|
|
||||||
identifierQuotes: '`"',
|
|
||||||
specialVar: "@:?$",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Utility function from https://codemirror.net/docs/migration/
|
|
||||||
export function editorFromTextArea(textarea, conf = {}) {
|
|
||||||
// This could also be configured with a set of tables and columns for better autocomplete:
|
|
||||||
// https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables
|
|
||||||
let view = new EditorView({
|
|
||||||
doc: textarea.value,
|
|
||||||
extensions: [
|
|
||||||
keymap.of([
|
|
||||||
{
|
|
||||||
key: "Shift-Enter",
|
|
||||||
run: function () {
|
|
||||||
textarea.value = view.state.doc.toString();
|
|
||||||
textarea.form.submit();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Meta-Enter",
|
|
||||||
run: function () {
|
|
||||||
textarea.value = view.state.doc.toString();
|
|
||||||
textarea.form.submit();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
// This has to be after the keymap or else the basicSetup keys will prevent
|
|
||||||
// Meta-Enter from running
|
|
||||||
basicSetup,
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
sql({
|
|
||||||
dialect: SQLite,
|
|
||||||
schema: conf.schema,
|
|
||||||
tables: conf.tables,
|
|
||||||
defaultTableName: conf.defaultTableName,
|
|
||||||
defaultSchemaName: conf.defaultSchemaName,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
|
|
||||||
// Using CSS resize: both and scheduling a measurement when the element changes.
|
|
||||||
let editorDOM = view.contentDOM.closest(".cm-editor");
|
|
||||||
let observer = new ResizeObserver(function () {
|
|
||||||
view.requestMeasure();
|
|
||||||
});
|
|
||||||
observer.observe(editorDOM, { attributes: true });
|
|
||||||
|
|
||||||
textarea.parentNode.insertBefore(view.dom, textarea);
|
|
||||||
textarea.style.display = "none";
|
|
||||||
if (textarea.form) {
|
|
||||||
textarea.form.addEventListener("submit", () => {
|
|
||||||
textarea.value = view.state.doc.toString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
1
datasette/static/cm-editor.bundle.js
Normal file
1
datasette/static/cm-editor.bundle.js
Normal file
File diff suppressed because one or more lines are too long
51
datasette/static/cm-editor.js
Normal file
51
datasette/static/cm-editor.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// IIFE entry point for Datasette's own SQL editor pages. Built as
|
||||||
|
// cm-editor.bundle.js (global name `cm`) and included by _codemirror.html.
|
||||||
|
//
|
||||||
|
// This is a thin consumer of the datasette-sql-editor.js primitives so there is
|
||||||
|
// a single CodeMirror implementation. rollup inlines the shared module into this
|
||||||
|
// bundle.
|
||||||
|
import { createSqlEditor, SQLiteDialect } from "./datasette-sql-editor.js";
|
||||||
|
|
||||||
|
// Re-exported so plugins/pages using the IIFE global can reach the curated
|
||||||
|
// dialect (cm.SQLiteDialect) without a second CodeMirror instance.
|
||||||
|
export { SQLiteDialect };
|
||||||
|
|
||||||
|
// Utility function from https://codemirror.net/docs/migration/. Wraps a textarea
|
||||||
|
// with a CodeMirror SQL editor, mirroring the textarea's value back on submit.
|
||||||
|
// Returns the EditorView (with an added updateSchema method) for backwards
|
||||||
|
// compatibility with existing callers (window.editor).
|
||||||
|
export function editorFromTextArea(textarea, conf = {}) {
|
||||||
|
const submit = (view) => {
|
||||||
|
textarea.value = view.state.doc.toString();
|
||||||
|
textarea.form.submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = createSqlEditor(null, {
|
||||||
|
doc: textarea.value,
|
||||||
|
schema: conf.schema,
|
||||||
|
defaultTable: conf.defaultTable,
|
||||||
|
defaultSchema: conf.defaultSchema,
|
||||||
|
onSubmit: submit,
|
||||||
|
});
|
||||||
|
const view = handle.view;
|
||||||
|
|
||||||
|
// Preserve the historical public surface: callers use view.updateSchema(conf).
|
||||||
|
view.updateSchema = handle.updateSchema;
|
||||||
|
|
||||||
|
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
|
||||||
|
// Using CSS resize: both and scheduling a measurement when the element changes.
|
||||||
|
let editorDOM = view.contentDOM.closest(".cm-editor");
|
||||||
|
let observer = new ResizeObserver(function () {
|
||||||
|
view.requestMeasure();
|
||||||
|
});
|
||||||
|
observer.observe(editorDOM, { attributes: true });
|
||||||
|
|
||||||
|
textarea.parentNode.insertBefore(view.dom, textarea);
|
||||||
|
textarea.style.display = "none";
|
||||||
|
if (textarea.form) {
|
||||||
|
textarea.form.addEventListener("submit", () => {
|
||||||
|
textarea.value = view.state.doc.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
let columnChooserInstanceCounter = 0;
|
|
||||||
|
|
||||||
class ColumnChooser extends HTMLElement {
|
class ColumnChooser extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.titleId = `column-chooser-title-${++columnChooserInstanceCounter}`;
|
this.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
// State
|
// State
|
||||||
this._items = [];
|
this._items = [];
|
||||||
|
|
@ -28,60 +26,375 @@ class ColumnChooser extends HTMLElement {
|
||||||
// Bound handlers
|
// Bound handlers
|
||||||
this._onMove = this._onMove.bind(this);
|
this._onMove = this._onMove.bind(this);
|
||||||
this._onUp = this._onUp.bind(this);
|
this._onUp = this._onUp.bind(this);
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
--ink: #0f0f0f;
|
||||||
|
--paper: #eef6ff;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--rule: #d8e6f5;
|
||||||
|
--accent: #1a56db;
|
||||||
|
--accent-light: #e8effd;
|
||||||
|
--card: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
if (this._modal) return;
|
|
||||||
this.innerHTML = `
|
dialog {
|
||||||
<datasette-modal><dialog aria-labelledby="${this.titleId}">
|
border: none;
|
||||||
|
border-radius: var(--modal-border-radius, 0.75rem);
|
||||||
|
padding: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: min(640px, calc(100vh - 32px));
|
||||||
|
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||||
|
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--card);
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog[open] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: min(640px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||||
|
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-meta {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--paper);
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar {
|
||||||
|
padding: 6px 24px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar button {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 10px;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.list-toolbar button:hover { background: var(--accent); color: white; }
|
||||||
|
|
||||||
|
.list-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-wrap::before,
|
||||||
|
.list-wrap::after {
|
||||||
|
content: '';
|
||||||
|
position: sticky;
|
||||||
|
display: block;
|
||||||
|
left: 0; right: 0;
|
||||||
|
height: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.list-wrap::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
|
||||||
|
}
|
||||||
|
.list-wrap::after {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-zone {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0;
|
||||||
|
height: 72px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.scroll-zone-top { top: 0; }
|
||||||
|
.scroll-zone-bot { bottom: 0; }
|
||||||
|
|
||||||
|
.drag-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: grab;
|
||||||
|
color: #c8c4bc;
|
||||||
|
touch-action: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover { color: var(--accent); }
|
||||||
|
.drag-handle svg { pointer-events: none; display: block; }
|
||||||
|
|
||||||
|
.drag-item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-check input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 48px;
|
||||||
|
padding-right: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item.is-dragging {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-indicator {
|
||||||
|
position: absolute;
|
||||||
|
left: 48px;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 99px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 20;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.drop-indicator.top { top: -1px; display: block; }
|
||||||
|
.drop-indicator.bottom { bottom: -1px; display: block; }
|
||||||
|
|
||||||
|
.drag-ghost {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1.5px solid var(--accent-light);
|
||||||
|
opacity: 0.97;
|
||||||
|
will-change: transform;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-pulse {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.scroll-pulse.top { top: 8px; }
|
||||||
|
.scroll-pulse.bot { bottom: 8px; }
|
||||||
|
.scroll-pulse.active {
|
||||||
|
opacity: 0.18;
|
||||||
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
|
||||||
|
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
flex: 1;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 9px 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #1448c0; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--rule); color: var(--ink); }
|
||||||
|
|
||||||
|
.list-wrap::-webkit-scrollbar { width: 5px; }
|
||||||
|
.list-wrap::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
|
||||||
|
|
||||||
|
input, textarea { -webkit-user-select: auto; user-select: auto; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<dialog aria-labelledby="modalTitle">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="modal-title" id="${this.titleId}">Choose columns</span>
|
<span class="modal-title" id="modalTitle">Choose columns</span>
|
||||||
<span class="modal-meta"></span>
|
<span class="modal-meta" id="selectedCount"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-toolbar">
|
<div class="list-toolbar">
|
||||||
<button class="select-all">Select all</button>
|
<button id="selectAllBtn">Select all</button>
|
||||||
<button class="deselect-all">Deselect all</button>
|
<button id="deselectAllBtn">Deselect all</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body list-wrap">
|
<div class="list-wrap" id="listWrap">
|
||||||
<div class="scroll-pulse top"></div>
|
<div class="scroll-pulse top" id="pulseTop"></div>
|
||||||
<div class="scroll-pulse bot"></div>
|
<div class="scroll-pulse bot" id="pulseBot"></div>
|
||||||
<ul class="drag-list"></ul>
|
<ul class="drag-list" id="dragList"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="footer-info"></span>
|
<span class="footer-info" id="footerInfo"></span>
|
||||||
<button class="modal-btn modal-btn-ghost">Cancel</button>
|
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
|
||||||
<button class="modal-btn modal-btn-primary">Apply</button>
|
<button class="btn btn-primary" id="applyBtn">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</dialog></datasette-modal>
|
</dialog>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
this._modal = this.querySelector("datasette-modal");
|
this._dialog = this.shadowRoot.querySelector("dialog");
|
||||||
this._listWrap = this.querySelector(".list-wrap");
|
this._listWrap = this.shadowRoot.getElementById("listWrap");
|
||||||
this._dragList = this.querySelector(".drag-list");
|
this._dragList = this.shadowRoot.getElementById("dragList");
|
||||||
this._pulseTop = this.querySelector(".scroll-pulse.top");
|
this._pulseTop = this.shadowRoot.getElementById("pulseTop");
|
||||||
this._pulseBot = this.querySelector(".scroll-pulse.bot");
|
this._pulseBot = this.shadowRoot.getElementById("pulseBot");
|
||||||
this._selectAllBtn = this.querySelector(".select-all");
|
this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn");
|
||||||
this._deselectAllBtn = this.querySelector(".deselect-all");
|
this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn");
|
||||||
this._cancelBtn = this.querySelector(".modal-btn-ghost");
|
this._cancelBtn = this.shadowRoot.getElementById("cancelBtn");
|
||||||
this._applyBtn = this.querySelector(".modal-btn-primary");
|
this._applyBtn = this.shadowRoot.getElementById("applyBtn");
|
||||||
this._countEl = this.querySelector(".modal-meta");
|
this._countEl = this.shadowRoot.getElementById("selectedCount");
|
||||||
this._footerEl = this.querySelector(".footer-info");
|
this._footerEl = this.shadowRoot.getElementById("footerInfo");
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
this._selectAllBtn.addEventListener("click", () => this._selectAll());
|
this._selectAllBtn.addEventListener("click", () => this._selectAll());
|
||||||
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
|
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
|
||||||
this._cancelBtn.addEventListener("click", () =>
|
this._cancelBtn.addEventListener("click", () => this._close());
|
||||||
this._modal.requestClose("cancel"),
|
|
||||||
);
|
|
||||||
this._applyBtn.addEventListener("click", () => this._apply());
|
this._applyBtn.addEventListener("click", () => this._apply());
|
||||||
this._modal.beforeClose = () => {
|
this._dialog.addEventListener("click", (e) => {
|
||||||
this._items = this._savedItems ? [...this._savedItems] : this._items;
|
if (e.target === this._dialog) this._close();
|
||||||
this._checked = this._savedChecked
|
});
|
||||||
? new Set(this._savedChecked)
|
this._dialog.addEventListener("cancel", (e) => {
|
||||||
: this._checked;
|
e.preventDefault();
|
||||||
return true;
|
this._close();
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -101,11 +414,19 @@ class ColumnChooser extends HTMLElement {
|
||||||
this._savedChecked = new Set(this._checked);
|
this._savedChecked = new Set(this._checked);
|
||||||
|
|
||||||
this._render();
|
this._render();
|
||||||
this._modal.show();
|
this._dialog.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal methods ──
|
// ── Internal methods ──
|
||||||
|
|
||||||
|
_close() {
|
||||||
|
this._items = this._savedItems ? [...this._savedItems] : this._items;
|
||||||
|
this._checked = this._savedChecked
|
||||||
|
? new Set(this._savedChecked)
|
||||||
|
: this._checked;
|
||||||
|
this._dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
_selectAll() {
|
_selectAll() {
|
||||||
this._items.forEach((col) => this._checked.add(col));
|
this._items.forEach((col) => this._checked.add(col));
|
||||||
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||||
|
|
@ -124,7 +445,7 @@ class ColumnChooser extends HTMLElement {
|
||||||
|
|
||||||
_apply() {
|
_apply() {
|
||||||
const selected = this._items.filter((col) => this._checked.has(col));
|
const selected = this._items.filter((col) => this._checked.has(col));
|
||||||
this._modal.close();
|
this._dialog.close();
|
||||||
if (this._onApply) {
|
if (this._onApply) {
|
||||||
this._onApply(selected);
|
this._onApply(selected);
|
||||||
}
|
}
|
||||||
|
|
@ -151,13 +472,11 @@ class ColumnChooser extends HTMLElement {
|
||||||
<span class="drag-item-check">
|
<span class="drag-item-check">
|
||||||
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
|
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
|
||||||
</span>
|
</span>
|
||||||
<span class="drag-item-label"></span>
|
<span class="drag-item-label">${col}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="drop-indicator"></div>
|
<div class="drop-indicator"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
li.querySelector(".drag-item-label").textContent = col;
|
|
||||||
|
|
||||||
li.querySelector("input").addEventListener("change", (e) => {
|
li.querySelector("input").addEventListener("change", (e) => {
|
||||||
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
|
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
|
||||||
this._updateCounts();
|
this._updateCounts();
|
||||||
|
|
@ -190,7 +509,7 @@ class ColumnChooser extends HTMLElement {
|
||||||
this._ghostOffX = e.clientX - rect.left;
|
this._ghostOffX = e.clientX - rect.left;
|
||||||
this._ghostOffY = e.clientY - rect.top;
|
this._ghostOffY = e.clientY - rect.top;
|
||||||
|
|
||||||
// Keep the drag preview inside the dialog so it stays above the backdrop.
|
// Build ghost inside shadow DOM
|
||||||
this._ghost = document.createElement("div");
|
this._ghost = document.createElement("div");
|
||||||
this._ghost.className = "drag-ghost";
|
this._ghost.className = "drag-ghost";
|
||||||
this._ghost.style.width = rect.width + "px";
|
this._ghost.style.width = rect.width + "px";
|
||||||
|
|
@ -199,7 +518,7 @@ class ColumnChooser extends HTMLElement {
|
||||||
this._ghost.querySelector(".drop-indicator")?.remove();
|
this._ghost.querySelector(".drop-indicator")?.remove();
|
||||||
const h = this._ghost.querySelector(".drag-handle");
|
const h = this._ghost.querySelector(".drag-handle");
|
||||||
if (h) h.style.color = "var(--accent)";
|
if (h) h.style.color = "var(--accent)";
|
||||||
this._modal.dialog.appendChild(this._ghost);
|
this.shadowRoot.appendChild(this._ghost);
|
||||||
|
|
||||||
srcEl.classList.add("is-dragging");
|
srcEl.classList.add("is-dragging");
|
||||||
this._positionGhost(e.clientX, e.clientY);
|
this._positionGhost(e.clientX, e.clientY);
|
||||||
|
|
|
||||||
1
datasette/static/datasette-sql-editor.bundle.js
Normal file
1
datasette/static/datasette-sql-editor.bundle.js
Normal file
File diff suppressed because one or more lines are too long
675
datasette/static/datasette-sql-editor.js
Normal file
675
datasette/static/datasette-sql-editor.js
Normal file
|
|
@ -0,0 +1,675 @@
|
||||||
|
// datasette-sql-editor: ESM primitives for embedding Datasette's SQL editor.
|
||||||
|
//
|
||||||
|
// This is the single source of truth for Datasette's CodeMirror setup. The IIFE
|
||||||
|
// entry point (cm-editor.js, served as cm-editor.bundle.js for Datasette's own
|
||||||
|
// pages) is a thin consumer of these primitives, and plugin authors can import
|
||||||
|
// this module directly from /-/static/datasette-sql-editor.js to get a SQL
|
||||||
|
// editor that shares ONE CodeMirror instance per page (no duplicate
|
||||||
|
// @codemirror/state bug).
|
||||||
|
//
|
||||||
|
// Built by rollup.config.mjs into datasette-sql-editor.bundle.js.
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
highlightActiveLineGutter,
|
||||||
|
highlightSpecialChars,
|
||||||
|
drawSelection,
|
||||||
|
dropCursor,
|
||||||
|
rectangularSelection,
|
||||||
|
crosshairCursor,
|
||||||
|
highlightActiveLine,
|
||||||
|
tooltips,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { EditorState, Compartment, Annotation, Prec } from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
foldGutter,
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
defaultHighlightStyle,
|
||||||
|
bracketMatching,
|
||||||
|
foldKeymap,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { history, defaultKeymap, historyKeymap } from "@codemirror/commands";
|
||||||
|
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
||||||
|
import {
|
||||||
|
closeBrackets,
|
||||||
|
autocompletion,
|
||||||
|
closeBracketsKeymap,
|
||||||
|
completionKeymap,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import { lintKeymap } from "@codemirror/lint";
|
||||||
|
import { sql, SQLDialect } from "@codemirror/lang-sql";
|
||||||
|
|
||||||
|
// A curated variation of SQLite from lang-sql:
|
||||||
|
// https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
|
||||||
|
export const SQLiteDialect = SQLDialect.define({
|
||||||
|
// Based on https://www.sqlite.org/lang_keywords.html, restricted to likely
|
||||||
|
// keywords used in select queries.
|
||||||
|
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
|
||||||
|
keywords:
|
||||||
|
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
|
||||||
|
// https://www.sqlite.org/datatype3.html
|
||||||
|
types: "null integer real text blob",
|
||||||
|
builtin: "",
|
||||||
|
operatorChars: "*+-%<>!=&|/~",
|
||||||
|
identifierQuotes: '`"',
|
||||||
|
specialVar: "@:?$",
|
||||||
|
caseInsensitiveIdentifiers: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Annotation used to tag host-originated changes (e.g. a ProseMirror/collab host
|
||||||
|
// pushing edits into the editor, or the exported `value` setter). Changes tagged
|
||||||
|
// with this annotation do NOT re-fire onChange, so hosts can suppress the echo of
|
||||||
|
// their own edits. Mirrors datasette-paper's `fromPM` pattern.
|
||||||
|
export const hostChange = Annotation.define();
|
||||||
|
|
||||||
|
// Builds the sql() language extension from a {schema, defaultTable, defaultSchema}
|
||||||
|
// conf object. Undefined fields are fine - lang-sql ignores them.
|
||||||
|
function sqlExtension(conf = {}) {
|
||||||
|
return sql({
|
||||||
|
dialect: SQLiteDialect,
|
||||||
|
schema: conf.schema,
|
||||||
|
defaultTable: conf.defaultTable,
|
||||||
|
defaultSchema: conf.defaultSchema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replicates codemirror's basicSetup (node_modules/codemirror/dist/index.js) as a
|
||||||
|
// plain array so we can drop the undo history when `withHistory` is false. When
|
||||||
|
// history is off we optionally forward Mod-z / Mod-y / Mod-Shift-z to the host so
|
||||||
|
// an external undo stack (ProseMirror, collab) can own undo/redo.
|
||||||
|
function baseSetup(withHistory, onHostUndo, onHostRedo) {
|
||||||
|
const setup = [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
highlightSpecialChars(),
|
||||||
|
foldGutter(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
EditorState.allowMultipleSelections.of(true),
|
||||||
|
indentOnInput(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
bracketMatching(),
|
||||||
|
closeBrackets(),
|
||||||
|
autocompletion(),
|
||||||
|
rectangularSelection(),
|
||||||
|
crosshairCursor(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
];
|
||||||
|
const bindings = [
|
||||||
|
...closeBracketsKeymap,
|
||||||
|
...defaultKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
...foldKeymap,
|
||||||
|
...completionKeymap,
|
||||||
|
...lintKeymap,
|
||||||
|
];
|
||||||
|
if (withHistory) {
|
||||||
|
setup.push(history());
|
||||||
|
bindings.push(...historyKeymap);
|
||||||
|
} else {
|
||||||
|
if (onHostUndo) {
|
||||||
|
bindings.push({
|
||||||
|
key: "Mod-z",
|
||||||
|
preventDefault: true,
|
||||||
|
run: () => {
|
||||||
|
onHostUndo();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onHostRedo) {
|
||||||
|
bindings.push(
|
||||||
|
{
|
||||||
|
key: "Mod-y",
|
||||||
|
mac: "Mod-Shift-z",
|
||||||
|
preventDefault: true,
|
||||||
|
run: () => {
|
||||||
|
onHostRedo();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Mod-Shift-z",
|
||||||
|
preventDefault: true,
|
||||||
|
run: () => {
|
||||||
|
onHostRedo();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setup.push(keymap.of(bindings));
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSqlEditor(parent, opts) -> handle
|
||||||
|
//
|
||||||
|
// opts:
|
||||||
|
// doc initial document string (default "")
|
||||||
|
// schema lang-sql SQLNamespace for autocomplete
|
||||||
|
// defaultTable unqualified-column default table
|
||||||
|
// defaultSchema default schema/attached-database name
|
||||||
|
// history include CM undo history (default true); false forwards
|
||||||
|
// undo/redo to onHostUndo/onHostRedo
|
||||||
|
// onHostUndo called on Mod-z when history is false
|
||||||
|
// onHostRedo called on Mod-y / Mod-Shift-z when history is false
|
||||||
|
// extensions extra CodeMirror extensions to append (default [])
|
||||||
|
// fixedTooltips use position:"fixed" tooltips for overflow-clipped containers
|
||||||
|
// onChange called (update) on user edits; host-annotated changes are
|
||||||
|
// suppressed
|
||||||
|
// onSubmit called (view) on Mod-Enter / Shift-Enter (highest precedence)
|
||||||
|
// onEscape called (view) on Escape
|
||||||
|
// lineWrapping soft-wrap long lines (default true)
|
||||||
|
//
|
||||||
|
// handle: {view, updateSchema(conf), destroy(), get value(), set value(v)}
|
||||||
|
export function createSqlEditor(parent, opts = {}) {
|
||||||
|
const {
|
||||||
|
doc = "",
|
||||||
|
schema,
|
||||||
|
defaultTable,
|
||||||
|
defaultSchema,
|
||||||
|
history: withHistory = true,
|
||||||
|
onHostUndo,
|
||||||
|
onHostRedo,
|
||||||
|
extensions = [],
|
||||||
|
fixedTooltips = false,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onEscape,
|
||||||
|
lineWrapping = true,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const sqlCompartment = new Compartment();
|
||||||
|
|
||||||
|
// Highest-precedence keymap so submit/escape win over the basic keymap.
|
||||||
|
const priorityBindings = [];
|
||||||
|
if (onSubmit) {
|
||||||
|
const runSubmit = () => {
|
||||||
|
onSubmit(view);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
priorityBindings.push(
|
||||||
|
{ key: "Mod-Enter", run: runSubmit },
|
||||||
|
{ key: "Shift-Enter", run: runSubmit },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (onEscape) {
|
||||||
|
priorityBindings.push({
|
||||||
|
key: "Escape",
|
||||||
|
run: () => {
|
||||||
|
onEscape(view);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorExtensions = [
|
||||||
|
Prec.highest(keymap.of(priorityBindings)),
|
||||||
|
...baseSetup(withHistory, onHostUndo, onHostRedo),
|
||||||
|
lineWrapping ? EditorView.lineWrapping : [],
|
||||||
|
fixedTooltips ? tooltips({ position: "fixed" }) : [],
|
||||||
|
sqlCompartment.of(sqlExtension({ schema, defaultTable, defaultSchema })),
|
||||||
|
onChange
|
||||||
|
? EditorView.updateListener.of((update) => {
|
||||||
|
if (!update.docChanged) return;
|
||||||
|
// Suppress echoes of host-originated changes.
|
||||||
|
if (update.transactions.some((tr) => tr.annotation(hostChange))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(update);
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
...extensions,
|
||||||
|
];
|
||||||
|
|
||||||
|
let view = new EditorView({
|
||||||
|
doc,
|
||||||
|
extensions: editorExtensions,
|
||||||
|
...(parent ? { parent } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
// Swap out the schema/defaultTable/defaultSchema used for autocomplete after
|
||||||
|
// the editor has been created.
|
||||||
|
// https://codemirror.net/examples/config/#dynamic-configuration
|
||||||
|
updateSchema(conf) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: sqlCompartment.reconfigure(sqlExtension(conf)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
view.destroy();
|
||||||
|
},
|
||||||
|
get value() {
|
||||||
|
return view.state.doc.toString();
|
||||||
|
},
|
||||||
|
// Host-originated: tagged with hostChange so it does not re-fire onChange.
|
||||||
|
set value(newValue) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: newValue },
|
||||||
|
annotations: hostChange.of(true),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps ticket 05's neutral editor-schema shape
|
||||||
|
// {tables: [{name, view: bool, columns: [{name, type}]}]}
|
||||||
|
// to a lang-sql SQLNamespace of Completion objects. Kept identical to
|
||||||
|
// _editor_schema() / _column_completion() in datasette/views/query_helpers.py so
|
||||||
|
// server-inlined and client-fetched schemas behave the same.
|
||||||
|
function columnCompletion(name, type) {
|
||||||
|
const completion = { label: name, type: "property", boost: 10 };
|
||||||
|
if (type) {
|
||||||
|
completion.detail = type;
|
||||||
|
}
|
||||||
|
return completion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schemaFromTables(tables) {
|
||||||
|
const schema = {};
|
||||||
|
for (const table of tables || []) {
|
||||||
|
const completions = (table.columns || []).map((column) =>
|
||||||
|
columnCompletion(column.name, column.type),
|
||||||
|
);
|
||||||
|
if (table.view) {
|
||||||
|
schema[table.name] = {
|
||||||
|
self: { label: table.name, type: "class", detail: "view" },
|
||||||
|
children: completions,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
schema[table.name] = completions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// datasetteSchema(baseUrl, database) -> Promise<SQLNamespace>
|
||||||
|
//
|
||||||
|
// Fetches GET {baseUrl}/{database}/-/editor-schema.json (ticket 05) and maps the
|
||||||
|
// neutral payload to a lang-sql SQLNamespace ready to pass as opts.schema /
|
||||||
|
// updateSchema({schema}). baseUrl is Datasette's base_url (may be "" or "/" or a
|
||||||
|
// mount prefix). Throws a descriptive Error on a non-200 response.
|
||||||
|
export async function datasetteSchema(baseUrl, database) {
|
||||||
|
const base = (baseUrl || "").replace(/\/+$/, "");
|
||||||
|
const url = `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
|
||||||
|
const response = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`datasetteSchema: failed to fetch ${url} (${response.status} ${response.statusText})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return schemaFromTables(data.tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOnlyState(ro) -> extensions that toggle editability. EditorState.readOnly
|
||||||
|
// blocks document edits; EditorView.editable additionally drops the
|
||||||
|
// contenteditable attribute so screen readers and the cursor reflect the
|
||||||
|
// read-only state.
|
||||||
|
function readOnlyState(ro) {
|
||||||
|
return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme that plumbs a small set of CSS custom properties into the editor so
|
||||||
|
// embedders can restyle without reaching into CodeMirror internals. The fallbacks
|
||||||
|
// reproduce Datasette's current editor appearance (monospace family, the
|
||||||
|
// page-inherited font size, a transparent background over the page/plugin
|
||||||
|
// background) so mounting the element on an existing Datasette page is visually a
|
||||||
|
// no-op — including in dark mode, which Datasette implements entirely through the
|
||||||
|
// surrounding page's colors, not editor-specific CSS. CodeMirror themes are static
|
||||||
|
// CSS-in-JS, but var() references pass straight through to the generated rules and
|
||||||
|
// resolve at render time, so an embedder's :root / @media (prefers-color-scheme)
|
||||||
|
// overrides just work.
|
||||||
|
const sqlEditorTheme = EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
fontSize: "var(--datasette-sql-editor-font-size, inherit)",
|
||||||
|
background: "var(--datasette-sql-editor-bg, transparent)",
|
||||||
|
},
|
||||||
|
".cm-content, .cm-gutters": {
|
||||||
|
fontFamily: "var(--datasette-sql-editor-font-family, monospace)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// <datasette-sql-editor> — a form-associated, light-DOM custom element wrapping
|
||||||
|
// createSqlEditor(). The module auto-registers the default tag on import (see the
|
||||||
|
// bottom of this file); call registerSqlEditorElement("my-tag") to also/instead
|
||||||
|
// register it under a different name.
|
||||||
|
//
|
||||||
|
// Attributes (all optional):
|
||||||
|
// name form-field name for the submitted SQL (form participation)
|
||||||
|
// database Datasette database name; when set and schema-url is absent the
|
||||||
|
// schema URL is derived as
|
||||||
|
// {base-url}/{database}/-/editor-schema.json
|
||||||
|
// base-url Datasette base_url prefix used for the derived schema URL ("")
|
||||||
|
// schema-url explicit URL returning the neutral {tables:[...]} schema payload
|
||||||
|
// default-table unqualified-column default table for autocomplete
|
||||||
|
// readonly boolean; mounts the editor read-only
|
||||||
|
// autofocus boolean; focuses the editor once mounted
|
||||||
|
// The initial document is, in priority order: a programmatically-set value; the
|
||||||
|
// value of a light-DOM <textarea> first child (a progressive-enhancement form
|
||||||
|
// field that keeps working with JS disabled - it is adopted then removed); or
|
||||||
|
// the element's trimmed textContent. The light DOM is cleared on mount.
|
||||||
|
//
|
||||||
|
// Properties:
|
||||||
|
// value get/set the document (set is host-tagged: no "input" event)
|
||||||
|
// schema set -> updateSchema({schema, defaultTable})
|
||||||
|
// view get the raw EditorView escape hatch (null before mount)
|
||||||
|
// readOnly get/set via a Compartment
|
||||||
|
// extensions get/set extra CodeMirror extensions; honored ONLY before the
|
||||||
|
// element connects (createSqlEditor builds the extension set once)
|
||||||
|
// Methods: focus(), updateSchema(conf), format().
|
||||||
|
// Events (all bubble):
|
||||||
|
// input {detail:{origin:"user"}} on user edits (host edits suppressed)
|
||||||
|
// submit cancelable; default action requestSubmit()s internals.form
|
||||||
|
// ready once mounted (schema may still be fetching — see below)
|
||||||
|
// editor-escape on Escape at the editor top level
|
||||||
|
export class DatasetteSqlEditorElement extends HTMLElement {
|
||||||
|
static formAssociated = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._handle = null;
|
||||||
|
this._internals = null;
|
||||||
|
this._readOnly = false;
|
||||||
|
this._readOnlyCompartment = new Compartment();
|
||||||
|
this._extensions = [];
|
||||||
|
this._pendingDoc = null;
|
||||||
|
this._initialDoc = "";
|
||||||
|
// attachInternals is guarded: Safari < 16.4 has no ElementInternals. Without
|
||||||
|
// it the editor still works fully, but form participation
|
||||||
|
// (setFormValue/reset) is a no-op, so this field degrades to contributing
|
||||||
|
// nothing on submit. Documented as an accepted graceful degradation.
|
||||||
|
try {
|
||||||
|
this._internals = this.attachInternals ? this.attachInternals() : null;
|
||||||
|
} catch (err) {
|
||||||
|
this._internals = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this._handle) return; // already mounted (e.g. move within the DOM)
|
||||||
|
|
||||||
|
// Parser-timing guard. When this element's definition loads BEFORE the parser
|
||||||
|
// reaches the element (e.g. a non-deferred <script> in <head>, which is how
|
||||||
|
// Datasette's own pages load the bundle), the browser upgrades and connects
|
||||||
|
// the element at its start tag - before its light-DOM children (the initial
|
||||||
|
// document / fallback <textarea>) have been parsed. Reading them now would
|
||||||
|
// yield an empty document. Detect that case - still parsing, nothing set
|
||||||
|
// programmatically, no children yet - and defer mounting to DOMContentLoaded,
|
||||||
|
// by which point the element's subtree is fully parsed. The listener is
|
||||||
|
// registered as the parser sees this element (before any later inline
|
||||||
|
// script's DOMContentLoaded handler), so consumers reading .view on
|
||||||
|
// DOMContentLoaded still observe a mounted editor.
|
||||||
|
if (
|
||||||
|
this.ownerDocument.readyState === "loading" &&
|
||||||
|
this._pendingDoc == null &&
|
||||||
|
!this.firstChild
|
||||||
|
) {
|
||||||
|
this.ownerDocument.addEventListener(
|
||||||
|
"DOMContentLoaded",
|
||||||
|
() => this.connectedCallback(),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progressive-enhancement fallback: a light-DOM <textarea> first child is a
|
||||||
|
// real form field that keeps working with JavaScript disabled. When present,
|
||||||
|
// adopt its value as the initial document and remove it, so it does not also
|
||||||
|
// submit a duplicate field alongside the value the element contributes via
|
||||||
|
// setFormValue.
|
||||||
|
const firstEl = this.firstElementChild;
|
||||||
|
const fallbackTextarea =
|
||||||
|
firstEl && firstEl.tagName === "TEXTAREA" ? firstEl : null;
|
||||||
|
let fallbackDoc = null;
|
||||||
|
if (fallbackTextarea) {
|
||||||
|
fallbackDoc = fallbackTextarea.value;
|
||||||
|
fallbackTextarea.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialDoc =
|
||||||
|
this._pendingDoc != null
|
||||||
|
? this._pendingDoc
|
||||||
|
: fallbackDoc != null
|
||||||
|
? fallbackDoc
|
||||||
|
: this.textContent.trim();
|
||||||
|
this._initialDoc = initialDoc;
|
||||||
|
this._pendingDoc = null;
|
||||||
|
// Clear the light-DOM text so it doesn't render behind the editor.
|
||||||
|
this.textContent = "";
|
||||||
|
|
||||||
|
this._readOnly = this.hasAttribute("readonly");
|
||||||
|
const defaultTable = this.getAttribute("default-table") || undefined;
|
||||||
|
|
||||||
|
this._handle = createSqlEditor(this, {
|
||||||
|
doc: initialDoc,
|
||||||
|
defaultTable,
|
||||||
|
extensions: [
|
||||||
|
sqlEditorTheme,
|
||||||
|
this._readOnlyCompartment.of(readOnlyState(this._readOnly)),
|
||||||
|
...(this._extensions || []),
|
||||||
|
],
|
||||||
|
onChange: () => {
|
||||||
|
this._syncFormValue();
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("input", {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { origin: "user" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSubmit: () => {
|
||||||
|
const proceed = this.dispatchEvent(
|
||||||
|
new CustomEvent("submit", { bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
if (!proceed) return; // default prevented
|
||||||
|
const form = this._internals && this._internals.form;
|
||||||
|
if (!form) return;
|
||||||
|
// requestSubmit() runs constraint validation and submit handlers, exactly
|
||||||
|
// like clicking a submit button; fall back to submit() where unsupported.
|
||||||
|
if (typeof form.requestSubmit === "function") {
|
||||||
|
form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEscape: () => {
|
||||||
|
this.dispatchEvent(new CustomEvent("editor-escape", { bubbles: true }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this._syncFormValue();
|
||||||
|
|
||||||
|
// Fetch schema (if configured) without blocking the editor: a failure
|
||||||
|
// downgrades to keyword-only completion and never breaks editing.
|
||||||
|
const schemaUrl = this._resolveSchemaUrl();
|
||||||
|
if (schemaUrl) {
|
||||||
|
fetch(schemaUrl, { credentials: "same-origin" })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`schema fetch ${schemaUrl} -> ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
this.updateSchema({ schema: schemaFromTables(data.tables) });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
"datasette-sql-editor: schema fetch failed; keyword-only completion",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasAttribute("autofocus")) {
|
||||||
|
this._handle.view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "ready" fires after mount; schema may still be in flight (it applies later
|
||||||
|
// via updateSchema). Dispatched synchronously so listeners attached before the
|
||||||
|
// element is inserted observe it.
|
||||||
|
this.dispatchEvent(new CustomEvent("ready", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (this._handle) {
|
||||||
|
// Preserve the document across DOM moves (disconnect + reconnect):
|
||||||
|
// connectedCallback prefers _pendingDoc over textContent, and the dead
|
||||||
|
// editor's DOM must not be left behind to be misread as initial content.
|
||||||
|
this._pendingDoc = this._handle.value;
|
||||||
|
this._handle.destroy();
|
||||||
|
this._handle = null;
|
||||||
|
this.replaceChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formResetCallback() {
|
||||||
|
if (!this._handle) return;
|
||||||
|
this._handle.value = this._initialDoc; // hostChange-tagged: no "input" event
|
||||||
|
this._syncFormValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveSchemaUrl() {
|
||||||
|
const explicit = this.getAttribute("schema-url");
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const database = this.getAttribute("database");
|
||||||
|
if (!database) return null;
|
||||||
|
const base = (this.getAttribute("base-url") || "").replace(/\/+$/, "");
|
||||||
|
return `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncFormValue() {
|
||||||
|
if (this._internals && this._internals.setFormValue) {
|
||||||
|
this._internals.setFormValue(this.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- properties -------------------------------------------------------
|
||||||
|
get value() {
|
||||||
|
return this._handle ? this._handle.value : this._pendingDoc || "";
|
||||||
|
}
|
||||||
|
set value(newValue) {
|
||||||
|
const v = newValue == null ? "" : String(newValue);
|
||||||
|
if (this._handle) {
|
||||||
|
this._handle.value = v; // hostChange-tagged: suppresses the "input" event
|
||||||
|
this._syncFormValue();
|
||||||
|
} else {
|
||||||
|
this._pendingDoc = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set schema(ns) {
|
||||||
|
this.updateSchema({ schema: ns });
|
||||||
|
}
|
||||||
|
|
||||||
|
get view() {
|
||||||
|
return this._handle ? this._handle.view : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readOnly() {
|
||||||
|
return this._readOnly;
|
||||||
|
}
|
||||||
|
set readOnly(value) {
|
||||||
|
this._readOnly = !!value;
|
||||||
|
if (this._handle) {
|
||||||
|
this._handle.view.dispatch({
|
||||||
|
effects: this._readOnlyCompartment.reconfigure(
|
||||||
|
readOnlyState(this._readOnly),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get extensions() {
|
||||||
|
return this._extensions;
|
||||||
|
}
|
||||||
|
set extensions(exts) {
|
||||||
|
if (this._handle) {
|
||||||
|
console.warn(
|
||||||
|
"datasette-sql-editor: .extensions must be set before the element connects; ignoring",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._extensions = exts || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- methods ----------------------------------------------------------
|
||||||
|
focus() {
|
||||||
|
if (this._handle) this._handle.view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSchema(conf = {}) {
|
||||||
|
if (!this._handle) return;
|
||||||
|
// Merge in default-table so a bare {schema} update doesn't drop it (the
|
||||||
|
// compartment reconfigure replaces the whole sql() extension).
|
||||||
|
this._handle.updateSchema({
|
||||||
|
defaultTable: this.getAttribute("default-table") || undefined,
|
||||||
|
...conf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
format() {
|
||||||
|
const formatter =
|
||||||
|
typeof window !== "undefined" ? window.sqlFormatter : undefined;
|
||||||
|
if (!formatter || typeof formatter.format !== "function") {
|
||||||
|
console.warn(
|
||||||
|
"datasette-sql-editor: window.sqlFormatter is not loaded; format() is a no-op",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._handle) return;
|
||||||
|
const formatted = formatter.format(this.value);
|
||||||
|
this._handle.value = formatted; // hostChange-tagged full replace
|
||||||
|
this._syncFormValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerSqlEditorElement(tagName) — defines the element under tagName, guarding
|
||||||
|
// against double registration (customElements.define throws on a duplicate). A
|
||||||
|
// no-op in non-DOM contexts. Returns the tag name.
|
||||||
|
export function registerSqlEditorElement(tagName = "datasette-sql-editor") {
|
||||||
|
if (typeof customElements === "undefined") return tagName;
|
||||||
|
if (!customElements.get(tagName)) {
|
||||||
|
customElements.define(tagName, DatasetteSqlEditorElement);
|
||||||
|
}
|
||||||
|
return tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the CodeMirror pieces callers need so plugin code shares this
|
||||||
|
// module's single CM instance instead of bundling its own.
|
||||||
|
export {
|
||||||
|
EditorView,
|
||||||
|
EditorState,
|
||||||
|
Compartment,
|
||||||
|
Annotation,
|
||||||
|
Prec,
|
||||||
|
keymap,
|
||||||
|
tooltips,
|
||||||
|
sql,
|
||||||
|
SQLDialect,
|
||||||
|
autocompletion,
|
||||||
|
completionKeymap,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-register the default <datasette-sql-editor> tag on import. Guarded so
|
||||||
|
// importing this module in a non-DOM context (SSR/tests) or after another copy of
|
||||||
|
// the module already claimed the tag is a harmless no-op. This gives template and
|
||||||
|
// dogfood usage a zero-config element; plugins that want to compose the primitives
|
||||||
|
// without the element simply don't touch the tag. Register a differently-named tag
|
||||||
|
// with registerSqlEditorElement("my-tag").
|
||||||
|
if (
|
||||||
|
typeof customElements !== "undefined" &&
|
||||||
|
!customElements.get("datasette-sql-editor")
|
||||||
|
) {
|
||||||
|
registerSqlEditorElement("datasette-sql-editor");
|
||||||
|
}
|
||||||
|
|
@ -915,7 +915,6 @@ function showTableCreateDialogError(state, message) {
|
||||||
|
|
||||||
function setTableCreateDialogSaving(state, isSaving) {
|
function setTableCreateDialogSaving(state, isSaving) {
|
||||||
state.isSaving = isSaving;
|
state.isSaving = isSaving;
|
||||||
state.modal.busy = isSaving;
|
|
||||||
state.columnList
|
state.columnList
|
||||||
.querySelectorAll("input, select, button")
|
.querySelectorAll("input, select, button")
|
||||||
.forEach(function (control) {
|
.forEach(function (control) {
|
||||||
|
|
@ -2044,7 +2043,8 @@ async function createTableFromDataPreview(state) {
|
||||||
var tableUrl =
|
var tableUrl =
|
||||||
responseData.table_url ||
|
responseData.table_url ||
|
||||||
fallbackTableUrl(responseData.table || payload.table);
|
fallbackTableUrl(responseData.table || payload.table);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (tableUrl) {
|
if (tableUrl) {
|
||||||
location.href = tableUrl;
|
location.href = tableUrl;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2118,7 +2118,8 @@ async function saveTableCreateDialog(state) {
|
||||||
var tableUrl =
|
var tableUrl =
|
||||||
responseData.table_url ||
|
responseData.table_url ||
|
||||||
fallbackTableUrl(responseData.table || payload.table);
|
fallbackTableUrl(responseData.table || payload.table);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (tableUrl) {
|
if (tableUrl) {
|
||||||
location.href = tableUrl;
|
location.href = tableUrl;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2140,6 +2141,18 @@ function confirmDiscardTableCreateChanges(state) {
|
||||||
return window.confirm("Discard this new table?");
|
return window.confirm("Discard this new table?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTableCreateDialogIfConfirmed(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmDiscardTableCreateChanges(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureTableCreateDialog(manager) {
|
function ensureTableCreateDialog(manager) {
|
||||||
if (tableCreateDialogState) {
|
if (tableCreateDialogState) {
|
||||||
return tableCreateDialogState;
|
return tableCreateDialogState;
|
||||||
|
|
@ -2148,8 +2161,7 @@ function ensureTableCreateDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = TABLE_CREATE_DIALOG_ID;
|
dialog.id = TABLE_CREATE_DIALOG_ID;
|
||||||
dialog.className = "table-create-dialog";
|
dialog.className = "table-create-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "table-create-title");
|
dialog.setAttribute("aria-labelledby", "table-create-title");
|
||||||
|
|
@ -2159,7 +2171,7 @@ function ensureTableCreateDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
<form class="table-create-form" method="post" novalidate>
|
<form class="table-create-form" method="post" novalidate>
|
||||||
<p class="table-create-error" id="table-create-error" role="alert" tabindex="-1" hidden></p>
|
<p class="table-create-error" id="table-create-error" role="alert" tabindex="-1" hidden></p>
|
||||||
<div class="modal-body table-create-fields">
|
<div class="table-create-fields">
|
||||||
<div class="table-create-field">
|
<div class="table-create-field">
|
||||||
<label class="table-create-label" for="table-create-name">Table name</label>
|
<label class="table-create-label" for="table-create-name">Table name</label>
|
||||||
<input class="table-create-input table-create-table-name" id="table-create-name" type="text" name="table" required autocomplete="off">
|
<input class="table-create-input table-create-table-name" id="table-create-name" type="text" name="table" required autocomplete="off">
|
||||||
|
|
@ -2186,15 +2198,14 @@ function ensureTableCreateDialog(manager) {
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<a href="#" class="table-create-mode-link table-create-from-data">Create table from data</a>
|
<a href="#" class="table-create-mode-link table-create-from-data">Create table from data</a>
|
||||||
<a href="#" class="table-create-mode-link table-create-manual" hidden>Create table manually</a>
|
<a href="#" class="table-create-mode-link table-create-manual" hidden>Create table manually</a>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost table-create-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost table-create-cancel">Cancel</button>
|
||||||
<button type="submit" class="modal-btn modal-btn-primary table-create-save">Create table</button>
|
<button type="submit" class="btn btn-primary table-create-save">Create table</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
tableCreateDialogState = {
|
tableCreateDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
form: dialog.querySelector(".table-create-form"),
|
form: dialog.querySelector(".table-create-form"),
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
|
|
@ -2214,6 +2225,8 @@ function ensureTableCreateDialog(manager) {
|
||||||
manualCreateLink: dialog.querySelector(".table-create-manual"),
|
manualCreateLink: dialog.querySelector(".table-create-manual"),
|
||||||
cancelButton: dialog.querySelector(".table-create-cancel"),
|
cancelButton: dialog.querySelector(".table-create-cancel"),
|
||||||
saveButton: dialog.querySelector(".table-create-save"),
|
saveButton: dialog.querySelector(".table-create-save"),
|
||||||
|
currentButton: null,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
dataPreviewRows: null,
|
dataPreviewRows: null,
|
||||||
|
|
@ -2253,7 +2266,7 @@ function ensureTableCreateDialog(manager) {
|
||||||
tableCreateDialogState.dataTextarea.focus();
|
tableCreateDialogState.dataTextarea.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modal.requestClose("cancel");
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
});
|
});
|
||||||
|
|
||||||
tableCreateDialogState.createFromDataLink.addEventListener(
|
tableCreateDialogState.createFromDataLink.addEventListener(
|
||||||
|
|
@ -2351,14 +2364,36 @@ function ensureTableCreateDialog(manager) {
|
||||||
updateTableCreateDialogButtons(tableCreateDialogState);
|
updateTableCreateDialogButtons(tableCreateDialogState);
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.beforeClose = function (source) {
|
dialog.addEventListener("click", function (ev) {
|
||||||
return confirmDiscardTableCreateChanges(tableCreateDialogState);
|
if (ev.target === dialog) {
|
||||||
};
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
var state = tableCreateDialogState;
|
var state = tableCreateDialogState;
|
||||||
clearTableCreateDialogError(state);
|
clearTableCreateDialogError(state);
|
||||||
setTableCreateDialogSaving(state, false);
|
setTableCreateDialogSaving(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return tableCreateDialogState;
|
return tableCreateDialogState;
|
||||||
|
|
@ -2379,12 +2414,15 @@ function openTableCreateDialog(button, manager) {
|
||||||
menu.open = false;
|
menu.open = false;
|
||||||
}
|
}
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
|
state.currentButton = button;
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.title.textContent = "Create a table in " + data.databaseName;
|
state.title.textContent = "Create a table in " + data.databaseName;
|
||||||
clearTableCreateDialogError(state);
|
clearTableCreateDialogError(state);
|
||||||
resetTableCreateDialog(state);
|
resetTableCreateDialog(state);
|
||||||
loadTableCreateForeignKeyTargets(state);
|
loadTableCreateForeignKeyTargets(state);
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
state.tableName.focus();
|
state.tableName.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2410,7 +2448,6 @@ function initTableCreateActions(manager) {
|
||||||
|
|
||||||
function setRowDeleteDialogBusy(state, isBusy) {
|
function setRowDeleteDialogBusy(state, isBusy) {
|
||||||
state.isBusy = isBusy;
|
state.isBusy = isBusy;
|
||||||
state.modal.busy = isBusy;
|
|
||||||
state.confirmButton.disabled = isBusy;
|
state.confirmButton.disabled = isBusy;
|
||||||
state.cancelButton.disabled = isBusy;
|
state.cancelButton.disabled = isBusy;
|
||||||
state.confirmButton.textContent = isBusy ? "Deleting..." : "Delete row";
|
state.confirmButton.textContent = isBusy ? "Deleting..." : "Delete row";
|
||||||
|
|
@ -2657,7 +2694,6 @@ function showTableAlterDialogError(state, message) {
|
||||||
|
|
||||||
function setTableAlterDialogSaving(state, isSaving) {
|
function setTableAlterDialogSaving(state, isSaving) {
|
||||||
state.isSaving = isSaving;
|
state.isSaving = isSaving;
|
||||||
state.modal.busy = isSaving;
|
|
||||||
state.cancelButton.disabled = isSaving;
|
state.cancelButton.disabled = isSaving;
|
||||||
state.addColumnButton.disabled = isSaving;
|
state.addColumnButton.disabled = isSaving;
|
||||||
state.backButton.disabled = isSaving;
|
state.backButton.disabled = isSaving;
|
||||||
|
|
@ -3793,7 +3829,8 @@ async function applyTableAlterChanges(state, result) {
|
||||||
result.columnTypeAssignments || [],
|
result.columnTypeAssignments || [],
|
||||||
tableUrl,
|
tableUrl,
|
||||||
);
|
);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (tableAlterResultRenamesTable(result) && tableUrl) {
|
if (tableAlterResultRenamesTable(result) && tableUrl) {
|
||||||
window.location.href = tableUrl;
|
window.location.href = tableUrl;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3854,7 +3891,8 @@ async function dropTableFromAlterDialog(state) {
|
||||||
if (!response.ok || (responseData && responseData.ok === false)) {
|
if (!response.ok || (responseData && responseData.ok === false)) {
|
||||||
throw rowMutationRequestError(response, responseData);
|
throw rowMutationRequestError(response, responseData);
|
||||||
}
|
}
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
window.location.href = tableAlterDatabaseUrl() || "/";
|
window.location.href = tableAlterDatabaseUrl() || "/";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTableAlterDialogSaving(state, false);
|
setTableAlterDialogSaving(state, false);
|
||||||
|
|
@ -3890,6 +3928,27 @@ function confirmDiscardTableAlterChanges(state) {
|
||||||
return window.confirm("Discard table changes?");
|
return window.confirm("Discard table changes?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTableAlterDialogIfConfirmed(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmDiscardTableAlterChanges(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTableAlterDialog(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureTableAlterDialog(manager) {
|
function ensureTableAlterDialog(manager) {
|
||||||
if (tableAlterDialogState) {
|
if (tableAlterDialogState) {
|
||||||
return tableAlterDialogState;
|
return tableAlterDialogState;
|
||||||
|
|
@ -3898,8 +3957,7 @@ function ensureTableAlterDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = TABLE_ALTER_DIALOG_ID;
|
dialog.id = TABLE_ALTER_DIALOG_ID;
|
||||||
dialog.className = "table-alter-dialog";
|
dialog.className = "table-alter-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "table-alter-title");
|
dialog.setAttribute("aria-labelledby", "table-alter-title");
|
||||||
|
|
@ -3909,7 +3967,7 @@ function ensureTableAlterDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
<form class="table-alter-form" method="post" novalidate>
|
<form class="table-alter-form" method="post" novalidate>
|
||||||
<p class="table-alter-error" id="table-alter-error" role="alert" tabindex="-1" hidden></p>
|
<p class="table-alter-error" id="table-alter-error" role="alert" tabindex="-1" hidden></p>
|
||||||
<div class="modal-body table-alter-fields">
|
<div class="table-alter-fields">
|
||||||
<div class="table-alter-columns">
|
<div class="table-alter-columns">
|
||||||
<div class="table-alter-column-headings" aria-hidden="true">
|
<div class="table-alter-column-headings" aria-hidden="true">
|
||||||
<span>Column</span>
|
<span>Column</span>
|
||||||
|
|
@ -3928,19 +3986,18 @@ function ensureTableAlterDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body table-alter-review" hidden></div>
|
<div class="table-alter-review" hidden></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="modal-btn modal-btn-danger table-alter-drop" hidden>Drop table</button>
|
<button type="button" class="btn btn-danger table-alter-drop" hidden>Drop table</button>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost table-alter-back" hidden>Back</button>
|
<button type="button" class="btn btn-ghost table-alter-back" hidden>Back</button>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost table-alter-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost table-alter-cancel">Cancel</button>
|
||||||
<button type="submit" class="modal-btn modal-btn-primary table-alter-save">Review changes</button>
|
<button type="submit" class="btn btn-primary table-alter-save">Review changes</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
tableAlterDialogState = {
|
tableAlterDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
form: dialog.querySelector(".table-alter-form"),
|
form: dialog.querySelector(".table-alter-form"),
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
|
|
@ -3955,6 +4012,8 @@ function ensureTableAlterDialog(manager) {
|
||||||
dropButton: dialog.querySelector(".table-alter-drop"),
|
dropButton: dialog.querySelector(".table-alter-drop"),
|
||||||
cancelButton: dialog.querySelector(".table-alter-cancel"),
|
cancelButton: dialog.querySelector(".table-alter-cancel"),
|
||||||
saveButton: dialog.querySelector(".table-alter-save"),
|
saveButton: dialog.querySelector(".table-alter-save"),
|
||||||
|
currentButton: null,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
initialSignature: "",
|
initialSignature: "",
|
||||||
originalTableName: "",
|
originalTableName: "",
|
||||||
|
|
@ -3996,7 +4055,7 @@ function ensureTableAlterDialog(manager) {
|
||||||
});
|
});
|
||||||
|
|
||||||
tableAlterDialogState.cancelButton.addEventListener("click", function () {
|
tableAlterDialogState.cancelButton.addEventListener("click", function () {
|
||||||
modal.requestClose("cancel");
|
closeTableAlterDialog(tableAlterDialogState);
|
||||||
});
|
});
|
||||||
|
|
||||||
tableAlterDialogState.dropButton.addEventListener("click", function () {
|
tableAlterDialogState.dropButton.addEventListener("click", function () {
|
||||||
|
|
@ -4017,17 +4076,36 @@ function ensureTableAlterDialog(manager) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.beforeClose = function (source) {
|
dialog.addEventListener("click", function (ev) {
|
||||||
return (
|
if (ev.target === dialog) {
|
||||||
source === "cancel" ||
|
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
|
||||||
confirmDiscardTableAlterChanges(tableAlterDialogState)
|
}
|
||||||
);
|
});
|
||||||
};
|
|
||||||
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
closeTableAlterDialogIfConfirmed(tableAlterDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
var state = tableAlterDialogState;
|
var state = tableAlterDialogState;
|
||||||
clearTableAlterDialogError(state);
|
clearTableAlterDialogError(state);
|
||||||
setTableAlterDialogSaving(state, false);
|
setTableAlterDialogSaving(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return tableAlterDialogState;
|
return tableAlterDialogState;
|
||||||
|
|
@ -4048,7 +4126,8 @@ function openTableAlterDialog(button, manager) {
|
||||||
menu.open = false;
|
menu.open = false;
|
||||||
}
|
}
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
|
state.currentButton = button;
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.title.textContent = "Alter table " + data.tableName;
|
state.title.textContent = "Alter table " + data.tableName;
|
||||||
clearTableAlterDialogError(state);
|
clearTableAlterDialogError(state);
|
||||||
resetTableAlterDialog(state, data);
|
resetTableAlterDialog(state, data);
|
||||||
|
|
@ -4058,7 +4137,9 @@ function openTableAlterDialog(button, manager) {
|
||||||
tableAlterForeignKeyTargetsUrl(),
|
tableAlterForeignKeyTargetsUrl(),
|
||||||
{ filterByType: false },
|
{ filterByType: false },
|
||||||
);
|
);
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
var firstName = state.columnList.querySelector(".table-alter-column-name");
|
var firstName = state.columnList.querySelector(".table-alter-column-name");
|
||||||
if (firstName) {
|
if (firstName) {
|
||||||
firstName.focus();
|
firstName.focus();
|
||||||
|
|
@ -4361,8 +4442,7 @@ function ensureRowDeleteDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = ROW_DELETE_DIALOG_ID;
|
dialog.id = ROW_DELETE_DIALOG_ID;
|
||||||
dialog.className = "row-delete-dialog";
|
dialog.className = "row-delete-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "row-delete-title");
|
dialog.setAttribute("aria-labelledby", "row-delete-title");
|
||||||
|
|
@ -4374,14 +4454,13 @@ function ensureRowDeleteDialog(manager) {
|
||||||
<p class="row-delete-message" id="row-delete-message">Delete row <span class="row-delete-id"></span>?</p>
|
<p class="row-delete-message" id="row-delete-message">Delete row <span class="row-delete-id"></span>?</p>
|
||||||
<p class="row-delete-error" role="alert" hidden></p>
|
<p class="row-delete-error" role="alert" hidden></p>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="modal-btn modal-btn-ghost row-delete-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost row-delete-cancel">Cancel</button>
|
||||||
<button type="button" class="modal-btn modal-btn-primary row-delete-confirm">Delete row</button>
|
<button type="button" class="btn btn-primary row-delete-confirm">Delete row</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
rowDeleteDialogState = {
|
rowDeleteDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
message: dialog.querySelector(".row-delete-message"),
|
message: dialog.querySelector(".row-delete-message"),
|
||||||
|
|
@ -4394,10 +4473,21 @@ function ensureRowDeleteDialog(manager) {
|
||||||
currentPkPath: null,
|
currentPkPath: null,
|
||||||
manager: manager,
|
manager: manager,
|
||||||
isBusy: false,
|
isBusy: false,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
rowDeleteDialogState.cancelButton.addEventListener("click", function () {
|
rowDeleteDialogState.cancelButton.addEventListener("click", function () {
|
||||||
modal.requestClose("cancel");
|
if (!rowDeleteDialogState.isBusy) {
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target === dialog && !rowDeleteDialogState.isBusy) {
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dialog.addEventListener("keydown", function (ev) {
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
|
@ -4409,6 +4499,25 @@ function ensureRowDeleteDialog(manager) {
|
||||||
if (!rowDeleteDialogState.isBusy) {
|
if (!rowDeleteDialogState.isBusy) {
|
||||||
rowDeleteDialogState.confirmButton.click();
|
rowDeleteDialogState.confirmButton.click();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rowDeleteDialogState.isBusy) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
if (rowDeleteDialogState.isBusy) {
|
||||||
|
ev.preventDefault();
|
||||||
|
} else {
|
||||||
|
rowDeleteDialogState.shouldRestoreFocus = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4416,6 +4525,13 @@ function ensureRowDeleteDialog(manager) {
|
||||||
var state = rowDeleteDialogState;
|
var state = rowDeleteDialogState;
|
||||||
clearRowDeleteDialogError(state);
|
clearRowDeleteDialogError(state);
|
||||||
setRowDeleteDialogBusy(state, false);
|
setRowDeleteDialogBusy(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rowDeleteDialogState.confirmButton.addEventListener(
|
rowDeleteDialogState.confirmButton.addEventListener(
|
||||||
|
|
@ -4442,7 +4558,8 @@ function ensureRowDeleteDialog(manager) {
|
||||||
throw rowMutationRequestError(response, data);
|
throw rowMutationRequestError(response, data);
|
||||||
}
|
}
|
||||||
if (data && data.redirect) {
|
if (data && data.redirect) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
location.href = data.redirect;
|
location.href = data.redirect;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -4454,7 +4571,8 @@ function ensureRowDeleteDialog(manager) {
|
||||||
var statusMessage = state.currentPkPath
|
var statusMessage = state.currentPkPath
|
||||||
? "Deleted row " + state.currentPkPath + "."
|
? "Deleted row " + state.currentPkPath + "."
|
||||||
: "Deleted row.";
|
: "Deleted row.";
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
state.currentRow.remove();
|
state.currentRow.remove();
|
||||||
showRowMutationStatus(state.manager, statusMessage, false);
|
showRowMutationStatus(state.manager, statusMessage, false);
|
||||||
if (focusTarget && document.contains(focusTarget)) {
|
if (focusTarget && document.contains(focusTarget)) {
|
||||||
|
|
@ -4483,9 +4601,11 @@ function openRowDeleteDialog(button, manager) {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
|
state.currentButton = button;
|
||||||
state.currentRow = row;
|
state.currentRow = row;
|
||||||
state.currentDeleteUrl = rowDeleteUrl(row);
|
state.currentDeleteUrl = rowDeleteUrl(row);
|
||||||
state.currentPkPath = rowDisplayLabel(row);
|
state.currentPkPath = rowDisplayLabel(row);
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
|
||||||
clearRowDeleteDialogError(state);
|
clearRowDeleteDialogError(state);
|
||||||
setRowDeleteDialogBusy(state, false);
|
setRowDeleteDialogBusy(state, false);
|
||||||
|
|
@ -4497,7 +4617,9 @@ function openRowDeleteDialog(button, manager) {
|
||||||
);
|
);
|
||||||
state.rowId.textContent = state.currentPkPath || "this row";
|
state.rowId.textContent = state.currentPkPath || "this row";
|
||||||
|
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
state.confirmButton.focus();
|
state.confirmButton.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5572,7 +5694,6 @@ function setRowEditDialogLoading(state, isLoading) {
|
||||||
|
|
||||||
function setRowEditDialogSaving(state, isSaving) {
|
function setRowEditDialogSaving(state, isSaving) {
|
||||||
state.isSaving = isSaving;
|
state.isSaving = isSaving;
|
||||||
state.modal.busy = isSaving;
|
|
||||||
updateRowEditDialogButtons(state);
|
updateRowEditDialogButtons(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5790,6 +5911,18 @@ function confirmDiscardRowEditChanges(state) {
|
||||||
return window.confirm(message);
|
return window.confirm(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeRowEditDialogIfConfirmed(state) {
|
||||||
|
if (!state || state.isSaving) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmDiscardRowEditChanges(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function setRowInsertDialogTitle(state) {
|
function setRowInsertDialogTitle(state) {
|
||||||
var insertData = tableInsertData() || {};
|
var insertData = tableInsertData() || {};
|
||||||
var title = rowEditIsMultipleInsert(state)
|
var title = rowEditIsMultipleInsert(state)
|
||||||
|
|
@ -6615,6 +6748,38 @@ async function insertBulkPreviewRows(state) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleCloseRowEditDialogIfConfirmed(state) {
|
||||||
|
// Fix for an issue in Safari where hitting Esc would show
|
||||||
|
// the confirm() prompt asking if state should be discarded
|
||||||
|
// but the Esc key press would then cancel that dialog too.
|
||||||
|
// Wait for keyup, then move the confirm() to a fresh timer tick.
|
||||||
|
if (!state || state.isSaving || state.isClosePending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!rowEditDialogHasChanges(state)) {
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
|
state.dialog.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
state.isClosePending = true;
|
||||||
|
var closeAfterKeyup = function () {
|
||||||
|
if (!state.isClosePending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.isClosePending = false;
|
||||||
|
closeRowEditDialogIfConfirmed(state);
|
||||||
|
};
|
||||||
|
var onKeyup = function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.removeEventListener("keyup", onKeyup, true);
|
||||||
|
setTimeout(closeAfterKeyup, 0);
|
||||||
|
};
|
||||||
|
document.addEventListener("keyup", onKeyup, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function findDataRowElement(root, rowId) {
|
function findDataRowElement(root, rowId) {
|
||||||
var elements = root.querySelectorAll("[data-row]");
|
var elements = root.querySelectorAll("[data-row]");
|
||||||
for (var i = 0; i < elements.length; i += 1) {
|
for (var i = 0; i < elements.length; i += 1) {
|
||||||
|
|
@ -6704,8 +6869,9 @@ async function saveRowEditDialog(state) {
|
||||||
}
|
}
|
||||||
var formValues = collectRowFormValues(state);
|
var formValues = collectRowFormValues(state);
|
||||||
if (state.mode === "edit" && !Object.keys(formValues).length) {
|
if (state.mode === "edit" && !Object.keys(formValues).length) {
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
hideRowMutationStatus();
|
hideRowMutationStatus();
|
||||||
state.modal.close();
|
state.dialog.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var payload =
|
var payload =
|
||||||
|
|
@ -6738,8 +6904,9 @@ async function saveRowEditDialog(state) {
|
||||||
insertedRowData,
|
insertedRowData,
|
||||||
insertData.primaryKeys || [],
|
insertData.primaryKeys || [],
|
||||||
);
|
);
|
||||||
|
state.shouldRestoreFocus = false;
|
||||||
if (!insertedRowId) {
|
if (!insertedRowId) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
var missingIdStatus = showRowMutationStatus(
|
var missingIdStatus = showRowMutationStatus(
|
||||||
state.manager,
|
state.manager,
|
||||||
"Inserted row. Refresh the page to see it.",
|
"Inserted row. Refresh the page to see it.",
|
||||||
|
|
@ -6755,7 +6922,7 @@ async function saveRowEditDialog(state) {
|
||||||
try {
|
try {
|
||||||
insertedRow = await fetchUpdatedRowElement(state);
|
insertedRow = await fetchUpdatedRowElement(state);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
var refreshFailedStatus = showRowMutationStatus(
|
var refreshFailedStatus = showRowMutationStatus(
|
||||||
state.manager,
|
state.manager,
|
||||||
"Inserted row, but could not refresh the table row. Refresh the page to see it.",
|
"Inserted row, but could not refresh the table row. Refresh the page to see it.",
|
||||||
|
|
@ -6770,7 +6937,7 @@ async function saveRowEditDialog(state) {
|
||||||
rowTitleLabel(insertedRow),
|
rowTitleLabel(insertedRow),
|
||||||
);
|
);
|
||||||
var addedRow = addInsertedRowToPage(insertedRow);
|
var addedRow = addInsertedRowToPage(insertedRow);
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
showRowMutationStatus(state.manager, insertedStatusMessage, false);
|
showRowMutationStatus(state.manager, insertedStatusMessage, false);
|
||||||
if (addedRow) {
|
if (addedRow) {
|
||||||
var insertedFocusTarget =
|
var insertedFocusTarget =
|
||||||
|
|
@ -6779,7 +6946,7 @@ async function saveRowEditDialog(state) {
|
||||||
insertedFocusTarget.focus();
|
insertedFocusTarget.focus();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.dialog.close();
|
||||||
var filteredStatus = showRowMutationStatus(
|
var filteredStatus = showRowMutationStatus(
|
||||||
state.manager,
|
state.manager,
|
||||||
"Inserted row. It does not match the current filters.",
|
"Inserted row. It does not match the current filters.",
|
||||||
|
|
@ -6791,7 +6958,8 @@ async function saveRowEditDialog(state) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRowPage()) {
|
if (isRowPage()) {
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
location.reload();
|
location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -6827,7 +6995,8 @@ async function saveRowEditDialog(state) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.modal.close({ restoreFocus: false });
|
state.shouldRestoreFocus = false;
|
||||||
|
state.dialog.close();
|
||||||
if (focusTarget && document.contains(focusTarget)) {
|
if (focusTarget && document.contains(focusTarget)) {
|
||||||
focusTarget.focus();
|
focusTarget.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -6971,8 +7140,7 @@ function ensureRowEditDialog(manager) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = ROW_EDIT_DIALOG_ID;
|
dialog.id = ROW_EDIT_DIALOG_ID;
|
||||||
dialog.className = "row-edit-dialog";
|
dialog.className = "row-edit-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "row-edit-title");
|
dialog.setAttribute("aria-labelledby", "row-edit-title");
|
||||||
|
|
@ -6984,8 +7152,8 @@ function ensureRowEditDialog(manager) {
|
||||||
<p class="row-edit-summary" id="row-edit-summary" hidden></p>
|
<p class="row-edit-summary" id="row-edit-summary" hidden></p>
|
||||||
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
|
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
|
||||||
<p class="row-edit-error" role="alert" tabindex="-1" hidden></p>
|
<p class="row-edit-error" role="alert" tabindex="-1" hidden></p>
|
||||||
<div class="modal-body row-edit-fields"></div>
|
<div class="row-edit-fields"></div>
|
||||||
<div class="modal-body row-edit-bulk" hidden>
|
<div class="row-edit-bulk" hidden>
|
||||||
<div class="row-edit-bulk-editor">
|
<div class="row-edit-bulk-editor">
|
||||||
<p class="row-edit-bulk-note"><label for="row-edit-bulk-textarea">Paste TSV, CSV, or JSON</label>. You can also <button type="button" class="button-as-link row-edit-bulk-open-file">open a file</button> or drop it onto this textarea</p>
|
<p class="row-edit-bulk-note"><label for="row-edit-bulk-textarea">Paste TSV, CSV, or JSON</label>. You can also <button type="button" class="button-as-link row-edit-bulk-open-file">open a file</button> or drop it onto this textarea</p>
|
||||||
<input class="row-edit-bulk-file-input" type="file" accept=".csv,.tsv,.json,.txt,text/csv,text/tab-separated-values,application/json,text/plain" hidden>
|
<input class="row-edit-bulk-file-input" type="file" accept=".csv,.tsv,.json,.txt,text/csv,text/tab-separated-values,application/json,text/plain" hidden>
|
||||||
|
|
@ -7002,7 +7170,7 @@ function ensureRowEditDialog(manager) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-edit-bulk-actions">
|
<div class="row-edit-bulk-actions">
|
||||||
<button type="button" class="modal-btn modal-btn-ghost row-edit-copy-template"><span class="row-edit-copy-template-label-wide">Copy spreadsheet template</span><span class="row-edit-copy-template-label-narrow">Copy template</span></button>
|
<button type="button" class="btn btn-ghost row-edit-copy-template"><span class="row-edit-copy-template-label-wide">Copy spreadsheet template</span><span class="row-edit-copy-template-label-narrow">Copy template</span></button>
|
||||||
<span class="row-edit-bulk-template-note"><span class="row-edit-bulk-template-note-wide">You can paste the template into Google Sheets or Excel.</span><span class="row-edit-bulk-template-note-narrow">Paste into Google Sheets or Excel</span></span>
|
<span class="row-edit-bulk-template-note"><span class="row-edit-bulk-template-note-wide">You can paste the template into Google Sheets or Excel.</span><span class="row-edit-bulk-template-note-narrow">Paste into Google Sheets or Excel</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -7015,15 +7183,14 @@ function ensureRowEditDialog(manager) {
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<a href="#" class="row-edit-mode-link row-edit-bulk-insert" hidden>Insert multiple rows</a>
|
<a href="#" class="row-edit-mode-link row-edit-bulk-insert" hidden>Insert multiple rows</a>
|
||||||
<a href="#" class="row-edit-mode-link row-edit-single-insert" hidden>Insert single row</a>
|
<a href="#" class="row-edit-mode-link row-edit-single-insert" hidden>Insert single row</a>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost row-edit-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost row-edit-cancel">Cancel</button>
|
||||||
<button type="submit" class="modal-btn modal-btn-primary row-edit-save" disabled>Save</button>
|
<button type="submit" class="btn btn-primary row-edit-save" disabled>Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
rowEditDialogState = {
|
rowEditDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
form: dialog.querySelector(".row-edit-form"),
|
form: dialog.querySelector(".row-edit-form"),
|
||||||
title: dialog.querySelector(".modal-title"),
|
title: dialog.querySelector(".modal-title"),
|
||||||
|
|
@ -7054,6 +7221,7 @@ function ensureRowEditDialog(manager) {
|
||||||
singleInsertLink: dialog.querySelector(".row-edit-single-insert"),
|
singleInsertLink: dialog.querySelector(".row-edit-single-insert"),
|
||||||
cancelButton: dialog.querySelector(".row-edit-cancel"),
|
cancelButton: dialog.querySelector(".row-edit-cancel"),
|
||||||
saveButton: dialog.querySelector(".row-edit-save"),
|
saveButton: dialog.querySelector(".row-edit-save"),
|
||||||
|
currentButton: null,
|
||||||
currentRow: null,
|
currentRow: null,
|
||||||
currentRowId: null,
|
currentRowId: null,
|
||||||
currentPkPath: null,
|
currentPkPath: null,
|
||||||
|
|
@ -7081,7 +7249,9 @@ function ensureRowEditDialog(manager) {
|
||||||
manager: manager,
|
manager: manager,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
|
isClosePending: false,
|
||||||
hasLoaded: false,
|
hasLoaded: false,
|
||||||
|
shouldRestoreFocus: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
rowEditDialogState.form.addEventListener("submit", function (ev) {
|
rowEditDialogState.form.addEventListener("submit", function (ev) {
|
||||||
|
|
@ -7101,7 +7271,10 @@ function ensureRowEditDialog(manager) {
|
||||||
rowEditDialogState.bulkInsertTextarea.focus();
|
rowEditDialogState.bulkInsertTextarea.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modal.requestClose("cancel");
|
if (!rowEditDialogState.isSaving) {
|
||||||
|
rowEditDialogState.shouldRestoreFocus = true;
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) {
|
rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) {
|
||||||
|
|
@ -7220,17 +7393,31 @@ function ensureRowEditDialog(manager) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
modal.beforeClose = function (source) {
|
dialog.addEventListener("click", function (ev) {
|
||||||
return (
|
if (ev.target === dialog) {
|
||||||
source === "cancel" || confirmDiscardRowEditChanges(rowEditDialogState)
|
closeRowEditDialogIfConfirmed(rowEditDialogState);
|
||||||
);
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
var state = rowEditDialogState;
|
var state = rowEditDialogState;
|
||||||
var shouldReloadOnClose = state.shouldReloadOnClose;
|
var shouldReloadOnClose = state.shouldReloadOnClose;
|
||||||
var redirectOnCloseUrl = state.redirectOnCloseUrl;
|
var redirectOnCloseUrl = state.redirectOnCloseUrl;
|
||||||
state.loadId += 1;
|
state.loadId += 1;
|
||||||
|
state.isClosePending = false;
|
||||||
state.bulkInsertLiveValidationError = null;
|
state.bulkInsertLiveValidationError = null;
|
||||||
state.shouldReloadOnClose = false;
|
state.shouldReloadOnClose = false;
|
||||||
state.redirectOnCloseUrl = null;
|
state.redirectOnCloseUrl = null;
|
||||||
|
|
@ -7243,6 +7430,13 @@ function ensureRowEditDialog(manager) {
|
||||||
destroyRowEditFields(state);
|
destroyRowEditFields(state);
|
||||||
setRowEditDialogLoading(state, false);
|
setRowEditDialogLoading(state, false);
|
||||||
setRowEditDialogSaving(state, false);
|
setRowEditDialogSaving(state, false);
|
||||||
|
if (
|
||||||
|
state.shouldRestoreFocus &&
|
||||||
|
state.currentButton &&
|
||||||
|
document.contains(state.currentButton)
|
||||||
|
) {
|
||||||
|
state.currentButton.focus();
|
||||||
|
}
|
||||||
if (shouldReloadOnClose) {
|
if (shouldReloadOnClose) {
|
||||||
if (redirectOnCloseUrl) {
|
if (redirectOnCloseUrl) {
|
||||||
location.href = redirectOnCloseUrl;
|
location.href = redirectOnCloseUrl;
|
||||||
|
|
@ -7267,6 +7461,7 @@ async function openRowEditDialog(button, manager) {
|
||||||
|
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
state.mode = "edit";
|
state.mode = "edit";
|
||||||
|
state.currentButton = button;
|
||||||
state.currentRow = row;
|
state.currentRow = row;
|
||||||
state.currentRowId = row.getAttribute("data-row") || "";
|
state.currentRowId = row.getAttribute("data-row") || "";
|
||||||
state.currentPkPath = rowDisplayLabel(row);
|
state.currentPkPath = rowDisplayLabel(row);
|
||||||
|
|
@ -7283,7 +7478,7 @@ async function openRowEditDialog(button, manager) {
|
||||||
} else {
|
} else {
|
||||||
state.form.removeAttribute("action");
|
state.form.removeAttribute("action");
|
||||||
}
|
}
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.hasLoaded = false;
|
state.hasLoaded = false;
|
||||||
state.loadId += 1;
|
state.loadId += 1;
|
||||||
var loadId = state.loadId;
|
var loadId = state.loadId;
|
||||||
|
|
@ -7302,7 +7497,9 @@ async function openRowEditDialog(button, manager) {
|
||||||
state.summary.textContent = "";
|
state.summary.textContent = "";
|
||||||
syncRowEditInsertModeUi(state);
|
syncRowEditInsertModeUi(state);
|
||||||
|
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
state.cancelButton.focus();
|
state.cancelButton.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -7342,6 +7539,7 @@ function openRowInsertDialog(button, manager) {
|
||||||
|
|
||||||
state.manager = manager;
|
state.manager = manager;
|
||||||
state.mode = "insert";
|
state.mode = "insert";
|
||||||
|
state.currentButton = button;
|
||||||
state.currentRow = null;
|
state.currentRow = null;
|
||||||
state.currentRowId = null;
|
state.currentRowId = null;
|
||||||
state.currentPkPath = null;
|
state.currentPkPath = null;
|
||||||
|
|
@ -7356,7 +7554,7 @@ function openRowInsertDialog(button, manager) {
|
||||||
state.shouldReloadOnClose = false;
|
state.shouldReloadOnClose = false;
|
||||||
state.redirectOnCloseUrl = null;
|
state.redirectOnCloseUrl = null;
|
||||||
resetBulkInsertPreview(state);
|
resetBulkInsertPreview(state);
|
||||||
|
state.shouldRestoreFocus = true;
|
||||||
state.hasLoaded = false;
|
state.hasLoaded = false;
|
||||||
state.loadId += 1;
|
state.loadId += 1;
|
||||||
|
|
||||||
|
|
@ -7378,7 +7576,9 @@ function openRowInsertDialog(button, manager) {
|
||||||
state.summary.textContent = "";
|
state.summary.textContent = "";
|
||||||
syncRowEditInsertModeUi(state);
|
syncRowEditInsertModeUi(state);
|
||||||
|
|
||||||
state.modal.show({ returnFocusTo: button });
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
renderRowInsertFields(state, insertData);
|
renderRowInsertFields(state, insertData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
56
datasette/static/json-format-highlight-1.0.1.js
Normal file
56
datasette/static/json-format-highlight-1.0.1.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
https://github.com/luyilin/json-format-highlight
|
||||||
|
From https://unpkg.com/json-format-highlight@1.0.1/dist/json-format-highlight.js
|
||||||
|
MIT Licensed
|
||||||
|
*/
|
||||||
|
(function (global, factory) {
|
||||||
|
typeof exports === "object" && typeof module !== "undefined"
|
||||||
|
? (module.exports = factory())
|
||||||
|
: typeof define === "function" && define.amd
|
||||||
|
? define(factory)
|
||||||
|
: (global.jsonFormatHighlight = factory());
|
||||||
|
})(this, function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var defaultColors = {
|
||||||
|
keyColor: "dimgray",
|
||||||
|
numberColor: "lightskyblue",
|
||||||
|
stringColor: "lightcoral",
|
||||||
|
trueColor: "lightseagreen",
|
||||||
|
falseColor: "#f66578",
|
||||||
|
nullColor: "cornflowerblue",
|
||||||
|
};
|
||||||
|
|
||||||
|
function index(json, colorOptions) {
|
||||||
|
if (colorOptions === void 0) colorOptions = {};
|
||||||
|
|
||||||
|
if (!json) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof json !== "string") {
|
||||||
|
json = JSON.stringify(json, null, 2);
|
||||||
|
}
|
||||||
|
var colors = Object.assign({}, defaultColors, colorOptions);
|
||||||
|
json = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
return json.replace(
|
||||||
|
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g,
|
||||||
|
function (match) {
|
||||||
|
var color = colors.numberColor;
|
||||||
|
if (/^"/.test(match)) {
|
||||||
|
color = /:$/.test(match) ? colors.keyColor : colors.stringColor;
|
||||||
|
} else {
|
||||||
|
color = /true/.test(match)
|
||||||
|
? colors.trueColor
|
||||||
|
: /false/.test(match)
|
||||||
|
? colors.falseColor
|
||||||
|
: /null/.test(match)
|
||||||
|
? colors.nullColor
|
||||||
|
: color;
|
||||||
|
}
|
||||||
|
return '<span style="color: ' + color + '">' + match + "</span>";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
|
@ -66,8 +66,7 @@ function initMobileColumnActions(manager) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.className = "mobile-column-actions-dialog";
|
dialog.className = "mobile-column-actions-dialog";
|
||||||
dialog.id = MOBILE_COLUMN_DIALOG_ID;
|
dialog.id = MOBILE_COLUMN_DIALOG_ID;
|
||||||
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
|
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
|
||||||
|
|
@ -76,13 +75,13 @@ function initMobileColumnActions(manager) {
|
||||||
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
|
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
|
||||||
<span class="modal-meta"></span>
|
<span class="modal-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body list-wrap mobile-column-list"></div>
|
<div class="list-wrap mobile-column-list"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="footer-info">Tap a column to reveal actions.</span>
|
<span class="footer-info">Tap a column to reveal actions.</span>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost mobile-column-actions-done">Done</button>
|
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
triggerButton.setAttribute("aria-haspopup", "dialog");
|
triggerButton.setAttribute("aria-haspopup", "dialog");
|
||||||
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
|
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
|
||||||
|
|
@ -92,6 +91,7 @@ function initMobileColumnActions(manager) {
|
||||||
var listWrap = dialog.querySelector(".mobile-column-list");
|
var listWrap = dialog.querySelector(".mobile-column-list");
|
||||||
var doneButton = dialog.querySelector(".mobile-column-actions-done");
|
var doneButton = dialog.querySelector(".mobile-column-actions-done");
|
||||||
var expandedSectionId = null;
|
var expandedSectionId = null;
|
||||||
|
var shouldRestoreFocus = true;
|
||||||
|
|
||||||
function updateExpandedSection() {
|
function updateExpandedSection() {
|
||||||
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
|
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
|
||||||
|
|
@ -128,7 +128,16 @@ function initMobileColumnActions(manager) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog(options) {
|
function closeDialog(options) {
|
||||||
modal.close(options);
|
options = options || {};
|
||||||
|
shouldRestoreFocus = options.restoreFocus !== false;
|
||||||
|
if (dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
} else {
|
||||||
|
triggerButton.setAttribute("aria-expanded", "false");
|
||||||
|
if (shouldRestoreFocus) {
|
||||||
|
triggerButton.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDialog() {
|
function renderDialog() {
|
||||||
|
|
@ -157,8 +166,7 @@ function initMobileColumnActions(manager) {
|
||||||
topActions.className = "mobile-column-top-actions";
|
topActions.className = "mobile-column-top-actions";
|
||||||
|
|
||||||
var showAllColumns = document.createElement("a");
|
var showAllColumns = document.createElement("a");
|
||||||
showAllColumns.className =
|
showAllColumns.className = "btn btn-ghost mobile-column-top-action";
|
||||||
"modal-btn modal-btn-ghost mobile-column-top-action";
|
|
||||||
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
|
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
|
||||||
showAllColumns.textContent = "Show all columns";
|
showAllColumns.textContent = "Show all columns";
|
||||||
|
|
||||||
|
|
@ -257,7 +265,9 @@ function initMobileColumnActions(manager) {
|
||||||
if (!renderDialog()) {
|
if (!renderDialog()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
modal.show({ returnFocusTo: triggerButton });
|
if (!dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
triggerButton.setAttribute("aria-expanded", "true");
|
triggerButton.setAttribute("aria-expanded", "true");
|
||||||
var focusTarget =
|
var focusTarget =
|
||||||
dialog.querySelector(".mobile-column-top-action") ||
|
dialog.querySelector(".mobile-column-top-action") ||
|
||||||
|
|
@ -278,8 +288,22 @@ function initMobileColumnActions(manager) {
|
||||||
closeDialog();
|
closeDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target === dialog) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
closeDialog();
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
triggerButton.setAttribute("aria-expanded", "false");
|
triggerButton.setAttribute("aria-expanded", "false");
|
||||||
|
if (shouldRestoreFocus) {
|
||||||
|
triggerButton.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
window.addEventListener("resize", function () {
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
// Shared lifecycle for native modal dialogs.
|
|
||||||
(() => {
|
|
||||||
class DatasetteModal extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.beforeClose = null;
|
|
||||||
this._busy = false;
|
|
||||||
this._restoreFocus = true;
|
|
||||||
this._returnFocusTo = null;
|
|
||||||
this._escapeCleanup = null;
|
|
||||||
this._escapeTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static create() {
|
|
||||||
const modal = document.createElement("datasette-modal");
|
|
||||||
modal.appendChild(document.createElement("dialog"));
|
|
||||||
return modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
get dialog() {
|
|
||||||
return this.querySelector(":scope > dialog");
|
|
||||||
}
|
|
||||||
|
|
||||||
get busy() {
|
|
||||||
return this._busy;
|
|
||||||
}
|
|
||||||
|
|
||||||
set busy(value) {
|
|
||||||
this._busy = !!value;
|
|
||||||
if (this.dialog) {
|
|
||||||
this.dialog.setAttribute("aria-busy", String(this._busy));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
const dialog = this.dialog;
|
|
||||||
if (!dialog) return;
|
|
||||||
dialog.classList.add("datasette-modal");
|
|
||||||
this._listeners?.abort();
|
|
||||||
this._listeners = new AbortController();
|
|
||||||
const options = { signal: this._listeners.signal };
|
|
||||||
let backdropPointerDown = false;
|
|
||||||
const outside = (event) => {
|
|
||||||
const rect = dialog.getBoundingClientRect();
|
|
||||||
return (
|
|
||||||
event.target === dialog &&
|
|
||||||
(event.clientX < rect.left ||
|
|
||||||
event.clientX > rect.right ||
|
|
||||||
event.clientY < rect.top ||
|
|
||||||
event.clientY > rect.bottom)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
dialog.addEventListener(
|
|
||||||
"pointerdown",
|
|
||||||
(event) => {
|
|
||||||
backdropPointerDown = outside(event);
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"click",
|
|
||||||
(event) => {
|
|
||||||
if (backdropPointerDown && outside(event))
|
|
||||||
this.requestClose("backdrop");
|
|
||||||
backdropPointerDown = false;
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"keydown",
|
|
||||||
(event) => {
|
|
||||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
||||||
// A nested native dialog or plugin picker gets first refusal.
|
|
||||||
if (event.target.closest("dialog") !== dialog) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.busy || this._escapeCleanup || this._escapeTimer !== null)
|
|
||||||
return;
|
|
||||||
// Safari can otherwise use this Escape press to cancel confirm() too.
|
|
||||||
// Only keyboard dismissals wait for keyup; native cancel events needn't.
|
|
||||||
const onKeyup = (up) => {
|
|
||||||
if (up.key !== "Escape") return;
|
|
||||||
this._escapeCleanup();
|
|
||||||
this._escapeCleanup = null;
|
|
||||||
this._escapeTimer = setTimeout(() => {
|
|
||||||
this._escapeTimer = null;
|
|
||||||
this.requestClose("escape");
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
this.ownerDocument.addEventListener("keyup", onKeyup, true);
|
|
||||||
this._escapeCleanup = () =>
|
|
||||||
this.ownerDocument.removeEventListener("keyup", onKeyup, true);
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"cancel",
|
|
||||||
(event) => {
|
|
||||||
if (event.target !== dialog) return;
|
|
||||||
event.preventDefault();
|
|
||||||
if (!this._escapeCleanup && this._escapeTimer === null)
|
|
||||||
this.requestClose("escape");
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"close",
|
|
||||||
(event) => {
|
|
||||||
if (event.target !== dialog || dialog.open) return;
|
|
||||||
this._clearPendingClose();
|
|
||||||
this.busy = false;
|
|
||||||
if (this._restoreFocus && this._returnFocusTo?.isConnected) {
|
|
||||||
// Menu actions may have become hidden while the dialog was open.
|
|
||||||
const details = this._returnFocusTo.closest("details:not([open])");
|
|
||||||
const target =
|
|
||||||
details?.querySelector("summary") || this._returnFocusTo;
|
|
||||||
target.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
this._returnFocusTo = null;
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
this._listeners?.abort();
|
|
||||||
this._clearPendingClose();
|
|
||||||
this._returnFocusTo = null;
|
|
||||||
if (this.dialog?.open) this.dialog.close();
|
|
||||||
this.busy = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearPendingClose() {
|
|
||||||
this._escapeCleanup?.();
|
|
||||||
this._escapeCleanup = null;
|
|
||||||
clearTimeout(this._escapeTimer);
|
|
||||||
this._escapeTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
show({ returnFocusTo, initialFocus } = {}) {
|
|
||||||
const dialog = this.dialog;
|
|
||||||
if (!dialog.open) {
|
|
||||||
this._clearPendingClose();
|
|
||||||
this._returnFocusTo = returnFocusTo || this.ownerDocument.activeElement;
|
|
||||||
this._restoreFocus = true;
|
|
||||||
dialog.showModal();
|
|
||||||
}
|
|
||||||
if (typeof initialFocus === "function") initialFocus();
|
|
||||||
else initialFocus?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
requestClose(source = "cancel") {
|
|
||||||
if (!this.dialog.open || this.busy) return false;
|
|
||||||
if (this.beforeClose && this.beforeClose(source) === false) return false;
|
|
||||||
this.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
close({ restoreFocus = true } = {}) {
|
|
||||||
this._clearPendingClose();
|
|
||||||
this._restoreFocus = restoreFocus;
|
|
||||||
this.dialog.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("datasette-modal", DatasetteModal);
|
|
||||||
window.DatasetteModal = DatasetteModal;
|
|
||||||
})();
|
|
||||||
|
|
@ -10,22 +10,277 @@ class NavigationSearch extends HTMLElement {
|
||||||
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
|
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
|
||||||
this.statusId = `navigation-search-status-${this.instanceId}`;
|
this.statusId = `navigation-search-status-${this.instanceId}`;
|
||||||
this.titleId = `navigation-search-title-${this.instanceId}`;
|
this.titleId = `navigation-search-title-${this.instanceId}`;
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
this.matches = [];
|
this.matches = [];
|
||||||
this.renderedMatches = [];
|
this.renderedMatches = [];
|
||||||
this.debounceTimer = null;
|
this.debounceTimer = null;
|
||||||
}
|
this.restoreFocusTarget = null;
|
||||||
|
this.shouldRestoreFocus = true;
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
if (this._initialized) return;
|
|
||||||
this._initialized = true;
|
|
||||||
this.render();
|
this.render();
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<datasette-modal><dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
<style>
|
||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--modal-border-radius, 0.75rem);
|
||||||
|
padding: 0;
|
||||||
|
max-width: 90vw;
|
||||||
|
width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||||
|
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||||
|
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||||
|
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-search {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: #4b5563;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
width: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-search:hover,
|
||||||
|
.close-search:focus {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
height: calc(80vh - 180px);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.selected {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item > div {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-start-content {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.5rem 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-start-content:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-type {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-url {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-description {
|
||||||
|
color: #374151;
|
||||||
|
display: -webkit-box;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-heading {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
padding: 0.5rem 1rem 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-actions {
|
||||||
|
padding: 0.25rem 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-recent {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-recent:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text kbd {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
dialog {
|
||||||
|
width: 95vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
||||||
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
||||||
|
|
@ -47,22 +302,23 @@ class NavigationSearch extends HTMLElement {
|
||||||
>
|
>
|
||||||
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body results-container"></div>
|
<div class="results-container"></div>
|
||||||
<div class="hint-text">
|
<div class="hint-text">
|
||||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||||
<span><kbd>Enter</kbd> Select</span>
|
<span><kbd>Enter</kbd> Select</span>
|
||||||
<span><kbd>Esc</kbd> Close</span>
|
<span><kbd>Esc</kbd> Close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog></datasette-modal>
|
</dialog>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
const dialog = this.querySelector("dialog");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const closeButton = this.querySelector(".close-search");
|
const closeButton = this.shadowRoot.querySelector(".close-search");
|
||||||
const resultsContainer = this.querySelector(".results-container");
|
const resultsContainer =
|
||||||
|
this.shadowRoot.querySelector(".results-container");
|
||||||
|
|
||||||
// Global keyboard listener for "/"
|
// Global keyboard listener for "/"
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
|
|
@ -99,6 +355,8 @@ class NavigationSearch extends HTMLElement {
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectCurrentItem();
|
this.selectCurrentItem();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
this.closeMenu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -122,6 +380,18 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
dialog.addEventListener("click", (e) => {
|
||||||
|
if (e.target === dialog) {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", () => {
|
dialog.addEventListener("close", () => {
|
||||||
this.onMenuClosed();
|
this.onMenuClosed();
|
||||||
});
|
});
|
||||||
|
|
@ -162,6 +432,19 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
focusRestoreTarget(trigger) {
|
||||||
|
if (trigger && typeof trigger.focus === "function") {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
document.activeElement &&
|
||||||
|
typeof document.activeElement.focus === "function"
|
||||||
|
) {
|
||||||
|
return document.activeElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
setNavigationTriggersExpanded(expanded) {
|
setNavigationTriggersExpanded(expanded) {
|
||||||
if (typeof document.querySelectorAll !== "function") {
|
if (typeof document.querySelectorAll !== "function") {
|
||||||
return;
|
return;
|
||||||
|
|
@ -182,8 +465,8 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateComboboxState() {
|
updateComboboxState() {
|
||||||
const dialog = this.querySelector("dialog");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const matches = this.renderedMatches || [];
|
const matches = this.renderedMatches || [];
|
||||||
this.setElementAttribute(
|
this.setElementAttribute(
|
||||||
input,
|
input,
|
||||||
|
|
@ -208,7 +491,7 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(message) {
|
setStatus(message) {
|
||||||
const status = this.querySelector(`#${this.statusId}`);
|
const status = this.shadowRoot.querySelector(`#${this.statusId}`);
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = message || "";
|
status.textContent = message || "";
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +701,7 @@ class NavigationSearch extends HTMLElement {
|
||||||
section.render(node, {
|
section.render(node, {
|
||||||
navigationSearch: this,
|
navigationSearch: this,
|
||||||
container,
|
container,
|
||||||
input: this.querySelector(".search-input"),
|
input: this.shadowRoot.querySelector(".search-input"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -457,8 +740,8 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderResults() {
|
renderResults() {
|
||||||
const container = this.querySelector(".results-container");
|
const container = this.shadowRoot.querySelector(".results-container");
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const showStartContent = !input.value.trim();
|
const showStartContent = !input.value.trim();
|
||||||
const jumpSections = showStartContent ? this.jumpSections() : [];
|
const jumpSections = showStartContent ? this.jumpSections() : [];
|
||||||
const startBlock = showStartContent
|
const startBlock = showStartContent
|
||||||
|
|
@ -570,15 +853,18 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openMenu(returnFocusTo) {
|
openMenu(trigger) {
|
||||||
const input = this.querySelector(".search-input");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
|
|
||||||
this.querySelector("datasette-modal").show({
|
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
|
||||||
returnFocusTo,
|
this.shouldRestoreFocus = true;
|
||||||
initialFocus: input,
|
if (!dialog.open) {
|
||||||
});
|
dialog.showModal();
|
||||||
|
}
|
||||||
this.setNavigationTriggersExpanded(true);
|
this.setNavigationTriggersExpanded(true);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
input.focus();
|
||||||
|
|
||||||
// Reset state, then populate the default jump list.
|
// Reset state, then populate the default jump list.
|
||||||
this.matches = [];
|
this.matches = [];
|
||||||
|
|
@ -588,15 +874,29 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
closeMenu(options = {}) {
|
closeMenu(options = {}) {
|
||||||
this.querySelector("datasette-modal").close(options);
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
|
this.shouldRestoreFocus = options.restoreFocus !== false;
|
||||||
|
if (dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
} else {
|
||||||
|
this.onMenuClosed();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMenuClosed() {
|
onMenuClosed() {
|
||||||
const input = this.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
this.setElementAttribute(input, "aria-expanded", "false");
|
this.setElementAttribute(input, "aria-expanded", "false");
|
||||||
this.removeElementAttribute(input, "aria-activedescendant");
|
this.removeElementAttribute(input, "aria-activedescendant");
|
||||||
this.setNavigationTriggersExpanded(false);
|
this.setNavigationTriggersExpanded(false);
|
||||||
this.setStatus("");
|
this.setStatus("");
|
||||||
|
if (
|
||||||
|
this.shouldRestoreFocus &&
|
||||||
|
this.restoreFocusTarget &&
|
||||||
|
typeof this.restoreFocusTarget.focus === "function"
|
||||||
|
) {
|
||||||
|
this.restoreFocusTarget.focus();
|
||||||
|
}
|
||||||
|
this.restoreFocusTarget = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,6 @@ function createSetColumnTypeOption(value, name, description, checked) {
|
||||||
|
|
||||||
function setSetColumnTypeDialogBusy(state, isBusy) {
|
function setSetColumnTypeDialogBusy(state, isBusy) {
|
||||||
state.isBusy = isBusy;
|
state.isBusy = isBusy;
|
||||||
state.modal.busy = isBusy;
|
|
||||||
state.saveButton.disabled = isBusy;
|
state.saveButton.disabled = isBusy;
|
||||||
state.cancelButton.disabled = isBusy;
|
state.cancelButton.disabled = isBusy;
|
||||||
Array.from(
|
Array.from(
|
||||||
|
|
@ -186,8 +185,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modal = DatasetteModal.create();
|
var dialog = document.createElement("dialog");
|
||||||
var dialog = modal.dialog;
|
|
||||||
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
|
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
|
||||||
dialog.className = "set-column-type-dialog";
|
dialog.className = "set-column-type-dialog";
|
||||||
dialog.setAttribute("aria-labelledby", "set-column-type-title");
|
dialog.setAttribute("aria-labelledby", "set-column-type-title");
|
||||||
|
|
@ -198,17 +196,16 @@ function ensureSetColumnTypeDialog() {
|
||||||
</div>
|
</div>
|
||||||
<p class="set-column-type-status"></p>
|
<p class="set-column-type-status"></p>
|
||||||
<p class="set-column-type-error" hidden></p>
|
<p class="set-column-type-error" hidden></p>
|
||||||
<div class="modal-body set-column-type-options"></div>
|
<div class="set-column-type-options"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<span class="footer-info"></span>
|
<span class="footer-info"></span>
|
||||||
<button type="button" class="modal-btn modal-btn-ghost set-column-type-cancel">Cancel</button>
|
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
|
||||||
<button type="button" class="modal-btn modal-btn-primary set-column-type-save">Save</button>
|
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
setColumnTypeDialogState = {
|
setColumnTypeDialogState = {
|
||||||
modal: modal,
|
|
||||||
dialog: dialog,
|
dialog: dialog,
|
||||||
meta: dialog.querySelector(".modal-meta"),
|
meta: dialog.querySelector(".modal-meta"),
|
||||||
status: dialog.querySelector(".set-column-type-status"),
|
status: dialog.querySelector(".set-column-type-status"),
|
||||||
|
|
@ -223,7 +220,21 @@ function ensureSetColumnTypeDialog() {
|
||||||
};
|
};
|
||||||
|
|
||||||
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
|
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
|
||||||
modal.requestClose("cancel");
|
if (!setColumnTypeDialogState.isBusy) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target === dialog && !setColumnTypeDialogState.isBusy) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", function (ev) {
|
||||||
|
if (setColumnTypeDialogState.isBusy) {
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dialog.addEventListener("close", function () {
|
dialog.addEventListener("close", function () {
|
||||||
|
|
@ -231,9 +242,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
|
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
setColumnTypeDialogState.saveButton.addEventListener(
|
setColumnTypeDialogState.saveButton.addEventListener("click", async function () {
|
||||||
"click",
|
|
||||||
async function () {
|
|
||||||
var state = setColumnTypeDialogState;
|
var state = setColumnTypeDialogState;
|
||||||
var selected = state.dialog.querySelector(
|
var selected = state.dialog.querySelector(
|
||||||
'input[name="set-column-type-choice"]:checked',
|
'input[name="set-column-type-choice"]:checked',
|
||||||
|
|
@ -244,7 +253,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (selectedType === currentType) {
|
if (selectedType === currentType) {
|
||||||
state.modal.close();
|
state.dialog.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,8 +284,7 @@ function ensureSetColumnTypeDialog() {
|
||||||
setSetColumnTypeDialogBusy(state, false);
|
setSetColumnTypeDialogBusy(state, false);
|
||||||
showSetColumnTypeDialogError(state, error.message || "Request failed");
|
showSetColumnTypeDialogError(state, error.message || "Request failed");
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return setColumnTypeDialogState;
|
return setColumnTypeDialogState;
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +341,9 @@ function openSetColumnTypeDialog(th) {
|
||||||
state.optionsWrap.appendChild(emptyState);
|
state.optionsWrap.appendChild(emptyState);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.modal.show();
|
if (!state.dialog.open) {
|
||||||
|
state.dialog.showModal();
|
||||||
|
}
|
||||||
var selectedOption = state.dialog.querySelector(
|
var selectedOption = state.dialog.querySelector(
|
||||||
'input[name="set-column-type-choice"]:checked',
|
'input[name="set-column-type-choice"]:checked',
|
||||||
);
|
);
|
||||||
|
|
@ -357,10 +367,9 @@ function shouldShowShowAllColumns() {
|
||||||
|
|
||||||
function hasMultipleVisibleColumns(manager) {
|
function hasMultipleVisibleColumns(manager) {
|
||||||
return (
|
return (
|
||||||
Array.from(
|
Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter(
|
||||||
document.querySelectorAll(manager.selectors.tableHeaders),
|
(th) => th.dataset.column && th.dataset.isLinkColumn !== "1",
|
||||||
).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1")
|
).length > 1
|
||||||
.length > 1
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -640,12 +649,10 @@ function filterRowNumberFromName(name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextFilterRowNumber(manager) {
|
function nextFilterRowNumber(manager) {
|
||||||
return (
|
return filterRowsWithControls(manager).reduce((max, row) => {
|
||||||
filterRowsWithControls(manager).reduce((max, row) => {
|
|
||||||
var column = row.querySelector("select");
|
var column = row.querySelector("select");
|
||||||
return Math.max(max, filterRowNumberFromName(column && column.name));
|
return Math.max(max, filterRowNumberFromName(column && column.name));
|
||||||
}, 0) + 1
|
}, 0) + 1;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilterRowNumber(row, number) {
|
function setFilterRowNumber(row, number) {
|
||||||
|
|
@ -672,11 +679,9 @@ function updateFilterRowButtons(manager) {
|
||||||
if (addButton) {
|
if (addButton) {
|
||||||
addButton.hidden = index !== rows.length - 1 || !column.value;
|
addButton.hidden = index !== rows.length - 1 || !column.value;
|
||||||
}
|
}
|
||||||
var visibleButtonCount = [removeButton, addButton].filter(
|
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
|
||||||
function (button) {
|
|
||||||
return button && !button.hidden;
|
return button && !button.hidden;
|
||||||
},
|
}).length;
|
||||||
).length;
|
|
||||||
row.classList.toggle(
|
row.classList.toggle(
|
||||||
"filter-controls-row-has-buttons",
|
"filter-controls-row-has-buttons",
|
||||||
visibleButtonCount > 0,
|
visibleButtonCount > 0,
|
||||||
|
|
@ -698,9 +703,7 @@ function cloneFilterRow(row) {
|
||||||
clone.querySelector(".filter-op select").name = "_filter_op";
|
clone.querySelector(".filter-op select").name = "_filter_op";
|
||||||
clone.querySelector("input.filter-value").name = "_filter_value";
|
clone.querySelector("input.filter-value").name = "_filter_value";
|
||||||
resetFilterRow(clone);
|
resetFilterRow(clone);
|
||||||
clone
|
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
|
||||||
.querySelectorAll(".filter-row-icon")
|
|
||||||
.forEach((button) => button.remove());
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -857,45 +860,10 @@ function openColumnChooser() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCountAll() {
|
|
||||||
var button = document.querySelector(".count-all");
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
button.addEventListener("click", async function () {
|
|
||||||
var count = document.querySelector(".table-count");
|
|
||||||
var error = document.querySelector(".count-error");
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = "Counting…";
|
|
||||||
error.textContent = "";
|
|
||||||
try {
|
|
||||||
var response = await fetch(button.dataset.countUrl + location.search, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
var data = await response.json();
|
|
||||||
if (!response.ok || !data.ok) {
|
|
||||||
throw new Error((data.errors || ["Count failed"]).join(" "));
|
|
||||||
}
|
|
||||||
count.textContent =
|
|
||||||
data.count.toLocaleString("en-US") +
|
|
||||||
(data.count === 1 ? " row" : " rows");
|
|
||||||
button.remove();
|
|
||||||
} catch (ex) {
|
|
||||||
error.textContent = ex.message || "Count failed";
|
|
||||||
button.disabled = false;
|
|
||||||
button.textContent = "count all";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensures Table UI is initialized only after the Manager is ready.
|
// Ensures Table UI is initialized only after the Manager is ready.
|
||||||
document.addEventListener("datasette_init", function (evt) {
|
document.addEventListener("datasette_init", function (evt) {
|
||||||
const { detail: manager } = evt;
|
const { detail: manager } = evt;
|
||||||
|
|
||||||
initCountAll();
|
|
||||||
initializeColumnActions(manager);
|
initializeColumnActions(manager);
|
||||||
|
|
||||||
// Main table
|
// Main table
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
import json
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from .utils import tilde_encode, urlsafe_components
|
from .utils import tilde_encode, urlsafe_components
|
||||||
|
|
||||||
|
|
@ -387,7 +386,7 @@ async def count_queries(
|
||||||
OR q.sql LIKE :query_search
|
OR q.sql LIKE :query_search
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
params["query_search"] = f"%{q}%"
|
params["query_search"] = "%{}%".format(q)
|
||||||
if is_write is not None:
|
if is_write is not None:
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
where_clauses.append("q.is_write = :query_is_write")
|
||||||
params["query_is_write"] = int(bool(is_write))
|
params["query_is_write"] = int(bool(is_write))
|
||||||
|
|
@ -463,7 +462,7 @@ async def list_queries(
|
||||||
except ValueError:
|
except ValueError:
|
||||||
components = []
|
components = []
|
||||||
if database is None and len(components) == 3:
|
if database is None and len(components) == 3:
|
||||||
where_clauses.append(f"""
|
where_clauses.append("""
|
||||||
(
|
(
|
||||||
q.database_name > :cursor_database
|
q.database_name > :cursor_database
|
||||||
OR (
|
OR (
|
||||||
|
|
@ -477,12 +476,12 @@ async def list_queries(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""")
|
""".format(sort_key_sql=sort_key_sql))
|
||||||
params["cursor_database"] = components[0]
|
params["cursor_database"] = components[0]
|
||||||
params["cursor_sort_key"] = components[1]
|
params["cursor_sort_key"] = components[1]
|
||||||
params["cursor_name"] = components[2]
|
params["cursor_name"] = components[2]
|
||||||
elif database is not None and len(components) == 2:
|
elif database is not None and len(components) == 2:
|
||||||
where_clauses.append(f"""
|
where_clauses.append("""
|
||||||
(
|
(
|
||||||
{sort_key_sql} > :cursor_sort_key
|
{sort_key_sql} > :cursor_sort_key
|
||||||
OR (
|
OR (
|
||||||
|
|
@ -490,7 +489,7 @@ async def list_queries(
|
||||||
AND q.name > :cursor_name
|
AND q.name > :cursor_name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""")
|
""".format(sort_key_sql=sort_key_sql))
|
||||||
params["cursor_sort_key"] = components[0]
|
params["cursor_sort_key"] = components[0]
|
||||||
params["cursor_name"] = components[1]
|
params["cursor_name"] = components[1]
|
||||||
|
|
||||||
|
|
@ -503,7 +502,7 @@ async def list_queries(
|
||||||
OR q.sql LIKE :query_search
|
OR q.sql LIKE :query_search
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
params["query_search"] = f"%{q}%"
|
params["query_search"] = "%{}%".format(q)
|
||||||
if is_write is not None:
|
if is_write is not None:
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
where_clauses.append("q.is_write = :query_is_write")
|
||||||
params["query_is_write"] = int(bool(is_write))
|
params["query_is_write"] = int(bool(is_write))
|
||||||
|
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
"""
|
|
||||||
OpenTelemetry integration for Datasette.
|
|
||||||
|
|
||||||
This uses `opentelemetry-api` only. Providers, exporters and sampling are
|
|
||||||
configured by whoever runs Datasette, for example `opentelemetry-instrument`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import contextvars
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import weakref
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from opentelemetry import context as otel_context_api
|
|
||||||
from opentelemetry import metrics as otel_metrics
|
|
||||||
from opentelemetry import trace as otel_trace
|
|
||||||
from opentelemetry.propagate import extract
|
|
||||||
from opentelemetry.propagators.textmap import Getter
|
|
||||||
from opentelemetry.trace import Link, SpanKind, Status, StatusCode, get_current_span
|
|
||||||
|
|
||||||
from .telemetry_registry import (
|
|
||||||
DB_NAMESPACE,
|
|
||||||
DB_SYSTEM,
|
|
||||||
ERROR_TYPE,
|
|
||||||
HTTP_REQUEST_METHOD,
|
|
||||||
HTTP_RESPONSE_STATUS_CODE,
|
|
||||||
INTERNAL_CLIENT,
|
|
||||||
M_CONNECTIONS_OPEN,
|
|
||||||
M_OPERATION_DURATION,
|
|
||||||
M_QUERIES_INTERRUPTED,
|
|
||||||
M_QUERIES_PENDING,
|
|
||||||
M_THREADS_LIMIT,
|
|
||||||
M_THREADS_QUEUE_DEPTH,
|
|
||||||
M_WRITE_QUEUE_DEPTH,
|
|
||||||
M_WRITE_QUEUE_WAIT,
|
|
||||||
OPERATION,
|
|
||||||
SERVER_ADDRESS,
|
|
||||||
URL_PATH,
|
|
||||||
URL_SCHEME,
|
|
||||||
USER_AGENT_ORIGINAL,
|
|
||||||
)
|
|
||||||
from .version import __version__
|
|
||||||
|
|
||||||
# True while code is executing within a datasette.client request. Defined
|
|
||||||
# here rather than in app.py to avoid a circular import.
|
|
||||||
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
|
||||||
|
|
||||||
# The semantic conventions version matching the attribute names used here.
|
|
||||||
# 1.30.0 renamed `db.system` to `db.system.name`, so update this when
|
|
||||||
# renaming attributes to match a newer version.
|
|
||||||
SCHEMA_URL = "https://opentelemetry.io/schemas/1.29.0"
|
|
||||||
|
|
||||||
tracer = otel_trace.get_tracer("datasette", __version__, schema_url=SCHEMA_URL)
|
|
||||||
meter = otel_metrics.get_meter("datasette", __version__, schema_url=SCHEMA_URL)
|
|
||||||
|
|
||||||
MAX_SQL_LENGTH = 2048
|
|
||||||
|
|
||||||
|
|
||||||
def sql_attribute(sql: str) -> str:
|
|
||||||
"Truncate SQL text so it is safe to attach to a span as an attribute."
|
|
||||||
sql = sql.strip()
|
|
||||||
if len(sql) <= MAX_SQL_LENGTH:
|
|
||||||
return sql
|
|
||||||
return sql[:MAX_SQL_LENGTH] + "…[truncated]"
|
|
||||||
|
|
||||||
|
|
||||||
def callback_name(fn) -> str:
|
|
||||||
"""
|
|
||||||
The name recorded as `datasette.callback` for a callback-style call.
|
|
||||||
|
|
||||||
Falls back to the type name for callables such as `functools.partial`
|
|
||||||
that have no `__qualname__`.
|
|
||||||
"""
|
|
||||||
return getattr(fn, "__qualname__", type(fn).__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def linked_root_span_kwargs(context=None):
|
|
||||||
"""
|
|
||||||
Keyword arguments that start a new root span with a ``Link`` back to
|
|
||||||
the current span.
|
|
||||||
|
|
||||||
Use this for work that can outlive the span that caused it, such as a
|
|
||||||
background task or a ``block=False`` write.
|
|
||||||
|
|
||||||
Pass ``context`` to link to the span in a previously captured context
|
|
||||||
instead of the current one. If there is no valid span, no link is added.
|
|
||||||
|
|
||||||
Works with any tracer::
|
|
||||||
|
|
||||||
with my_tracer.start_as_current_span(
|
|
||||||
"myplugin.job", **linked_root_span_kwargs()
|
|
||||||
):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
cause = get_current_span(context).get_span_context()
|
|
||||||
links = [Link(cause)] if cause.is_valid else []
|
|
||||||
return {"context": otel_context_api.Context(), "links": links}
|
|
||||||
|
|
||||||
|
|
||||||
# Keywords that can be recorded as db.operation.name. SQL can be supplied by
|
|
||||||
# users, so an allowlist keeps the number of distinct values small.
|
|
||||||
DB_OPERATION_ALLOWLIST = frozenset(
|
|
||||||
{
|
|
||||||
"SELECT",
|
|
||||||
"INSERT",
|
|
||||||
"UPDATE",
|
|
||||||
"DELETE",
|
|
||||||
"CREATE",
|
|
||||||
"DROP",
|
|
||||||
"ALTER",
|
|
||||||
"PRAGMA",
|
|
||||||
"EXPLAIN",
|
|
||||||
"REPLACE",
|
|
||||||
"VACUUM",
|
|
||||||
"ANALYZE",
|
|
||||||
"WITH",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_LEADING_KEYWORD = re.compile(r"^\s*([A-Za-z]+)")
|
|
||||||
|
|
||||||
|
|
||||||
def sql_operation_name(sql: str) -> str | None:
|
|
||||||
"""
|
|
||||||
The statement's leading keyword if it is in the allowlist, else None.
|
|
||||||
|
|
||||||
Statements that start with a comment or "(" return None. Statements
|
|
||||||
starting with a CTE return `WITH`. Only call this for a single statement.
|
|
||||||
"""
|
|
||||||
match = _LEADING_KEYWORD.match(sql)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
keyword = match.group(1).upper()
|
|
||||||
if keyword in DB_OPERATION_ALLOWLIST:
|
|
||||||
return keyword
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --- The HTTP request span ------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class _ScopeHeadersGetter(Getter):
|
|
||||||
"Read W3C trace context from an ASGI scope's headers."
|
|
||||||
|
|
||||||
def get(self, carrier, key):
|
|
||||||
wanted = key.lower().encode("latin-1")
|
|
||||||
values = [v.decode("latin-1") for k, v in carrier if k.lower() == wanted]
|
|
||||||
return values or None
|
|
||||||
|
|
||||||
def keys(self, carrier):
|
|
||||||
return [k.decode("latin-1") for k, _ in carrier]
|
|
||||||
|
|
||||||
|
|
||||||
_HEADERS_GETTER = _ScopeHeadersGetter()
|
|
||||||
|
|
||||||
|
|
||||||
# Methods defined by RFC 9110 plus PATCH (RFC 5789). Anything else is
|
|
||||||
# recorded as `_OTHER`, as recommended by semantic conventions.
|
|
||||||
_KNOWN_METHODS = frozenset(
|
|
||||||
{"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def clamp_http_method(method):
|
|
||||||
"The request method if it is one we recognise, else ``_OTHER``."
|
|
||||||
method = (method or "").upper()
|
|
||||||
return method if method in _KNOWN_METHODS else "_OTHER"
|
|
||||||
|
|
||||||
|
|
||||||
def _first_header(headers, name):
|
|
||||||
"The first value of a header, decoded, or None."
|
|
||||||
for key, value in headers:
|
|
||||||
if key.lower() == name:
|
|
||||||
return value.decode("latin-1")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _url_path(scope):
|
|
||||||
"""
|
|
||||||
The request path, with any query string removed.
|
|
||||||
|
|
||||||
Prefers `raw_path`, which preserves encoded slashes in database and
|
|
||||||
table names. Some clients include the query string in `raw_path`, so
|
|
||||||
that is stripped as well.
|
|
||||||
"""
|
|
||||||
raw_path = scope.get("raw_path")
|
|
||||||
if raw_path:
|
|
||||||
if isinstance(raw_path, bytes):
|
|
||||||
raw_path = raw_path.decode("latin-1")
|
|
||||||
return raw_path.split("?", 1)[0]
|
|
||||||
return scope.get("path", "")
|
|
||||||
|
|
||||||
|
|
||||||
# The request span is passed to the router in the ASGI scope, because a
|
|
||||||
# plugin's asgi_wrapper() middleware may have made its own span current.
|
|
||||||
# Absent if the span is not recording.
|
|
||||||
REQUEST_SPAN_SCOPE_KEY = "datasette.telemetry.request_span"
|
|
||||||
|
|
||||||
|
|
||||||
def request_span(scope):
|
|
||||||
"""
|
|
||||||
The recording request span for an ASGI scope, or None.
|
|
||||||
|
|
||||||
Falls back to the current span, for when Datasette is running under
|
|
||||||
other instrumentation.
|
|
||||||
"""
|
|
||||||
span = scope.get(REQUEST_SPAN_SCOPE_KEY)
|
|
||||||
if span is None:
|
|
||||||
span = otel_trace.get_current_span()
|
|
||||||
return span if span.is_recording() else None
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryMiddleware:
|
|
||||||
"""
|
|
||||||
One `SpanKind.SERVER` span per HTTP request.
|
|
||||||
|
|
||||||
The span ends after the full response, including any streamed body,
|
|
||||||
has been sent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, app):
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
|
||||||
# Pass lifespan and websocket scopes straight through
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
headers = scope.get("headers") or []
|
|
||||||
# Uses the global propagator, configured with OTEL_PROPAGATORS
|
|
||||||
context = extract(headers, getter=_HEADERS_GETTER)
|
|
||||||
method = clamp_http_method(scope.get("method", ""))
|
|
||||||
# Renamed to include the route once routing has happened
|
|
||||||
with tracer.start_as_current_span(
|
|
||||||
method, context=context, kind=SpanKind.SERVER
|
|
||||||
) as span:
|
|
||||||
if not span.is_recording():
|
|
||||||
# No provider installed, or the trace was not sampled
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
span.set_attribute(HTTP_REQUEST_METHOD, method)
|
|
||||||
span.set_attribute(URL_PATH, _url_path(scope))
|
|
||||||
scheme = scope.get("scheme")
|
|
||||||
if scheme:
|
|
||||||
span.set_attribute(URL_SCHEME, scheme)
|
|
||||||
host = _first_header(headers, b"host")
|
|
||||||
if host:
|
|
||||||
span.set_attribute(SERVER_ADDRESS, host)
|
|
||||||
user_agent = _first_header(headers, b"user-agent")
|
|
||||||
if user_agent:
|
|
||||||
span.set_attribute(USER_AGENT_ORIGINAL, user_agent)
|
|
||||||
if _in_datasette_client.get():
|
|
||||||
span.set_attribute(INTERNAL_CLIENT, True)
|
|
||||||
|
|
||||||
scope = dict(scope, **{REQUEST_SPAN_SCOPE_KEY: span})
|
|
||||||
|
|
||||||
# Some responses are sent without a Response object, so the
|
|
||||||
# status is captured by wrapping send()
|
|
||||||
status_holder = {}
|
|
||||||
|
|
||||||
async def wrapped_send(message):
|
|
||||||
if (
|
|
||||||
message["type"] == "http.response.start"
|
|
||||||
and "status" not in status_holder
|
|
||||||
):
|
|
||||||
status_holder["status"] = message["status"]
|
|
||||||
await send(message)
|
|
||||||
|
|
||||||
escaped = False
|
|
||||||
try:
|
|
||||||
await self.app(scope, receive, wrapped_send)
|
|
||||||
except BaseException as exception:
|
|
||||||
# Includes asyncio.CancelledError when a client disconnects
|
|
||||||
escaped = True
|
|
||||||
span.set_attribute(ERROR_TYPE, type(exception).__name__)
|
|
||||||
span.set_status(Status(StatusCode.ERROR, str(exception)))
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
status = status_holder.get("status")
|
|
||||||
if status is not None:
|
|
||||||
span.set_attribute(HTTP_RESPONSE_STATUS_CODE, status)
|
|
||||||
# 4xx responses are not errors for a server span. If an
|
|
||||||
# exception escaped, keep its class name as error.type.
|
|
||||||
if status >= 500 and not escaped:
|
|
||||||
span.set_status(Status(StatusCode.ERROR))
|
|
||||||
span.set_attribute(ERROR_TYPE, str(status))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Metrics --------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _duration_attributes(database_name, operation):
|
|
||||||
return {
|
|
||||||
DB_SYSTEM: "sqlite",
|
|
||||||
DB_NAMESPACE: database_name,
|
|
||||||
OPERATION: operation,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Instruments use plain text descriptions. The registry entries have longer
|
|
||||||
# reStructuredText descriptions for the documentation.
|
|
||||||
|
|
||||||
sql_operation_duration = meter.create_histogram(
|
|
||||||
M_OPERATION_DURATION,
|
|
||||||
unit=M_OPERATION_DURATION.unit,
|
|
||||||
description="Duration of a SQL operation issued by Datasette",
|
|
||||||
explicit_bucket_boundaries_advisory=M_OPERATION_DURATION.buckets,
|
|
||||||
)
|
|
||||||
|
|
||||||
write_queue_wait = meter.create_histogram(
|
|
||||||
M_WRITE_QUEUE_WAIT,
|
|
||||||
unit=M_WRITE_QUEUE_WAIT.unit,
|
|
||||||
description=(
|
|
||||||
"Time a write spent queued behind the single write thread for its database"
|
|
||||||
),
|
|
||||||
explicit_bucket_boundaries_advisory=M_WRITE_QUEUE_WAIT.buckets,
|
|
||||||
)
|
|
||||||
|
|
||||||
queries_interrupted = meter.create_counter(
|
|
||||||
M_QUERIES_INTERRUPTED,
|
|
||||||
unit=M_QUERIES_INTERRUPTED.unit,
|
|
||||||
description="Queries cancelled for exceeding sql_time_limit_ms",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def record_operation_duration(database_name, operation):
|
|
||||||
"""
|
|
||||||
Record `db.client.operation.duration` for one SQL operation.
|
|
||||||
|
|
||||||
Sets `error.type` to the exception class on failure. For a `block=False`
|
|
||||||
write this measures the time taken to enqueue the write.
|
|
||||||
"""
|
|
||||||
attributes = _duration_attributes(database_name, operation)
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
except BaseException as exception:
|
|
||||||
attributes[ERROR_TYPE] = type(exception).__qualname__
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
sql_operation_duration.record(time.perf_counter() - started, attributes)
|
|
||||||
|
|
||||||
|
|
||||||
def record_write_queue_wait(database_name, waited_ns):
|
|
||||||
write_queue_wait.record(waited_ns / 1e9, {DB_NAMESPACE: database_name})
|
|
||||||
|
|
||||||
|
|
||||||
def record_query_interrupted(database_name):
|
|
||||||
queries_interrupted.add(1, {DB_NAMESPACE: database_name})
|
|
||||||
|
|
||||||
|
|
||||||
# Live Datasette instances reported by the gauges below. The lock is needed
|
|
||||||
# because gauge callbacks run on the SDK's collection thread.
|
|
||||||
#
|
|
||||||
# The pool gauges do not identify which instance they came from, so they
|
|
||||||
# are only meaningful for a process running a single Datasette instance.
|
|
||||||
_live_datasettes = weakref.WeakSet()
|
|
||||||
_live_datasettes_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def register_datasette(ds):
|
|
||||||
"Start reporting pool/queue gauges for this Datasette instance."
|
|
||||||
with _live_datasettes_lock:
|
|
||||||
_live_datasettes.add(ds)
|
|
||||||
|
|
||||||
|
|
||||||
def unregister_datasette(ds):
|
|
||||||
"Stop reporting gauges for an instance that has been closed."
|
|
||||||
with _live_datasettes_lock:
|
|
||||||
_live_datasettes.discard(ds)
|
|
||||||
|
|
||||||
|
|
||||||
def _live_instances():
|
|
||||||
with _live_datasettes_lock:
|
|
||||||
return list(_live_datasettes)
|
|
||||||
|
|
||||||
|
|
||||||
def _databases_of(ds):
|
|
||||||
"Every Database attached to an instance, including the internal database."
|
|
||||||
databases = list(ds.databases.values())
|
|
||||||
internal = getattr(ds, "_internal_database", None)
|
|
||||||
if internal is not None:
|
|
||||||
databases.append(internal)
|
|
||||||
return databases
|
|
||||||
|
|
||||||
|
|
||||||
def observe_sql_thread_limit(options=None):
|
|
||||||
"Size of the shared read-query thread pool (the num_sql_threads setting)."
|
|
||||||
for ds in _live_instances():
|
|
||||||
if ds.executor is None:
|
|
||||||
# num_sql_threads=0 - queries run on the event loop, no pool.
|
|
||||||
continue
|
|
||||||
yield otel_metrics.Observation(ds.setting("num_sql_threads"), {})
|
|
||||||
|
|
||||||
|
|
||||||
def observe_sql_thread_queue_depth(options=None):
|
|
||||||
"""
|
|
||||||
Read queries waiting for a free thread in the shared pool.
|
|
||||||
|
|
||||||
`_work_queue` is a private attribute of ThreadPoolExecutor, so this
|
|
||||||
reports nothing if it is missing.
|
|
||||||
"""
|
|
||||||
for ds in _live_instances():
|
|
||||||
if ds.executor is None:
|
|
||||||
continue
|
|
||||||
work_queue = getattr(ds.executor, "_work_queue", None)
|
|
||||||
if work_queue is None:
|
|
||||||
continue
|
|
||||||
yield otel_metrics.Observation(work_queue.qsize(), {})
|
|
||||||
|
|
||||||
|
|
||||||
def observe_pending_queries(options=None):
|
|
||||||
"""
|
|
||||||
Read queries submitted to the pool and not yet finished, per database.
|
|
||||||
|
|
||||||
Reads `len()` without `_pending_execute_futures_lock` to avoid blocking
|
|
||||||
queries.
|
|
||||||
"""
|
|
||||||
for ds in _live_instances():
|
|
||||||
for db in _databases_of(ds):
|
|
||||||
yield otel_metrics.Observation(
|
|
||||||
len(db._pending_execute_futures), {DB_NAMESPACE: db.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def observe_write_queue_depth(options=None):
|
|
||||||
"Writes queued behind the single write thread, per database."
|
|
||||||
for ds in _live_instances():
|
|
||||||
for db in _databases_of(ds):
|
|
||||||
write_queue = db._write_queue
|
|
||||||
if write_queue is None:
|
|
||||||
# No write has ever been queued for this database.
|
|
||||||
continue
|
|
||||||
yield otel_metrics.Observation(write_queue.qsize(), {DB_NAMESPACE: db.name})
|
|
||||||
|
|
||||||
|
|
||||||
def observe_open_connections(options=None):
|
|
||||||
"Open SQLite connections tracked for closing, per database."
|
|
||||||
for ds in _live_instances():
|
|
||||||
for db in _databases_of(ds):
|
|
||||||
yield otel_metrics.Observation(
|
|
||||||
len(db._all_connections), {DB_NAMESPACE: db.name}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
sql_thread_limit_gauge = meter.create_observable_gauge(
|
|
||||||
M_THREADS_LIMIT,
|
|
||||||
callbacks=[observe_sql_thread_limit],
|
|
||||||
unit=M_THREADS_LIMIT.unit,
|
|
||||||
description="Maximum concurrent read queries (the num_sql_threads setting)",
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_thread_queue_depth_gauge = meter.create_observable_gauge(
|
|
||||||
M_THREADS_QUEUE_DEPTH,
|
|
||||||
callbacks=[observe_sql_thread_queue_depth],
|
|
||||||
unit=M_THREADS_QUEUE_DEPTH.unit,
|
|
||||||
description="Read queries waiting for a free thread in the shared SQL pool",
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_queries_gauge = meter.create_observable_gauge(
|
|
||||||
M_QUERIES_PENDING,
|
|
||||||
callbacks=[observe_pending_queries],
|
|
||||||
unit=M_QUERIES_PENDING.unit,
|
|
||||||
description="Read queries submitted to the pool and not yet complete",
|
|
||||||
)
|
|
||||||
|
|
||||||
write_queue_depth_gauge = meter.create_observable_gauge(
|
|
||||||
M_WRITE_QUEUE_DEPTH,
|
|
||||||
callbacks=[observe_write_queue_depth],
|
|
||||||
unit=M_WRITE_QUEUE_DEPTH.unit,
|
|
||||||
description="Writes queued behind a database's single write thread",
|
|
||||||
)
|
|
||||||
|
|
||||||
open_connections_gauge = meter.create_observable_gauge(
|
|
||||||
M_CONNECTIONS_OPEN,
|
|
||||||
callbacks=[observe_open_connections],
|
|
||||||
unit=M_CONNECTIONS_OPEN.unit,
|
|
||||||
description="Open SQLite connections tracked for closing",
|
|
||||||
)
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
"""
|
|
||||||
Every span, metric and attribute that Datasette emits.
|
|
||||||
|
|
||||||
These entries are used by the instrumentation code, by `docs/telemetry_doc.py`
|
|
||||||
to generate the documentation, and by `tests/test_telemetry_registry.py` to
|
|
||||||
check that the emitted telemetry matches the registry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from opentelemetry.trace import SpanKind
|
|
||||||
|
|
||||||
|
|
||||||
class Attribute(str):
|
|
||||||
"""
|
|
||||||
A span attribute key, carrying its own documentation.
|
|
||||||
|
|
||||||
Subclasses `str` so it can be handed straight to `set_attribute()`.
|
|
||||||
|
|
||||||
Part of Datasette's public plugin API - plugins declare their own
|
|
||||||
telemetry registries with these classes. See the "Telemetry for plugin
|
|
||||||
authors" documentation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("description", "optional", "values")
|
|
||||||
|
|
||||||
def __new__(cls, name, description, optional=False, values=None):
|
|
||||||
self = super().__new__(cls, name)
|
|
||||||
self.description = description
|
|
||||||
self.optional = optional
|
|
||||||
# The allowed values for this attribute, or None to allow any value
|
|
||||||
self.values = frozenset(values) if values is not None else None
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __reduce__(self):
|
|
||||||
# Copies and pickles become a plain str, since __new__ requires the
|
|
||||||
# extra arguments. ConsoleMetricExporter deepcopies attribute keys.
|
|
||||||
return (str, (str(self),))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"Attribute({str(self)!r})"
|
|
||||||
|
|
||||||
|
|
||||||
class SpanName(str):
|
|
||||||
"""A span name, carrying its documentation and the attributes it may set.
|
|
||||||
|
|
||||||
Part of Datasette's public plugin API, like `Attribute`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("attributes", "description", "dynamic", "kind", "prefix")
|
|
||||||
|
|
||||||
def __new__(
|
|
||||||
cls,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
attributes=(),
|
|
||||||
prefix=False,
|
|
||||||
dynamic=False,
|
|
||||||
kind=SpanKind.INTERNAL,
|
|
||||||
):
|
|
||||||
self = super().__new__(cls, name)
|
|
||||||
self.description = description
|
|
||||||
self.attributes = tuple(attributes)
|
|
||||||
# Match emitted names that start with this prefix, for names with a
|
|
||||||
# variable suffix such as SpanName("chat ", ..., prefix=True)
|
|
||||||
self.prefix = prefix
|
|
||||||
# The emitted name is built at runtime, so `span_for()` matches it by
|
|
||||||
# span kind. The entry's string is a template for the documentation.
|
|
||||||
self.dynamic = dynamic
|
|
||||||
self.kind = kind
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __reduce__(self):
|
|
||||||
# See Attribute.__reduce__.
|
|
||||||
return (str, (str(self),))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"SpanName({str(self)!r})"
|
|
||||||
|
|
||||||
|
|
||||||
class MetricName(str):
|
|
||||||
"A metric name, carrying its instrument kind, unit and attributes."
|
|
||||||
|
|
||||||
__slots__ = ("attributes", "buckets", "description", "kind", "unit")
|
|
||||||
|
|
||||||
def __new__(cls, name, kind, unit, description, attributes=(), buckets=None):
|
|
||||||
self = super().__new__(cls, name)
|
|
||||||
self.kind = kind
|
|
||||||
self.unit = unit
|
|
||||||
self.description = description
|
|
||||||
self.attributes = tuple(attributes)
|
|
||||||
# Explicit bucket boundaries, for histograms only
|
|
||||||
self.buckets = tuple(buckets) if buckets is not None else None
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __reduce__(self):
|
|
||||||
# See Attribute.__reduce__.
|
|
||||||
return (str, (str(self),))
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"MetricName({str(self)!r})"
|
|
||||||
|
|
||||||
|
|
||||||
COUNTER = "Counter"
|
|
||||||
UPDOWN_COUNTER = "UpDownCounter"
|
|
||||||
HISTOGRAM = "Histogram"
|
|
||||||
GAUGE = "Observable gauge"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Attributes -----------------------------------------------------------
|
|
||||||
|
|
||||||
HTTP_REQUEST_METHOD = Attribute(
|
|
||||||
"http.request.method",
|
|
||||||
"The HTTP request method. Methods outside the nine defined by RFC 9110 "
|
|
||||||
"and RFC 5789 are recorded as ``_OTHER``.",
|
|
||||||
)
|
|
||||||
HTTP_RESPONSE_STATUS_CODE = Attribute(
|
|
||||||
"http.response.status_code",
|
|
||||||
"The HTTP response status code. Omitted if no response was started.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
HTTP_ROUTE = Attribute(
|
|
||||||
"http.route",
|
|
||||||
"The regular expression for the matched route, for example "
|
|
||||||
"``/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)(\\.(?P<format>\\w+))?$`` "
|
|
||||||
"for a table page. Use this attribute to group requests by route. "
|
|
||||||
"Omitted when no route matches.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
URL_PATH = Attribute(
|
|
||||||
"url.path",
|
|
||||||
"The URL path, excluding the query string.",
|
|
||||||
)
|
|
||||||
URL_SCHEME = Attribute("url.scheme", "``http`` or ``https``.")
|
|
||||||
SERVER_ADDRESS = Attribute(
|
|
||||||
"server.address",
|
|
||||||
"The ``Host`` header, including any ``:port`` suffix. This value is "
|
|
||||||
"supplied by the client.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
USER_AGENT_ORIGINAL = Attribute(
|
|
||||||
"user_agent.original",
|
|
||||||
"The ``User-Agent`` header, verbatim. Omitted if the client sent none.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
INTERNAL_CLIENT = Attribute(
|
|
||||||
"datasette.internal_client",
|
|
||||||
"``True`` for requests made through ``datasette.client``. Calls made "
|
|
||||||
"inside another request produce a nested ``SERVER`` span. Filter on "
|
|
||||||
"this attribute to exclude internal requests from request counts. "
|
|
||||||
"Omitted for requests received over the network.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
ERROR_TYPE = Attribute(
|
|
||||||
"error.type",
|
|
||||||
"The exception class name for a failed operation. On HTTP spans, also "
|
|
||||||
"set to the status code as a string for 5xx responses. A 4xx response "
|
|
||||||
"alone does not set this attribute or an error status.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_SYSTEM = Attribute("db.system", "Always ``sqlite``.")
|
|
||||||
DB_NAMESPACE = Attribute("db.namespace", "Name of the database being queried.")
|
|
||||||
OPERATION = Attribute(
|
|
||||||
"datasette.operation",
|
|
||||||
"Whether the operation was a read or a write.",
|
|
||||||
values={"read", "write"},
|
|
||||||
)
|
|
||||||
DB_QUERY_TEXT = Attribute(
|
|
||||||
"db.query.text",
|
|
||||||
"The SQL, truncated to 2048 characters. Bound parameter values are not "
|
|
||||||
"recorded. For callback methods, ``datasette.callback`` is recorded instead.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
CALLBACK = Attribute(
|
|
||||||
"datasette.callback",
|
|
||||||
"The qualified name of the Python callable passed to ``execute_fn()``, "
|
|
||||||
"``execute_write_fn()`` or ``execute_isolated_fn()``, for example "
|
|
||||||
"``TableInsertView.post.<locals>.insert_or_upsert_rows``. Set instead of "
|
|
||||||
"``db.query.text``. Lambdas appear as ``<lambda>``; use a named function "
|
|
||||||
"for a more descriptive span.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
DB_OPERATION_NAME = Attribute(
|
|
||||||
"db.operation.name",
|
|
||||||
"The statement's leading keyword, such as ``SELECT``, ``INSERT`` or "
|
|
||||||
"``CREATE``, if it matches the supported allowlist. Statements beginning "
|
|
||||||
"with a common table expression report ``WITH``. Omitted for unrecognized "
|
|
||||||
"keywords and ``execute_write_script()``.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
PARAM_COUNT = Attribute(
|
|
||||||
"datasette.param_count",
|
|
||||||
"Number of bound parameters. Recorded instead of the values themselves.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
PARAM_SETS = Attribute(
|
|
||||||
"datasette.param_sets",
|
|
||||||
"Number of parameter sets consumed by ``execute_write_many()``. "
|
|
||||||
"The parameter values are not recorded.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
TIME_LIMIT_MS = Attribute(
|
|
||||||
"datasette.time_limit_ms",
|
|
||||||
"Time limit applied to the read query, in milliseconds: "
|
|
||||||
":ref:`setting_sql_time_limit_ms` or a shorter ``custom_time_limit``.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
ROWS_RETURNED = Attribute(
|
|
||||||
"datasette.rows_returned",
|
|
||||||
"Number of rows returned by a successful read query.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
TRUNCATED = Attribute(
|
|
||||||
"datasette.truncated",
|
|
||||||
"True if the result was cut short by :ref:`setting_max_returned_rows`.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
INTERRUPTED = Attribute(
|
|
||||||
"datasette.interrupted",
|
|
||||||
"True if the query exceeded its time limit. The span status is set to "
|
|
||||||
"``ERROR`` unless the caller used a ``custom_time_limit`` shorter than "
|
|
||||||
":ref:`setting_sql_time_limit_ms`, in which case the status is left unset.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
SQL_ERROR_SUPPRESSED = Attribute(
|
|
||||||
"datasette.sql_error_suppressed",
|
|
||||||
"True for a non-timeout SQL error with ``log_sql_errors=False``. The "
|
|
||||||
"exception is still raised, but the span status is left unset.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
EXECUTESCRIPT = Attribute(
|
|
||||||
"datasette.executescript",
|
|
||||||
"True for ``execute_write_script()``, which runs multiple statements.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
EXECUTEMANY = Attribute(
|
|
||||||
"datasette.executemany",
|
|
||||||
"True for ``execute_write_many()``, which runs one statement against many "
|
|
||||||
"parameter sets.",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
ISOLATED_CONNECTION = Attribute(
|
|
||||||
"datasette.isolated_connection",
|
|
||||||
"True if the write ran on its own connection rather than the shared write "
|
|
||||||
"connection.",
|
|
||||||
)
|
|
||||||
TRANSACTION = Attribute(
|
|
||||||
"datasette.transaction",
|
|
||||||
"False for statements such as ``VACUUM`` that cannot run inside a transaction.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Spans ----------------------------------------------------------------
|
|
||||||
|
|
||||||
HTTP_REQUEST = SpanName(
|
|
||||||
"{http.request.method} {http.route}",
|
|
||||||
"One span per HTTP request, containing spans from plugin middleware and "
|
|
||||||
"database operations. Named for the HTTP method and matched route, or "
|
|
||||||
"just the method if no route matches. Incoming ``traceparent`` headers "
|
|
||||||
"are extracted using the global propagator to continue the caller's "
|
|
||||||
"trace. Incoming ``baggage`` is not propagated into plugin or downstream "
|
|
||||||
"context in this release. Set ``OTEL_PROPAGATORS=none`` to disable "
|
|
||||||
"extraction. For public instances, strip trace context headers at your "
|
|
||||||
"proxy if callers should not supply trace context.",
|
|
||||||
(
|
|
||||||
HTTP_REQUEST_METHOD,
|
|
||||||
HTTP_ROUTE,
|
|
||||||
URL_PATH,
|
|
||||||
URL_SCHEME,
|
|
||||||
SERVER_ADDRESS,
|
|
||||||
USER_AGENT_ORIGINAL,
|
|
||||||
HTTP_RESPONSE_STATUS_CODE,
|
|
||||||
ERROR_TYPE,
|
|
||||||
INTERNAL_CLIENT,
|
|
||||||
),
|
|
||||||
dynamic=True,
|
|
||||||
kind=SpanKind.SERVER,
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_QUERY = SpanName(
|
|
||||||
"db.query",
|
|
||||||
"A SQL operation, including time spent queued for a worker thread. For "
|
|
||||||
"``block=False`` writes, the span ends after the write is queued. "
|
|
||||||
"Callback methods record ``datasette.callback`` in place of ``db.query.text``.",
|
|
||||||
(
|
|
||||||
DB_SYSTEM,
|
|
||||||
DB_NAMESPACE,
|
|
||||||
DB_QUERY_TEXT,
|
|
||||||
CALLBACK,
|
|
||||||
DB_OPERATION_NAME,
|
|
||||||
PARAM_COUNT,
|
|
||||||
PARAM_SETS,
|
|
||||||
TIME_LIMIT_MS,
|
|
||||||
ROWS_RETURNED,
|
|
||||||
TRUNCATED,
|
|
||||||
INTERRUPTED,
|
|
||||||
SQL_ERROR_SUPPRESSED,
|
|
||||||
EXECUTESCRIPT,
|
|
||||||
EXECUTEMANY,
|
|
||||||
),
|
|
||||||
kind=SpanKind.CLIENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_QUERY_EXECUTE = SpanName(
|
|
||||||
"db.query.execute",
|
|
||||||
"The read executing inside a SQL worker thread. Child of ``db.query``; the "
|
|
||||||
"gap between the two is time spent waiting for a thread.",
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_WRITE_QUEUE_WAIT = SpanName(
|
|
||||||
"db.write.queue_wait",
|
|
||||||
"Time a write spent waiting in its database's write queue. For "
|
|
||||||
"``block=True``, this is a child of ``db.query``. For ``block=False``, "
|
|
||||||
"it is a root span linked to the span that queued the write, since the "
|
|
||||||
"write can outlive that request.",
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_WRITE_EXECUTE = SpanName(
|
|
||||||
"db.write.execute",
|
|
||||||
"The write executing on the write thread. For ``block=True``, this is "
|
|
||||||
"a child of ``db.query``. For ``block=False``, it is a root span linked "
|
|
||||||
"to the span that queued the write.",
|
|
||||||
(ISOLATED_CONNECTION, TRANSACTION),
|
|
||||||
)
|
|
||||||
|
|
||||||
STARTUP = SpanName(
|
|
||||||
"datasette.startup",
|
|
||||||
"Startup work performed by ``invoke_startup()``, including registration "
|
|
||||||
"hooks, schema catalog updates, saved queries, column type configuration "
|
|
||||||
"and the ``startup`` hook. Runs during instance startup, either before "
|
|
||||||
"serving requests or as part of the first request.",
|
|
||||||
)
|
|
||||||
|
|
||||||
SPANS = (
|
|
||||||
HTTP_REQUEST,
|
|
||||||
DB_QUERY,
|
|
||||||
DB_QUERY_EXECUTE,
|
|
||||||
DB_WRITE_QUEUE_WAIT,
|
|
||||||
DB_WRITE_EXECUTE,
|
|
||||||
STARTUP,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def span_for(emitted_name, kind=None, spans=None):
|
|
||||||
"""
|
|
||||||
Resolve an emitted span name to its registry entry, or None.
|
|
||||||
|
|
||||||
Exact matches take precedence over `prefix=True` entries, which take
|
|
||||||
precedence over `dynamic=True` entries matched by `kind`.
|
|
||||||
|
|
||||||
`spans` defaults to Datasette's own registry.
|
|
||||||
"""
|
|
||||||
if spans is None:
|
|
||||||
spans = SPANS
|
|
||||||
for span in spans:
|
|
||||||
if span.dynamic:
|
|
||||||
continue
|
|
||||||
if emitted_name == span:
|
|
||||||
return span
|
|
||||||
for span in spans:
|
|
||||||
if span.prefix and emitted_name.startswith(span):
|
|
||||||
return span
|
|
||||||
if kind is not None:
|
|
||||||
for span in spans:
|
|
||||||
if span.dynamic and span.kind == kind:
|
|
||||||
return span
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def metric_for(emitted_name, metrics=None):
|
|
||||||
"""
|
|
||||||
Resolve an emitted metric name to its registry entry, or None.
|
|
||||||
|
|
||||||
`metrics` defaults to Datasette's own registry.
|
|
||||||
"""
|
|
||||||
if metrics is None:
|
|
||||||
metrics = METRICS
|
|
||||||
for metric in metrics:
|
|
||||||
if emitted_name == metric:
|
|
||||||
return metric
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def attribute_allowed(entry, emitted_key):
|
|
||||||
"""
|
|
||||||
Whether `emitted_key` is a registered attribute of `entry`.
|
|
||||||
|
|
||||||
`entry` is a `SpanName` or a `MetricName` - both carry `.attributes`.
|
|
||||||
"""
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
return emitted_key in entry.attributes
|
|
||||||
|
|
||||||
|
|
||||||
def attribute_value_allowed(entry, emitted_key, value):
|
|
||||||
"""
|
|
||||||
Whether `value` is permitted for `emitted_key` on `entry` (a `SpanName`
|
|
||||||
or a `MetricName`).
|
|
||||||
|
|
||||||
Any value is allowed if the attribute does not declare `values=`.
|
|
||||||
"""
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
for attribute in entry.attributes:
|
|
||||||
if attribute == emitted_key:
|
|
||||||
return attribute.values is None or value in attribute.values
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# --- Metrics --------------------------------------------------------------
|
|
||||||
|
|
||||||
# Bucket boundaries in seconds for every duration histogram. OpenTelemetry's
|
|
||||||
# defaults are designed for milliseconds and would put almost every SQLite
|
|
||||||
# query in the first bucket. These are the semantic conventions' recommended
|
|
||||||
# boundaries for db.client.operation.duration, plus 0.0001 and 0.0005 for
|
|
||||||
# fast in-process SQLite queries.
|
|
||||||
DURATION_BUCKETS = (0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10)
|
|
||||||
|
|
||||||
M_OPERATION_DURATION = MetricName(
|
|
||||||
"db.client.operation.duration",
|
|
||||||
HISTOGRAM,
|
|
||||||
"s",
|
|
||||||
"Duration of a SQL operation, including callback-based calls such as "
|
|
||||||
"``execute_fn()``. For ``block=False`` writes, measures enqueue time.",
|
|
||||||
(DB_SYSTEM, DB_NAMESPACE, OPERATION, ERROR_TYPE),
|
|
||||||
buckets=DURATION_BUCKETS,
|
|
||||||
)
|
|
||||||
|
|
||||||
M_WRITE_QUEUE_WAIT = MetricName(
|
|
||||||
"datasette.write.queue_wait",
|
|
||||||
HISTOGRAM,
|
|
||||||
"s",
|
|
||||||
"Time each write waited in its database's write queue.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
buckets=DURATION_BUCKETS,
|
|
||||||
)
|
|
||||||
|
|
||||||
M_QUERIES_INTERRUPTED = MetricName(
|
|
||||||
"datasette.sql.queries.interrupted",
|
|
||||||
COUNTER,
|
|
||||||
"{query}",
|
|
||||||
"Queries cancelled for exceeding :ref:`setting_sql_time_limit_ms`. A "
|
|
||||||
"rising rate can indicate that queries need optimization or a higher "
|
|
||||||
"time limit. Caller-selected timeouts shorter than this limit, such as "
|
|
||||||
"those used for facet suggestion, are excluded.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
M_THREADS_LIMIT = MetricName(
|
|
||||||
"datasette.sql.threads.limit",
|
|
||||||
GAUGE,
|
|
||||||
"{thread}",
|
|
||||||
"Maximum concurrent read queries, configured by "
|
|
||||||
":ref:`setting_num_sql_threads`. Not reported when ``num_sql_threads`` "
|
|
||||||
"is ``0``.",
|
|
||||||
)
|
|
||||||
|
|
||||||
M_THREADS_QUEUE_DEPTH = MetricName(
|
|
||||||
"datasette.sql.threads.queue_depth",
|
|
||||||
GAUGE,
|
|
||||||
"{query}",
|
|
||||||
"Read queries waiting for a free SQL thread. Sustained values above "
|
|
||||||
"zero indicate a saturated read pool.",
|
|
||||||
)
|
|
||||||
|
|
||||||
M_QUERIES_PENDING = MetricName(
|
|
||||||
"datasette.sql.queries.pending",
|
|
||||||
GAUGE,
|
|
||||||
"{query}",
|
|
||||||
"Read queries submitted to the pool and not yet complete. Sum across "
|
|
||||||
"databases and compare with ``datasette.sql.threads.limit`` to assess "
|
|
||||||
"pool usage.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
M_WRITE_QUEUE_DEPTH = MetricName(
|
|
||||||
"datasette.write.queue_depth",
|
|
||||||
GAUGE,
|
|
||||||
"{write}",
|
|
||||||
"Writes waiting for a database's single write thread. Increasing "
|
|
||||||
"``num_sql_threads`` does not increase write concurrency. Not reported for "
|
|
||||||
"databases that have never been written to.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
M_CONNECTIONS_OPEN = MetricName(
|
|
||||||
"datasette.connections.open",
|
|
||||||
GAUGE,
|
|
||||||
"{connection}",
|
|
||||||
"Open SQLite connections managed by Datasette.",
|
|
||||||
(DB_NAMESPACE,),
|
|
||||||
)
|
|
||||||
|
|
||||||
METRICS = (
|
|
||||||
M_OPERATION_DURATION,
|
|
||||||
M_WRITE_QUEUE_WAIT,
|
|
||||||
M_QUERIES_INTERRUPTED,
|
|
||||||
M_THREADS_LIMIT,
|
|
||||||
M_THREADS_QUEUE_DEPTH,
|
|
||||||
M_QUERIES_PENDING,
|
|
||||||
M_WRITE_QUEUE_DEPTH,
|
|
||||||
M_CONNECTIONS_OPEN,
|
|
||||||
)
|
|
||||||
|
|
@ -1,427 +0,0 @@
|
||||||
"""
|
|
||||||
Pytest helpers for testing OpenTelemetry instrumentation - Datasette's own
|
|
||||||
and any plugin's. Part of Datasette's public plugin API; see the "Telemetry
|
|
||||||
for plugin authors" documentation.
|
|
||||||
|
|
||||||
Usage from a plugin's ``conftest.py``::
|
|
||||||
|
|
||||||
from datasette.telemetry_testing import ( # noqa: F401
|
|
||||||
MetricsCollector,
|
|
||||||
otel_metrics,
|
|
||||||
otel_meter_provider,
|
|
||||||
otel_provider,
|
|
||||||
otel_spans,
|
|
||||||
)
|
|
||||||
|
|
||||||
Tests can then use the ``otel_spans`` and ``otel_metrics`` fixtures. The
|
|
||||||
OpenTelemetry SDK is imported lazily, and the fixtures skip if it is not
|
|
||||||
installed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .telemetry_registry import (
|
|
||||||
attribute_allowed,
|
|
||||||
attribute_value_allowed,
|
|
||||||
metric_for,
|
|
||||||
span_for,
|
|
||||||
)
|
|
||||||
|
|
||||||
_span_exporter = None
|
|
||||||
_metric_reader = None
|
|
||||||
|
|
||||||
|
|
||||||
def install_span_exporter():
|
|
||||||
"""
|
|
||||||
Install a TracerProvider + InMemorySpanExporter once per process and
|
|
||||||
return the exporter, or None when the SDK is not installed.
|
|
||||||
|
|
||||||
Uses `SimpleSpanProcessor` so spans are exported as soon as they end.
|
|
||||||
"""
|
|
||||||
global _span_exporter
|
|
||||||
if _span_exporter is not None:
|
|
||||||
return _span_exporter
|
|
||||||
try:
|
|
||||||
from opentelemetry import trace as otel_trace
|
|
||||||
from opentelemetry.sdk.trace import TracerProvider
|
|
||||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
||||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
|
||||||
InMemorySpanExporter,
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
exporter = InMemorySpanExporter()
|
|
||||||
provider = TracerProvider()
|
|
||||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
||||||
otel_trace.set_tracer_provider(provider)
|
|
||||||
# set_tracer_provider() is ignored if a provider was already installed,
|
|
||||||
# in which case the fixtures skip
|
|
||||||
if otel_trace.get_tracer_provider() is not provider:
|
|
||||||
return None
|
|
||||||
_span_exporter = exporter
|
|
||||||
return exporter
|
|
||||||
|
|
||||||
|
|
||||||
def install_metric_reader():
|
|
||||||
"""
|
|
||||||
Install a MeterProvider + InMemoryMetricReader once per process and
|
|
||||||
return the reader, or None when the SDK is not installed.
|
|
||||||
|
|
||||||
Uses delta temporality for counters and histograms, so each collection
|
|
||||||
only reports measurements since the previous one.
|
|
||||||
"""
|
|
||||||
global _metric_reader
|
|
||||||
if _metric_reader is not None:
|
|
||||||
return _metric_reader
|
|
||||||
try:
|
|
||||||
from opentelemetry import metrics as otel_metrics_api
|
|
||||||
from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider
|
|
||||||
from opentelemetry.sdk.metrics.export import (
|
|
||||||
AggregationTemporality,
|
|
||||||
InMemoryMetricReader,
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
reader = InMemoryMetricReader(
|
|
||||||
preferred_temporality={
|
|
||||||
Counter: AggregationTemporality.DELTA,
|
|
||||||
Histogram: AggregationTemporality.DELTA,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
provider = MeterProvider(metric_readers=[reader])
|
|
||||||
otel_metrics_api.set_meter_provider(provider)
|
|
||||||
if otel_metrics_api.get_meter_provider() is not provider:
|
|
||||||
return None
|
|
||||||
_metric_reader = reader
|
|
||||||
return reader
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def otel_provider():
|
|
||||||
"Install the span exporter once per test session, before any spans are created."
|
|
||||||
install_span_exporter()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def otel_meter_provider():
|
|
||||||
"Install the metric reader once per test session."
|
|
||||||
install_metric_reader()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def otel_reset():
|
|
||||||
"Clear recorded spans and drain collected metrics after every test."
|
|
||||||
yield
|
|
||||||
if _span_exporter is not None:
|
|
||||||
_span_exporter.clear()
|
|
||||||
if _metric_reader is not None:
|
|
||||||
_metric_reader.get_metrics_data()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def otel_spans():
|
|
||||||
"""
|
|
||||||
The in-memory span exporter, cleared before the test. Call
|
|
||||||
`.get_finished_spans()` to retrieve spans.
|
|
||||||
"""
|
|
||||||
pytest.importorskip("opentelemetry.sdk")
|
|
||||||
exporter = install_span_exporter()
|
|
||||||
if exporter is None:
|
|
||||||
pytest.skip("OpenTelemetry SDK provider was not installed")
|
|
||||||
exporter.clear()
|
|
||||||
yield exporter
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsCollector:
|
|
||||||
"""
|
|
||||||
Wraps an `InMemoryMetricReader`.
|
|
||||||
|
|
||||||
`collect()` runs a collection cycle and stores a snapshot, which
|
|
||||||
`points()` and `point()` then query.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, reader):
|
|
||||||
self.reader = reader
|
|
||||||
self.snapshot = {}
|
|
||||||
# (instrumentation scope name, sdk Metric) pairs from the last collect()
|
|
||||||
self.collected = []
|
|
||||||
|
|
||||||
def collect(self):
|
|
||||||
self.snapshot = {}
|
|
||||||
self.collected = []
|
|
||||||
data = self.reader.get_metrics_data()
|
|
||||||
if data is None:
|
|
||||||
return self.snapshot
|
|
||||||
for resource_metrics in data.resource_metrics:
|
|
||||||
for scope_metrics in resource_metrics.scope_metrics:
|
|
||||||
scope_name = scope_metrics.scope.name if scope_metrics.scope else None
|
|
||||||
for metric in scope_metrics.metrics:
|
|
||||||
self.snapshot.setdefault(metric.name, []).extend(
|
|
||||||
metric.data.data_points
|
|
||||||
)
|
|
||||||
self.collected.append((scope_name, metric))
|
|
||||||
return self.snapshot
|
|
||||||
|
|
||||||
def points(self, name, attributes=None):
|
|
||||||
"Data points for `name` whose attributes are a superset of `attributes`."
|
|
||||||
found = []
|
|
||||||
for point in self.snapshot.get(name, []):
|
|
||||||
point_attributes = dict(point.attributes or {})
|
|
||||||
if all(point_attributes.get(k) == v for k, v in (attributes or {}).items()):
|
|
||||||
found.append(point)
|
|
||||||
return found
|
|
||||||
|
|
||||||
def point(self, name, attributes=None):
|
|
||||||
"The single matching data point, asserting there is exactly one."
|
|
||||||
found = self.points(name, attributes)
|
|
||||||
assert len(found) == 1, (
|
|
||||||
f"expected exactly one {name} point matching {attributes}, "
|
|
||||||
f"got {len(found)}: {found}"
|
|
||||||
)
|
|
||||||
return found[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def otel_metrics():
|
|
||||||
"A `MetricsCollector`, drained before the test so counts start from zero."
|
|
||||||
pytest.importorskip("opentelemetry.sdk")
|
|
||||||
reader = install_metric_reader()
|
|
||||||
if reader is None:
|
|
||||||
pytest.skip("OpenTelemetry SDK meter provider was not installed")
|
|
||||||
reader.get_metrics_data()
|
|
||||||
yield MetricsCollector(reader)
|
|
||||||
|
|
||||||
|
|
||||||
def _scoped(finished_spans, scope_name):
|
|
||||||
if scope_name is None:
|
|
||||||
return list(finished_spans)
|
|
||||||
return [
|
|
||||||
span
|
|
||||||
for span in finished_spans
|
|
||||||
if span.instrumentation_scope and span.instrumentation_scope.name == scope_name
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def assert_spans_conform(registry_spans, finished_spans, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every finished span is registered in `registry_spans`, sets only
|
|
||||||
registered attributes and uses allowed attribute values.
|
|
||||||
|
|
||||||
Pass `scope_name` to only check spans from that instrumentation scope.
|
|
||||||
"""
|
|
||||||
problems = []
|
|
||||||
for span in _scoped(finished_spans, scope_name):
|
|
||||||
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
|
|
||||||
if entry is None:
|
|
||||||
problems.append(f"unregistered span: {span.name!r}")
|
|
||||||
continue
|
|
||||||
for key, value in (span.attributes or {}).items():
|
|
||||||
if not attribute_allowed(entry, str(key)):
|
|
||||||
problems.append(f"{span.name}: unregistered attribute {key!r}")
|
|
||||||
elif not attribute_value_allowed(entry, str(key), value):
|
|
||||||
problems.append(
|
|
||||||
f"{span.name}: {key}={value!r} not in the declared enum"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(problems)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_spans_covered(registry_spans, finished_spans, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every entry in `registry_spans` was emitted at least once, with
|
|
||||||
each of its attributes that is not `optional=True`.
|
|
||||||
"""
|
|
||||||
spans = _scoped(finished_spans, scope_name)
|
|
||||||
seen_attributes = {}
|
|
||||||
for span in spans:
|
|
||||||
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
|
|
||||||
if entry is not None:
|
|
||||||
seen = seen_attributes.setdefault(str(entry), set())
|
|
||||||
seen.update(str(key) for key in (span.attributes or {}))
|
|
||||||
problems = []
|
|
||||||
for entry in registry_spans:
|
|
||||||
if str(entry) not in seen_attributes:
|
|
||||||
problems.append(f"registered span never emitted: {entry!r}")
|
|
||||||
continue
|
|
||||||
required = {
|
|
||||||
str(attribute) for attribute in entry.attributes if not attribute.optional
|
|
||||||
}
|
|
||||||
missing = required - seen_attributes[str(entry)]
|
|
||||||
if missing:
|
|
||||||
problems.append(
|
|
||||||
f"{entry}: registered attributes never emitted: {sorted(missing)}"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(problems)
|
|
||||||
|
|
||||||
|
|
||||||
# Registry instrument kinds mapped to the SDK data type collected for them.
|
|
||||||
# Both counter kinds collect as Sum, distinguished by is_monotonic.
|
|
||||||
_KIND_TO_DATA_TYPE = {
|
|
||||||
"Counter": "Sum",
|
|
||||||
"UpDownCounter": "Sum",
|
|
||||||
"Histogram": "Histogram",
|
|
||||||
"Observable gauge": "Gauge",
|
|
||||||
}
|
|
||||||
_KIND_IS_MONOTONIC = {"Counter": True, "UpDownCounter": False}
|
|
||||||
|
|
||||||
|
|
||||||
def _scoped_metrics(collector, scope_name):
|
|
||||||
for scope, metric in collector.collected:
|
|
||||||
if scope_name is None or scope == scope_name:
|
|
||||||
yield metric
|
|
||||||
|
|
||||||
|
|
||||||
def assert_metrics_conform(registry_metrics, collector, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every metric in the collector's last `collect()` is registered in
|
|
||||||
`registry_metrics` with a matching instrument kind and unit, sets only
|
|
||||||
registered attributes and uses allowed attribute values.
|
|
||||||
|
|
||||||
Pass `scope_name` to only check metrics from that instrumentation scope.
|
|
||||||
"""
|
|
||||||
problems = set()
|
|
||||||
for metric in _scoped_metrics(collector, scope_name):
|
|
||||||
entry = metric_for(metric.name, metrics=registry_metrics)
|
|
||||||
if entry is None:
|
|
||||||
problems.add(f"unregistered metric: {metric.name!r}")
|
|
||||||
continue
|
|
||||||
expected_data_type = _KIND_TO_DATA_TYPE.get(entry.kind)
|
|
||||||
actual_data_type = type(metric.data).__name__
|
|
||||||
if expected_data_type is not None and actual_data_type != expected_data_type:
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: registry declares {entry.kind}, "
|
|
||||||
f"SDK collected {actual_data_type}"
|
|
||||||
)
|
|
||||||
expected_monotonic = _KIND_IS_MONOTONIC.get(entry.kind)
|
|
||||||
actual_monotonic = getattr(metric.data, "is_monotonic", None)
|
|
||||||
if (
|
|
||||||
expected_monotonic is not None
|
|
||||||
and actual_monotonic is not None
|
|
||||||
and actual_monotonic != expected_monotonic
|
|
||||||
):
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: registry declares {entry.kind}, but the "
|
|
||||||
f"collected Sum is_monotonic={actual_monotonic}"
|
|
||||||
)
|
|
||||||
if (metric.unit or "") != (entry.unit or ""):
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: instrument unit {metric.unit!r} != "
|
|
||||||
f"registry unit {entry.unit!r}"
|
|
||||||
)
|
|
||||||
for point in metric.data.data_points:
|
|
||||||
for key, value in dict(point.attributes or {}).items():
|
|
||||||
if not attribute_allowed(entry, str(key)):
|
|
||||||
problems.add(f"{metric.name}: unregistered attribute {key!r}")
|
|
||||||
elif not attribute_value_allowed(entry, str(key), value):
|
|
||||||
problems.add(
|
|
||||||
f"{metric.name}: {key}={value!r} not in the declared enum"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(sorted(problems))
|
|
||||||
|
|
||||||
|
|
||||||
def assert_metrics_covered(registry_metrics, collector, scope_name=None):
|
|
||||||
"""
|
|
||||||
Assert every entry in `registry_metrics` was collected at least once,
|
|
||||||
with each of its attributes that is not `optional=True`.
|
|
||||||
|
|
||||||
Call `collect()` once after the workload and before this check.
|
|
||||||
"""
|
|
||||||
seen_attributes = {}
|
|
||||||
for metric in _scoped_metrics(collector, scope_name):
|
|
||||||
entry = metric_for(metric.name, metrics=registry_metrics)
|
|
||||||
if entry is None:
|
|
||||||
continue
|
|
||||||
seen = seen_attributes.setdefault(str(entry), set())
|
|
||||||
for point in metric.data.data_points:
|
|
||||||
seen.update(str(key) for key in dict(point.attributes or {}))
|
|
||||||
problems = []
|
|
||||||
for entry in registry_metrics:
|
|
||||||
if str(entry) not in seen_attributes:
|
|
||||||
problems.append(f"registered metric never collected: {entry!r}")
|
|
||||||
continue
|
|
||||||
required = {
|
|
||||||
str(attribute) for attribute in entry.attributes if not attribute.optional
|
|
||||||
}
|
|
||||||
missing = required - seen_attributes[str(entry)]
|
|
||||||
if missing:
|
|
||||||
problems.append(
|
|
||||||
f"{entry}: registered attributes never collected: {sorted(missing)}"
|
|
||||||
)
|
|
||||||
assert not problems, "\n".join(problems)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_no_forbidden_values(
|
|
||||||
forbidden, finished_spans=None, collector=None, scope_name=None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Assert that none of the `forbidden` strings appear anywhere in the
|
|
||||||
emitted telemetry: span names, span attribute values, span event names
|
|
||||||
and attributes, span status descriptions, or metric point attributes.
|
|
||||||
|
|
||||||
Use fake private values such as tokens or email addresses in your test
|
|
||||||
workload, then check that they were not recorded:
|
|
||||||
|
|
||||||
FORBIDDEN = {"secret-token-123", "alice@example.com"}
|
|
||||||
run_workload_using_those_values()
|
|
||||||
assert_no_forbidden_values(
|
|
||||||
FORBIDDEN,
|
|
||||||
finished_spans=otel_spans.get_finished_spans(),
|
|
||||||
collector=otel_metrics,
|
|
||||||
)
|
|
||||||
|
|
||||||
Matches substrings of each value's string form. Empty strings in
|
|
||||||
`forbidden` are ignored. Leave `scope_name` unset to also check
|
|
||||||
Datasette's own telemetry.
|
|
||||||
"""
|
|
||||||
needles = [needle for needle in forbidden if needle]
|
|
||||||
leaks = set()
|
|
||||||
|
|
||||||
def check(value, where):
|
|
||||||
text = str(value)
|
|
||||||
for needle in needles:
|
|
||||||
if needle in text:
|
|
||||||
leaks.add(f"{where} contains {needle!r}")
|
|
||||||
|
|
||||||
if finished_spans is not None:
|
|
||||||
for span in _scoped(finished_spans, scope_name):
|
|
||||||
check(span.name, f"span name {str(span.name)!r}")
|
|
||||||
for key, value in (span.attributes or {}).items():
|
|
||||||
check(value, f"{span.name} attribute {key}")
|
|
||||||
for event in span.events or ():
|
|
||||||
check(event.name, f"{span.name} event name")
|
|
||||||
for key, value in (event.attributes or {}).items():
|
|
||||||
check(value, f"{span.name} event {event.name} attribute {key}")
|
|
||||||
if span.status is not None and span.status.description:
|
|
||||||
check(span.status.description, f"{span.name} status description")
|
|
||||||
if collector is not None:
|
|
||||||
for metric in _scoped_metrics(collector, scope_name):
|
|
||||||
for point in metric.data.data_points:
|
|
||||||
for key, value in dict(point.attributes or {}).items():
|
|
||||||
check(value, f"metric {metric.name} attribute {key}")
|
|
||||||
assert not leaks, "forbidden values leaked into telemetry:\n" + "\n".join(
|
|
||||||
sorted(leaks)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_package_never_imports_sdk(*module_names):
|
|
||||||
"""
|
|
||||||
Import the named modules in a fresh interpreter and assert none of them
|
|
||||||
imported `opentelemetry.sdk`.
|
|
||||||
|
|
||||||
Run the test that calls this early in your suite: on macOS with CPython
|
|
||||||
3.13, starting a subprocess from a process with many threads can crash.
|
|
||||||
"""
|
|
||||||
imports = "; ".join(f"import {name}" for name in module_names)
|
|
||||||
code = (
|
|
||||||
f"import sys; {imports}; "
|
|
||||||
"print([m for m in sys.modules if m.startswith('opentelemetry.sdk')])"
|
|
||||||
)
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-c", code], capture_output=True, text=True, check=True
|
|
||||||
)
|
|
||||||
assert result.stdout.strip() == "[]", (
|
|
||||||
f"importing {module_names} pulled in the OpenTelemetry SDK: "
|
|
||||||
f"{result.stdout.strip()}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
|
<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="{{ static('cm-editor.bundle.js') }}"></script>
|
||||||
<style>
|
<style>
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
resize: both;
|
resize: both;
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,21 @@
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
const sqlFormat = document.querySelector("button#sql-format");
|
const sqlFormat = document.querySelector("button#sql-format");
|
||||||
const readOnly = document.querySelector("pre#sql-query");
|
const readOnly = document.querySelector("pre#sql-query");
|
||||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
const editorElement = document.querySelector("datasette-sql-editor#sql-editor");
|
||||||
if (sqlFormat && !readOnly) {
|
if (sqlFormat && !readOnly) {
|
||||||
sqlFormat.hidden = false;
|
sqlFormat.hidden = false;
|
||||||
}
|
}
|
||||||
if (sqlInput) {
|
if (editorElement) {
|
||||||
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
|
// Rich lang-sql schema inlined server-side (see const schema above): drives
|
||||||
schema,
|
// first-paint autocomplete with no extra HTTP request and no flash of
|
||||||
}));
|
// no-completions. The default table is carried on the element attribute.
|
||||||
|
if (Object.keys(schema).length) {
|
||||||
|
editorElement.schema = schema;
|
||||||
|
}
|
||||||
|
// Back-compat: plugins and inline scripts reach the raw EditorView here.
|
||||||
|
window.editor = editorElement.view;
|
||||||
if (sqlFormat) {
|
if (sqlFormat) {
|
||||||
sqlFormat.addEventListener("click", (ev) => {
|
sqlFormat.addEventListener("click", () => editorElement.format());
|
||||||
const formatted = sqlFormatter.format(editor.state.doc.toString());
|
|
||||||
editor.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: editor.state.doc.length,
|
|
||||||
insert: formatted,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (sqlFormat && readOnly) {
|
if (sqlFormat && readOnly) {
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,8 @@
|
||||||
padding: 1.5em;
|
padding: 1.5em;
|
||||||
margin-bottom: 2em;
|
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 {
|
.form-section {
|
||||||
margin-bottom: 1.25em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
.form-section label {
|
.form-section label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -27,51 +15,22 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.form-section input[type="text"],
|
.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 {
|
.form-section select {
|
||||||
height: 3rem;
|
width: 100%;
|
||||||
padding: 0.6rem 0.75rem;
|
max-width: 500px;
|
||||||
}
|
padding: 0.5em;
|
||||||
.permission-textarea {
|
box-sizing: border-box;
|
||||||
font-family: monospace;
|
border: 1px solid #ccc;
|
||||||
min-height: 12rem;
|
border-radius: 3px;
|
||||||
padding: 0.75rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
}
|
||||||
.form-section input[type="text"]:focus,
|
.form-section input[type="text"]:focus,
|
||||||
.form-section input[type="number"]:focus,
|
.form-section select:focus {
|
||||||
.form-section select:focus,
|
outline: 2px solid #0066cc;
|
||||||
.permission-textarea:focus {
|
|
||||||
border-color: #0066cc;
|
border-color: #0066cc;
|
||||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
.form-section small {
|
.form-section small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.45em;
|
margin-top: 0.3em;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
|
|
@ -183,9 +142,4 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
@media only screen and (max-width: 576px) {
|
|
||||||
.permission-form-grid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,10 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<nav class="permissions-debug-tabs">
|
<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('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
|
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
|
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
|
||||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</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('-/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>
|
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ form.sql .query-create-sql {
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
}
|
}
|
||||||
.query-create-sql .cm-editor,
|
.query-create-sql .cm-editor,
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
form.sql .query-create-sql textarea[name="sql"] {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +127,7 @@ form.sql .query-create-sql textarea#sql-editor {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.query-create-sql .cm-editor,
|
.query-create-sql .cm-editor,
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
form.sql .query-create-sql textarea[name="sql"] {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
.query-create-options,
|
.query-create-options,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ window.datasetteSqlParameters = (() => {
|
||||||
if (window.editor) {
|
if (window.editor) {
|
||||||
return window.editor.state.doc.toString();
|
return window.editor.state.doc.toString();
|
||||||
}
|
}
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
|
const sqlInput = form.querySelector(
|
||||||
|
"#sql-editor, textarea[name=sql], input[name=sql]"
|
||||||
|
);
|
||||||
return sqlInput ? sqlInput.value : "";
|
return sqlInput ? sqlInput.value : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +203,7 @@ window.datasetteSqlParameters = (() => {
|
||||||
editorElement.addEventListener("input", callback);
|
editorElement.addEventListener("input", callback);
|
||||||
}
|
}
|
||||||
if (!window.editor) {
|
if (!window.editor) {
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor");
|
const sqlInput = form.querySelector("#sql-editor");
|
||||||
if (sqlInput) {
|
if (sqlInput) {
|
||||||
sqlInput.addEventListener("input", callback);
|
sqlInput.addEventListener("input", callback);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
form.sql .sql-editor {
|
form.sql .sql-editor {
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
}
|
}
|
||||||
form.sql .sql-editor textarea#sql-editor {
|
form.sql .sql-editor textarea[name="sql"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
form.sql .sql-parameters-section {
|
form.sql .sql-parameters-section {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,29 @@
|
||||||
{% block title %}Debug allow rules{% endblock %}
|
{% block title %}Debug allow rules{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% include "_permission_ui_styles.html" %}
|
|
||||||
<style>
|
<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 {
|
p.message-warning {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
.two-col {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% 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>
|
<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" style="margin-bottom: 1em">
|
||||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
|
<div class="two-col">
|
||||||
<div class="permission-form-grid">
|
<p><label>Allow block</label></p>
|
||||||
<div class="form-section">
|
<textarea name="allow">{{ allow_input }}</textarea>
|
||||||
<label for="allow-block">Allow block</label>
|
|
||||||
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-section">
|
<div class="two-col">
|
||||||
<label for="allow-actor">Actor</label>
|
<p><label>Actor</label></p>
|
||||||
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
|
<textarea name="actor">{{ actor_input }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style="margin-top: 1em;">
|
||||||
<div class="form-actions">
|
<input type="submit" value="Apply allow block to actor">
|
||||||
<button type="submit" class="submit-btn">Apply allow block to actor</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 %}
|
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block title %}API Explorer{% endblock %}
|
{% block title %}API Explorer{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -125,7 +126,7 @@ getForm.addEventListener("submit", (ev) => {
|
||||||
document.getElementById('response-status').textContent = response.status;
|
document.getElementById('response-status').textContent = response.status;
|
||||||
return response.json();
|
return response.json();
|
||||||
}).then((data) => {
|
}).then((data) => {
|
||||||
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
|
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||||
errorList.style.display = 'none';
|
errorList.style.display = 'none';
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
alert(error);
|
alert(error);
|
||||||
|
|
@ -173,7 +174,7 @@ postForm.addEventListener("submit", (ev) => {
|
||||||
} else {
|
} else {
|
||||||
errorList.style.display = 'none';
|
errorList.style.display = 'none';
|
||||||
}
|
}
|
||||||
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
|
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
alert("Error: " + err);
|
alert("Error: " + err);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||||
<script src="{{ static('modal.js') }}" defer></script>
|
|
||||||
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
||||||
{% for url in extra_js_urls %}
|
{% for url in extra_js_urls %}
|
||||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
{% if allow_execute_sql %}
|
{% if allow_execute_sql %}
|
||||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||||
<h3>Custom SQL query</h3>
|
<h3>Custom SQL query</h3>
|
||||||
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
<p class="sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></datasette-sql-editor></p>
|
||||||
{% set parameter_names = [] %}
|
{% set parameter_names = [] %}
|
||||||
{% set parameter_values = {} %}
|
{% set parameter_values = {} %}
|
||||||
{% set sql_parameters_allow_expand = false %}
|
{% set sql_parameters_allow_expand = false %}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block title %}Allowed Resources{% endblock %}
|
{% block title %}Allowed Resources{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -48,7 +49,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -197,7 +198,7 @@ function displayResults(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update raw JSON
|
// Update raw JSON
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayError(data) {
|
function displayError(data) {
|
||||||
|
|
@ -207,7 +208,7 @@ function displayError(data) {
|
||||||
|
|
||||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||||
|
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable child input if parent is empty
|
// Disable child input if parent is empty
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Explain a permission decision{% endblock %}
|
{% block title %}Permission Check{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -12,35 +13,29 @@
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
#output.allowed {
|
#output.allowed {
|
||||||
background-color: #f3fbf4;
|
background-color: #e8f5e9;
|
||||||
border: 2px solid #4caf50;
|
border: 2px solid #4caf50;
|
||||||
}
|
}
|
||||||
#output.denied {
|
#output.denied {
|
||||||
background-color: #fff7f7;
|
background-color: #ffebee;
|
||||||
border: 2px solid #f44336;
|
border: 2px solid #f44336;
|
||||||
}
|
}
|
||||||
#output h2 {
|
#output h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
#output h3 {
|
#output .result-badge {
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
#output .result-badge,
|
|
||||||
.effect-badge,
|
|
||||||
.rule-status {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.2em 0.5em;
|
padding: 0.3em 0.8em;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
#output .allowed-badge,
|
#output .allowed-badge {
|
||||||
.effect-allow {
|
background-color: #4caf50;
|
||||||
background-color: #2e7d32;
|
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
#output .denied-badge,
|
#output .denied-badge {
|
||||||
.effect-deny {
|
background-color: #f44336;
|
||||||
background-color: #c62828;
|
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.details-section {
|
.details-section {
|
||||||
|
|
@ -53,130 +48,70 @@
|
||||||
.details-section dd {
|
.details-section dd {
|
||||||
margin-left: 1em;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Explain a permission decision</h1>
|
<h1>Permission check</h1>
|
||||||
|
|
||||||
{% set current_tab = "check" %}
|
{% set current_tab = "check" %}
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% 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">
|
<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">
|
<div class="form-section">
|
||||||
<label for="actor">Actor JSON:</label>
|
<label for="action">Action (permission name):</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>
|
|
||||||
<select id="action" name="action" required>
|
<select id="action" name="action" required>
|
||||||
<option value="">Select an action...</option>
|
<option value="">Select an action...</option>
|
||||||
{% for action in actions %}
|
{% for action_name in sorted_actions %}
|
||||||
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
|
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<small id="action-help">The operation to evaluate</small>
|
<small>The permission action to check</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section" id="parent-section">
|
<div class="form-section">
|
||||||
<label for="parent">Parent resource:</label>
|
<label for="parent">Parent resource (optional):</label>
|
||||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
<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>
|
||||||
|
|
||||||
<div class="form-section" id="child-section">
|
<div class="form-section">
|
||||||
<label for="child">Child resource:</label>
|
<label for="child">Child resource (optional):</label>
|
||||||
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
|
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||||
<small>The table, query or other child resource</small>
|
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="output" style="display: none;">
|
<div id="output" style="display: none;">
|
||||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||||
<p id="result-summary"></p>
|
|
||||||
|
|
||||||
<dl class="details-section">
|
<dl class="details-section">
|
||||||
<dt>Actor:</dt>
|
|
||||||
<dd><code id="result-actor"></code></dd>
|
|
||||||
<dt>Action:</dt>
|
<dt>Action:</dt>
|
||||||
<dd><code id="result-action"></code></dd>
|
<dd id="result-action"></dd>
|
||||||
<dt>Resource:</dt>
|
|
||||||
<dd><code id="result-resource"></code></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>
|
</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;">
|
<details style="margin-top: 1em;">
|
||||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
<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>
|
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||||
|
|
@ -184,134 +119,152 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
|
|
||||||
const form = document.getElementById('check-form');
|
const form = document.getElementById('check-form');
|
||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
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() {
|
async function performCheck() {
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Explaining...';
|
submitBtn.textContent = 'Checking...';
|
||||||
const params = new URLSearchParams(new FormData(form));
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
displayResult(data);
|
displayResult(data);
|
||||||
} else {
|
} else {
|
||||||
displayError(data);
|
displayError(data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
displayError({error: error.message});
|
alert('Error: ' + error.message);
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.disabled = false;
|
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) {
|
function displayResult(data) {
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
|
|
||||||
|
// Set badge and styling
|
||||||
const resultBadge = document.getElementById('result-badge');
|
const resultBadge = document.getElementById('result-badge');
|
||||||
output.className = data.allowed ? 'allowed' : 'denied';
|
if (data.allowed) {
|
||||||
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
|
output.className = 'allowed';
|
||||||
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
resultBadge.className = 'result-badge allowed-badge';
|
||||||
document.getElementById('result-summary').textContent = data.explanation.summary;
|
resultBadge.textContent = 'ALLOWED ✓';
|
||||||
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
|
} else {
|
||||||
document.getElementById('result-action').textContent = data.action;
|
output.className = 'denied';
|
||||||
document.getElementById('result-resource').textContent = data.resource.path;
|
resultBadge.className = 'result-badge denied-badge';
|
||||||
displayRules(data.explanation);
|
resultBadge.textContent = 'DENIED ✗';
|
||||||
displayRestrictions(data.explanation.restrictions);
|
|
||||||
displayRequirements(data.explanation.required_actions);
|
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayRules(explanation) {
|
// Basic details
|
||||||
const container = document.getElementById('matching-rules');
|
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||||
if (!explanation.matched_rules.length) {
|
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||||
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
|
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||||
return;
|
|
||||||
}
|
// Additional details
|
||||||
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>';
|
const additionalDetails = document.getElementById('additional-details');
|
||||||
for (const rule of explanation.matched_rules) {
|
additionalDetails.innerHTML = '';
|
||||||
const status = rule.decisive
|
|
||||||
? '<span class="rule-status">Decisive</span>'
|
if (data.reason !== undefined) {
|
||||||
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
|
const dt = document.createElement('dt');
|
||||||
html += '<tr>';
|
dt.textContent = 'Reason:';
|
||||||
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
|
const dd = document.createElement('dd');
|
||||||
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
|
dd.textContent = data.reason || 'N/A';
|
||||||
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
|
additionalDetails.appendChild(dt);
|
||||||
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
|
additionalDetails.appendChild(dd);
|
||||||
html += `<td data-label="Role in decision">${status}</td>`;
|
|
||||||
html += '</tr>';
|
|
||||||
}
|
|
||||||
container.innerHTML = html + '</tbody></table>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayRestrictions(restrictions) {
|
if (data.source_plugin !== undefined) {
|
||||||
const section = document.getElementById('restrictions-section');
|
const dt = document.createElement('dt');
|
||||||
const container = document.getElementById('restriction-results');
|
dt.textContent = 'Source Plugin:';
|
||||||
section.style.display = restrictions.length ? 'block' : 'none';
|
const dd = document.createElement('dd');
|
||||||
container.innerHTML = restrictions.map(restriction => {
|
dd.textContent = data.source_plugin || 'N/A';
|
||||||
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
|
additionalDetails.appendChild(dt);
|
||||||
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
|
additionalDetails.appendChild(dd);
|
||||||
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayRequirements(requirements) {
|
if (data.used_default !== undefined) {
|
||||||
const section = document.getElementById('requirements-section');
|
const dt = document.createElement('dt');
|
||||||
const container = document.getElementById('requirement-results');
|
dt.textContent = 'Used Default:';
|
||||||
section.style.display = requirements.length ? 'block' : 'none';
|
const dd = document.createElement('dd');
|
||||||
container.innerHTML = requirements.map(requirement => {
|
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||||
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
|
additionalDetails.appendChild(dt);
|
||||||
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
additionalDetails.appendChild(dd);
|
||||||
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
|
}
|
||||||
}).join('');
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Scroll to output
|
||||||
|
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayError(data) {
|
function displayError(data) {
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
output.className = 'denied';
|
output.className = 'denied';
|
||||||
|
|
||||||
const resultBadge = document.getElementById('result-badge');
|
const resultBadge = document.getElementById('result-badge');
|
||||||
resultBadge.className = 'result-badge denied-badge';
|
resultBadge.className = 'result-badge denied-badge';
|
||||||
resultBadge.textContent = 'ERROR';
|
resultBadge.textContent = 'ERROR';
|
||||||
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
|
|
||||||
document.getElementById('result-actor').textContent = '—';
|
document.getElementById('result-action').textContent = 'N/A';
|
||||||
document.getElementById('result-action').textContent = '—';
|
document.getElementById('result-resource').textContent = 'N/A';
|
||||||
document.getElementById('result-resource').textContent = '—';
|
document.getElementById('result-actor').textContent = 'N/A';
|
||||||
document.getElementById('matching-rules').innerHTML = '';
|
|
||||||
document.getElementById('restrictions-section').style.display = 'none';
|
const additionalDetails = document.getElementById('additional-details');
|
||||||
document.getElementById('requirements-section').style.display = 'none';
|
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
|
||||||
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', event => {
|
// Disable child input if parent is empty
|
||||||
event.preventDefault();
|
const parentInput = document.getElementById('parent');
|
||||||
performCheck();
|
const childInput = document.getElementById('child');
|
||||||
|
|
||||||
|
childInput.addEventListener('focus', () => {
|
||||||
|
if (!parentInput.value) {
|
||||||
|
alert('Please specify a parent resource first before adding a child resource.');
|
||||||
|
parentInput.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
actionSelect.addEventListener('change', updateResourceFields);
|
|
||||||
|
|
||||||
(function initializeFromUrl() {
|
|
||||||
const params = populateFormFromURL();
|
|
||||||
updateResourceFields();
|
|
||||||
if (params.get('action')) {
|
|
||||||
performCheck();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Permission activity{% endblock %}
|
{% block title %}Debug permissions{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
|
|
@ -20,29 +20,45 @@
|
||||||
.check-action, .check-when, .check-result {
|
.check-action, .check-when, .check-result {
|
||||||
font-size: 1.3em;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Permission activity</h1>
|
<h1>Permission playground</h1>
|
||||||
|
|
||||||
{% set current_tab = "permissions" %}
|
{% set current_tab = "permissions" %}
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% include "_permissions_debug_tabs.html" %}
|
||||||
|
|
||||||
<h2>Raw simulator</h2>
|
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="permission-form">
|
<div class="permission-form">
|
||||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||||
<div class="permission-form-grid">
|
<div class="two-col">
|
||||||
<div>
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="activity-actor">Actor</label>
|
<label>Actor</label>
|
||||||
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="two-col" style="vertical-align: top">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="permission">Action</label>
|
<label for="permission">Action</label>
|
||||||
<select name="permission" id="permission">
|
<select name="permission" id="permission">
|
||||||
|
|
@ -60,7 +76,6 @@
|
||||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="submit-btn">Simulate permission check</button>
|
<button type="submit" class="submit-btn">Simulate permission check</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h2>Recent permission checks</h2>
|
<h1>Recent permissions checks</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block title %}Permission Rules{% endblock %}
|
{% block title %}Permission Rules{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -36,7 +37,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -184,7 +185,7 @@ function displayResults(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update raw JSON
|
// Update raw JSON
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayError(data) {
|
function displayError(data) {
|
||||||
|
|
@ -194,7 +195,7 @@ function displayError(data) {
|
||||||
|
|
||||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||||
|
|
||||||
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
||||||
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
||||||
{% endif %}
|
{% 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{% if not sql %} sql-editor-min-lines{% endif %}"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
|
||||||
|
|
||||||
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
||||||
{% set sql_parameters_allow_expand = true %}
|
{% set sql_parameters_allow_expand = true %}
|
||||||
|
|
@ -175,7 +175,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
const executeWriteSqlInput = document.querySelector("#sql-editor");
|
||||||
const form = document.querySelector("form.sql.core");
|
const form = document.querySelector("form.sql.core");
|
||||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
||||||
const submitButton = form
|
const submitButton = form
|
||||||
|
|
@ -261,7 +261,7 @@ window.addEventListener("DOMContentLoaded", () => {
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
const tableSelect = document.querySelector("#execute-write-template-table");
|
const tableSelect = document.querySelector("#execute-write-template-table");
|
||||||
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
||||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
const sqlInput = document.querySelector("#sql-editor");
|
||||||
|
|
||||||
function dataKey(operation) {
|
function dataKey(operation) {
|
||||||
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
|
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@
|
||||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{% if database.views_count -%}
|
{% if database.views_count -%}
|
||||||
, {{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
|
{% if database.tables_count or database.hidden_tables_count %}, {% endif -%}
|
||||||
|
{{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p>{% for table in database.tables_and_views_truncated %}<a href="{{ urls.table(database.name, table.name) }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, <a href="{{ urls.database(database.name) }}">...</a>{% endif %}</p>
|
<p>{% for table in database.tables_and_views_truncated %}<a href="{{ urls.table(database.name, table.name) }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, <a href="{{ urls.database(database.name) }}">...</a>{% endif %}</p>
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not hide_sql %}
|
{% if not hide_sql %}
|
||||||
{% if editable and allow_execute_sql %}
|
{% if editable and allow_execute_sql %}
|
||||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
<p class="sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
||||||
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></datasette-sql-editor></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></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>
|
</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-sql sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
|
||||||
|
|
||||||
<p class="query-create-options">
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></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>
|
</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-sql sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
|
||||||
|
|
||||||
<p class="query-create-options">
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% 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_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 extra_head %}
|
{% block extra_head %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
|
|
@ -47,12 +47,11 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if count or human_description_en %}
|
{% if count or human_description_en %}
|
||||||
<h3 class="table-summary">
|
<h3>
|
||||||
{% if count_truncated %}<span class="table-count" aria-live="polite">{{ "{:,}".format(count - 1) }}+ rows</span>
|
{% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows
|
||||||
<button type="button" class="count-all" data-count-url="{{ urls.table(database, table) }}/-/count">count all</button>
|
{% 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 %}
|
||||||
<span class="count-error" role="alert"></span>
|
|
||||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||||
{% if human_description_en %}<span class="table-summary-description">{{ human_description_en }}</span>{% endif %}
|
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -127,7 +126,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if query.sql and allow_execute_sql %}
|
{% if query.sql and allow_execute_sql %}
|
||||||
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql, '_table': table}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
import itsdangerous
|
import itsdangerous
|
||||||
|
|
||||||
|
|
@ -50,24 +50,24 @@ class TokenRestrictions:
|
||||||
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
||||||
resource: dict[str, 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."""
|
"""Allow an action across all databases and resources."""
|
||||||
self.all.append(action)
|
self.all.append(action)
|
||||||
return self
|
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."""
|
"""Allow an action on a specific database."""
|
||||||
self.database.setdefault(database, []).append(action)
|
self.database.setdefault(database, []).append(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def allow_resource(
|
def allow_resource(
|
||||||
self, database: str, resource: str, action: str
|
self, database: str, resource: str, action: str
|
||||||
) -> TokenRestrictions:
|
) -> "TokenRestrictions":
|
||||||
"""Allow an action on a specific resource within a database."""
|
"""Allow an action on a specific resource within a database."""
|
||||||
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
||||||
return self
|
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
|
Return the abbreviated ``_r`` dictionary shape for this set of
|
||||||
restrictions, using action abbreviations registered with ``datasette``.
|
restrictions, using action abbreviations registered with ``datasette``.
|
||||||
|
|
@ -112,16 +112,16 @@ class TokenHandler:
|
||||||
|
|
||||||
async def create_token(
|
async def create_token(
|
||||||
self,
|
self,
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
*,
|
*,
|
||||||
expires_after: int | None = None,
|
expires_after: Optional[int] = None,
|
||||||
restrictions: TokenRestrictions | None = None,
|
restrictions: Optional[TokenRestrictions] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create and return a token string for the given actor."""
|
"""Create and return a token string for the given actor."""
|
||||||
raise NotImplementedError
|
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.
|
Verify a token and return an actor dict.
|
||||||
|
|
||||||
|
|
@ -142,11 +142,11 @@ class SignedTokenHandler(TokenHandler):
|
||||||
|
|
||||||
async def create_token(
|
async def create_token(
|
||||||
self,
|
self,
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
*,
|
*,
|
||||||
expires_after: int | None = None,
|
expires_after: Optional[int] = None,
|
||||||
restrictions: TokenRestrictions | None = None,
|
restrictions: Optional[TokenRestrictions] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not datasette.setting("allow_signed_tokens"):
|
if not datasette.setting("allow_signed_tokens"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -163,7 +163,7 @@ class SignedTokenHandler(TokenHandler):
|
||||||
token["_r"] = abbreviated
|
token["_r"] = abbreviated
|
||||||
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
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_"
|
prefix = "dstok_"
|
||||||
|
|
||||||
if not token.startswith(prefix):
|
if not token.startswith(prefix):
|
||||||
|
|
@ -200,7 +200,8 @@ class SignedTokenHandler(TokenHandler):
|
||||||
):
|
):
|
||||||
duration = max_signed_tokens_ttl
|
duration = max_signed_tokens_ttl
|
||||||
|
|
||||||
if duration and time.time() - created > duration:
|
if duration:
|
||||||
|
if time.time() - created > duration:
|
||||||
raise TokenInvalid("Token has expired")
|
raise TokenInvalid("Token has expired")
|
||||||
|
|
||||||
actor = {"id": decoded["a"], "token": "dstok"}
|
actor = {"id": decoded["a"], "token": "dstok"}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
|
||||||
tracers = {}
|
tracers = {}
|
||||||
|
|
||||||
|
|
@ -133,17 +132,17 @@ class AsgiTracer:
|
||||||
"num_traces": len(traces),
|
"num_traces": len(traces),
|
||||||
"traces": traces,
|
"traces": traces,
|
||||||
}
|
}
|
||||||
content_type = next(
|
try:
|
||||||
(
|
content_type = [
|
||||||
v.decode("utf8")
|
v.decode("utf8")
|
||||||
for k, v in response_headers
|
for k, v in response_headers
|
||||||
if k.lower() == b"content-type"
|
if k.lower() == b"content-type"
|
||||||
),
|
][0]
|
||||||
"",
|
except IndexError:
|
||||||
)
|
content_type = ""
|
||||||
if "text/html" in content_type and b"</body>" in accumulated_body:
|
if "text/html" in content_type and b"</body>" in accumulated_body:
|
||||||
extra = escape(json.dumps(trace_info, indent=2))
|
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)
|
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
|
||||||
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
||||||
data = json.loads(accumulated_body.decode("utf8"))
|
data = json.loads(accumulated_body.decode("utf8"))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
from .utils import tilde_encode, path_with_format, PrefixedUrlString
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from .utils import PrefixedUrlString, path_with_format, tilde_encode
|
|
||||||
|
|
||||||
|
|
||||||
class Urls:
|
class Urls:
|
||||||
def __init__(self, ds):
|
def __init__(self, ds):
|
||||||
|
|
@ -9,7 +8,8 @@ class Urls:
|
||||||
|
|
||||||
def path(self, path, format=None):
|
def path(self, path, format=None):
|
||||||
if not isinstance(path, PrefixedUrlString):
|
if not isinstance(path, PrefixedUrlString):
|
||||||
path = path.removeprefix("/")
|
if path.startswith("/"):
|
||||||
|
path = path[1:]
|
||||||
path = self.ds.setting("base_url") + path
|
path = self.ds.setting("base_url") + path
|
||||||
if format is not None:
|
if format is not None:
|
||||||
path = path_with_format(path=path, format=format)
|
path = path_with_format(path=path, format=format)
|
||||||
|
|
@ -56,7 +56,6 @@ class Urls:
|
||||||
return PrefixedUrlString(path)
|
return PrefixedUrlString(path)
|
||||||
|
|
||||||
def row_blob(self, database, table, row_path, column):
|
def row_blob(self, database, table, row_path, column):
|
||||||
return (
|
return self.table(database, table) + "/{}.blob?_blob_column={}".format(
|
||||||
self.table(database, table)
|
row_path, urllib.parse.quote_plus(column)
|
||||||
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,29 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
import binascii
|
import binascii
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import aiofiles
|
||||||
|
import click
|
||||||
|
from collections import OrderedDict, namedtuple, Counter
|
||||||
import copy
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
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 markupsafe
|
||||||
import mergedeep
|
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
|
import yaml
|
||||||
|
|
||||||
from .shutil_backport import copytree
|
from .shutil_backport import copytree
|
||||||
from .sqlite import sqlite3, supports_table_xinfo
|
from .sqlite import sqlite3, supports_table_xinfo
|
||||||
|
|
||||||
|
|
@ -38,7 +36,7 @@ if typing.TYPE_CHECKING:
|
||||||
class PaginatedResources:
|
class PaginatedResources:
|
||||||
"""Paginated results from allowed_resources query."""
|
"""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)
|
next: str | None # Keyset token for next page (None if no more results)
|
||||||
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
||||||
_action: str = dataclasses.field(default=None, repr=False)
|
_action: str = dataclasses.field(default=None, repr=False)
|
||||||
|
|
@ -85,132 +83,22 @@ class PaginatedResources:
|
||||||
|
|
||||||
|
|
||||||
# From https://www.sqlite.org/lang_keywords.html
|
# From https://www.sqlite.org/lang_keywords.html
|
||||||
reserved_words = {
|
reserved_words = set(
|
||||||
"abort",
|
(
|
||||||
"action",
|
"abort action add after all alter analyze and as asc attach autoincrement "
|
||||||
"add",
|
"before begin between by cascade case cast check collate column commit "
|
||||||
"after",
|
"conflict constraint create cross current_date current_time "
|
||||||
"all",
|
"current_timestamp database default deferrable deferred delete desc detach "
|
||||||
"alter",
|
"distinct drop each else end escape except exclusive exists explain fail "
|
||||||
"analyze",
|
"for foreign from full glob group having if ignore immediate in index "
|
||||||
"and",
|
"indexed initially inner insert instead intersect into is isnull join key "
|
||||||
"as",
|
"left like limit match natural no not notnull null of offset on or order "
|
||||||
"asc",
|
"outer plan pragma primary query raise recursive references regexp reindex "
|
||||||
"attach",
|
"release rename replace restrict right rollback row savepoint select set "
|
||||||
"autoincrement",
|
"table temp temporary then to transaction trigger union unique update using "
|
||||||
"before",
|
"vacuum values view virtual when where with without"
|
||||||
"begin",
|
).split()
|
||||||
"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",
|
|
||||||
}
|
|
||||||
|
|
||||||
APT_GET_DOCKERFILE_EXTRAS = r"""
|
APT_GET_DOCKERFILE_EXTRAS = r"""
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
|
@ -270,7 +158,7 @@ functions_marked_as_documented = []
|
||||||
|
|
||||||
def documented(fn=None, *, label=None):
|
def documented(fn=None, *, label=None):
|
||||||
def decorate(fn):
|
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)
|
functions_marked_as_documented.append(fn)
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
|
|
@ -472,7 +360,7 @@ disallawed_sql_res = [
|
||||||
(
|
(
|
||||||
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
||||||
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
|
"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)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
@ -568,7 +456,7 @@ def escape_css_string(s):
|
||||||
|
|
||||||
|
|
||||||
def escape_sqlite(s):
|
def escape_sqlite(s):
|
||||||
if _boring_keyword_re.fullmatch(s) and (s.lower() not in reserved_words):
|
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
||||||
return s
|
return s
|
||||||
return '"{}"'.format(s.replace('"', '""'))
|
return '"{}"'.format(s.replace('"', '""'))
|
||||||
|
|
||||||
|
|
@ -646,7 +534,10 @@ CMD {cmd}""".format(
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
environment_variables="\n".join(
|
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),
|
install_from=" ".join(install),
|
||||||
files=" ".join(files),
|
files=" ".join(files),
|
||||||
|
|
@ -745,11 +636,11 @@ def detect_primary_keys(conn, table):
|
||||||
|
|
||||||
|
|
||||||
def get_outbound_foreign_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 = []
|
fks = []
|
||||||
for info in infos:
|
for info in infos:
|
||||||
if info is not None:
|
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(
|
fks.append(
|
||||||
{
|
{
|
||||||
"column": from_,
|
"column": from_,
|
||||||
|
|
@ -820,8 +711,7 @@ def detect_spatialite(conn):
|
||||||
|
|
||||||
def detect_fts(conn, table):
|
def detect_fts(conn, table):
|
||||||
"""Detect if table has a corresponding FTS virtual table and return it"""
|
"""Detect if table has a corresponding FTS virtual table and return it"""
|
||||||
sql, params = detect_fts_sql(table)
|
rows = conn.execute(detect_fts_sql(table)).fetchall()
|
||||||
rows = conn.execute(sql, params).fetchall()
|
|
||||||
if len(rows) == 0:
|
if len(rows) == 0:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
|
|
@ -829,26 +719,18 @@ def detect_fts(conn, table):
|
||||||
|
|
||||||
|
|
||||||
def detect_fts_sql(table):
|
def detect_fts_sql(table):
|
||||||
escaped_table = table.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return r"""
|
||||||
return (
|
|
||||||
r"""
|
|
||||||
select name from sqlite_master
|
select name from sqlite_master
|
||||||
where rootpage = 0
|
where rootpage = 0
|
||||||
and (
|
and (
|
||||||
sql like :fts_double_quoted escape char(92)
|
sql like '%VIRTUAL TABLE%USING FTS%content="{table}"%'
|
||||||
or sql like :fts_bracket_quoted escape char(92)
|
or sql like '%VIRTUAL TABLE%USING FTS%content=[{table}]%'
|
||||||
or (
|
or (
|
||||||
tbl_name = :table
|
tbl_name = "{table}"
|
||||||
and sql like '%VIRTUAL TABLE%USING FTS%'
|
and sql like '%VIRTUAL TABLE%USING FTS%'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""",
|
""".format(table=table.replace("'", "''"))
|
||||||
{
|
|
||||||
"fts_double_quoted": f'%VIRTUAL TABLE%USING FTS%content="{escaped_table}"%',
|
|
||||||
"fts_bracket_quoted": f"%VIRTUAL TABLE%USING FTS%content=[{escaped_table}]%",
|
|
||||||
"table": table,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_json1(conn=None):
|
def detect_json1(conn=None):
|
||||||
|
|
@ -859,7 +741,7 @@ def detect_json1(conn=None):
|
||||||
try:
|
try:
|
||||||
conn.execute("SELECT json('{}')")
|
conn.execute("SELECT json('{}')")
|
||||||
return True
|
return True
|
||||||
except sqlite3.Error:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
if close_conn:
|
if close_conn:
|
||||||
|
|
@ -939,7 +821,9 @@ def is_url(value):
|
||||||
if not value.startswith("http://") and not value.startswith("https://"):
|
if not value.startswith("http://") and not value.startswith("https://"):
|
||||||
return False
|
return False
|
||||||
# Any whitespace at all is invalid
|
# 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-]*$")
|
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
|
||||||
|
|
@ -992,9 +876,7 @@ def module_from_path(path, name):
|
||||||
mod.__file__ = path
|
mod.__file__ = path
|
||||||
with open(path, "r") as file:
|
with open(path, "r") as file:
|
||||||
code = compile(file.read(), path, "exec", dont_inherit=True)
|
code = compile(file.read(), path, "exec", dont_inherit=True)
|
||||||
# Executing the file is the whole point - this is how --plugins-dir loads
|
exec(code, mod.__dict__)
|
||||||
# plugins and how metadata/config .py files are evaluated
|
|
||||||
exec(code, mod.__dict__) # noqa: S102
|
|
||||||
return mod
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1151,7 +1033,9 @@ def escape_fts(query):
|
||||||
query += '"'
|
query += '"'
|
||||||
bits = _escape_fts_re.split(query)
|
bits = _escape_fts_re.split(query)
|
||||||
bits = [b for b in bits if b and b != '""']
|
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:
|
class MultiParams:
|
||||||
|
|
@ -1163,7 +1047,7 @@ class MultiParams:
|
||||||
data[key], (list, tuple)
|
data[key], (list, tuple)
|
||||||
), "dictionary data should be a dictionary of key => [list]"
|
), "dictionary data should be a dictionary of key => [list]"
|
||||||
self._data = data
|
self._data = data
|
||||||
elif isinstance(data, (list, tuple)):
|
elif isinstance(data, list) or isinstance(data, tuple):
|
||||||
new_data = {}
|
new_data = {}
|
||||||
for item in data:
|
for item in data:
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -1253,7 +1137,9 @@ def _gather_arguments(fn, kwargs):
|
||||||
for parameter in parameters:
|
for parameter in parameters:
|
||||||
if parameter not in kwargs:
|
if parameter not in kwargs:
|
||||||
raise TypeError(
|
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])
|
call_with.append(kwargs[parameter])
|
||||||
return call_with
|
return call_with
|
||||||
|
|
@ -1322,9 +1208,9 @@ def resolve_env_secrets(config, environ):
|
||||||
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
if list(config.keys()) == ["$env"]:
|
if list(config.keys()) == ["$env"]:
|
||||||
return environ.get(next(iter(config.values())))
|
return environ.get(list(config.values())[0])
|
||||||
elif list(config.keys()) == ["$file"]:
|
elif list(config.keys()) == ["$file"]:
|
||||||
with open(next(iter(config.values()))) as fp:
|
with open(list(config.values())[0]) as fp:
|
||||||
return fp.read()
|
return fp.read()
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
|
|
@ -1420,7 +1306,7 @@ _named_param_re = re.compile(r":(\w+)")
|
||||||
|
|
||||||
|
|
||||||
@documented
|
@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
|
Given a SQL statement, return a list of named parameters that are used in the statement
|
||||||
|
|
||||||
|
|
@ -1433,7 +1319,7 @@ def named_parameters(sql: str) -> list[str]:
|
||||||
return _named_param_re.findall(sql)
|
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
|
This undocumented but stable method exists for backwards compatibility
|
||||||
with plugins that were using it before it switched to named_parameters()
|
with plugins that were using it before it switched to named_parameters()
|
||||||
|
|
@ -1457,9 +1343,9 @@ def parse_size_limit(value, default, maximum, name="_size"):
|
||||||
if size < 0:
|
if size < 0:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"{name} must be a positive integer")
|
raise ValueError("{} must be a positive integer".format(name))
|
||||||
if size > maximum:
|
if size > maximum:
|
||||||
raise ValueError(f"{name} must be <= {maximum}")
|
raise ValueError("{} must be <= {}".format(name, maximum))
|
||||||
return size
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1517,7 +1403,7 @@ class TildeEncoder(dict):
|
||||||
elif b == _space:
|
elif b == _space:
|
||||||
res = "+"
|
res = "+"
|
||||||
else:
|
else:
|
||||||
res = f"~{b:02X}"
|
res = "~{:02X}".format(b)
|
||||||
self[b] = res
|
self[b] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
@ -1566,13 +1452,7 @@ async def row_sql_params_pks(db, table, pk_values):
|
||||||
if use_rowid:
|
if use_rowid:
|
||||||
select = "rowid, *"
|
select = "rowid, *"
|
||||||
pks = ["rowid"]
|
pks = ["rowid"]
|
||||||
wheres = []
|
wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)]
|
||||||
for i, pk in enumerate(pks):
|
|
||||||
escaped_pk = escape_sqlite(pk)
|
|
||||||
# Preserve the historic always-quoted SQL exposed by _extra=query
|
|
||||||
if escaped_pk == pk:
|
|
||||||
escaped_pk = f'"{pk}"'
|
|
||||||
wheres.append(f"{escaped_pk}=:p{i}")
|
|
||||||
sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}"
|
sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}"
|
||||||
params = {}
|
params = {}
|
||||||
for i, pk_value in enumerate(pk_values):
|
for i, pk_value in enumerate(pk_values):
|
||||||
|
|
@ -1618,7 +1498,7 @@ def _combine(base: dict, update: dict) -> dict:
|
||||||
return base
|
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.
|
Parse a list of key-value pairs into a nested dictionary.
|
||||||
"""
|
"""
|
||||||
|
|
@ -1633,7 +1513,7 @@ def make_slot_function(name, datasette, request, **kwargs):
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
||||||
method = getattr(pm.hook, name, None)
|
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():
|
async def inner():
|
||||||
html_bits = []
|
html_bits = []
|
||||||
|
|
@ -1657,7 +1537,7 @@ def prune_empty_dicts(d: dict):
|
||||||
d.pop(key, None)
|
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
|
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
|
||||||
hierarchy in destination if needed. After moving, recursively remove any keys
|
hierarchy in destination if needed. After moving, recursively remove any keys
|
||||||
|
|
@ -1744,7 +1624,7 @@ def redact_keys(original: dict, key_patterns: Iterable) -> dict:
|
||||||
return {
|
return {
|
||||||
k: (
|
k: (
|
||||||
redact(v)
|
redact(v)
|
||||||
if not any(pattern in k.casefold() for pattern in key_patterns)
|
if not any(pattern in k for pattern in key_patterns)
|
||||||
else "***"
|
else "***"
|
||||||
)
|
)
|
||||||
for k, v in data.items()
|
for k, v in data.items()
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,6 @@ from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.permissions import Action
|
|
||||||
|
|
||||||
|
|
||||||
def _child_collation(action: "Action") -> str:
|
|
||||||
"""Match resource identity without changing the spelling returned by SQL."""
|
|
||||||
resource_class = action.resource_class
|
|
||||||
if resource_class is not None and resource_class.case_insensitive_child:
|
|
||||||
return "NOCASE"
|
|
||||||
return "BINARY"
|
|
||||||
|
|
||||||
|
|
||||||
async def build_allowed_resources_sql(
|
async def build_allowed_resources_sql(
|
||||||
|
|
@ -158,7 +149,6 @@ async def _build_single_action_sql(
|
||||||
raise ValueError(f"Unknown action: {action}")
|
raise ValueError(f"Unknown action: {action}")
|
||||||
|
|
||||||
# Get base resources SQL from the resource class
|
# Get base resources SQL from the resource class
|
||||||
child_collation = _child_collation(action_obj)
|
|
||||||
base_resources_sql = await action_obj.resource_class.resources_sql(
|
base_resources_sql = await action_obj.resource_class.resources_sql(
|
||||||
datasette, actor=actor
|
datasette, actor=actor
|
||||||
)
|
)
|
||||||
|
|
@ -195,7 +185,7 @@ async def _build_single_action_sql(
|
||||||
if permission_sql.sql is None:
|
if permission_sql.sql is None:
|
||||||
continue
|
continue
|
||||||
rule_sqls.append(f"""
|
rule_sqls.append(f"""
|
||||||
SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
||||||
{permission_sql.sql}
|
{permission_sql.sql}
|
||||||
)
|
)
|
||||||
""".strip())
|
""".strip())
|
||||||
|
|
@ -262,62 +252,88 @@ async def _build_single_action_sql(
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Continue with the cascading logic.
|
# 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
|
|
||||||
|
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
["child_agg AS ("]
|
[
|
||||||
+ _agg(
|
"child_lvl AS (",
|
||||||
"parent, child,",
|
" SELECT b.parent, b.child,",
|
||||||
"parent IS NOT NULL AND child IS NOT NULL",
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
"parent, child",
|
" 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,",
|
||||||
+ ["),", "parent_agg AS ("]
|
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||||
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
" FROM base b",
|
||||||
+ ["),", "global_agg AS ("]
|
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||||
+ _agg("", "parent IS NULL AND child IS NULL", None)
|
" 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
|
# Add anonymous decision logic if needed
|
||||||
if include_is_private:
|
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(
|
query_parts.extend(
|
||||||
["anon_child_agg AS ("]
|
[
|
||||||
+ _anon_agg(
|
"anon_child_lvl AS (",
|
||||||
f"parent, child COLLATE {child_collation} AS child,",
|
" SELECT b.parent, b.child,",
|
||||||
"parent IS NOT NULL AND child IS NOT NULL",
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
f"parent, child COLLATE {child_collation}",
|
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||||
)
|
" FROM base b",
|
||||||
+ ["),", "anon_parent_agg AS ("]
|
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||||
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
" GROUP BY b.parent, b.child",
|
||||||
+ ["),", "anon_global_agg AS ("]
|
"),",
|
||||||
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
|
"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
|
# Final decisions
|
||||||
|
|
@ -326,28 +342,31 @@ async def _build_single_action_sql(
|
||||||
"decisions AS (",
|
"decisions AS (",
|
||||||
" SELECT",
|
" SELECT",
|
||||||
" b.parent, b.child,",
|
" 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:",
|
" -- Priority order:",
|
||||||
" -- 1. Child-level deny 2. Child-level allow",
|
" -- 1. Child-level deny (most specific, blocks access)",
|
||||||
" -- 3. Parent-level deny 4. Parent-level allow",
|
" -- 2. Child-level allow (most specific, grants access)",
|
||||||
" -- 5. Global-level deny 6. Global-level allow",
|
" -- 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)",
|
" -- 7. Default deny (no rules match)",
|
||||||
" CASE",
|
" CASE",
|
||||||
" WHEN ca.any_deny = 1 THEN 0",
|
" WHEN cl.any_deny = 1 THEN 0",
|
||||||
" WHEN ca.any_allow = 1 THEN 1",
|
" WHEN cl.any_allow = 1 THEN 1",
|
||||||
" WHEN pa.any_deny = 1 THEN 0",
|
" WHEN pl.any_deny = 1 THEN 0",
|
||||||
" WHEN pa.any_allow = 1 THEN 1",
|
" WHEN pl.any_allow = 1 THEN 1",
|
||||||
" WHEN ga.any_deny = 1 THEN 0",
|
" WHEN gl.any_deny = 1 THEN 0",
|
||||||
" WHEN ga.any_allow = 1 THEN 1",
|
" WHEN gl.any_allow = 1 THEN 1",
|
||||||
" ELSE 0",
|
" ELSE 0",
|
||||||
" END AS is_allowed,",
|
" END AS is_allowed,",
|
||||||
" CASE",
|
" CASE",
|
||||||
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
|
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
|
||||||
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
|
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
|
||||||
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
|
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
|
||||||
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
|
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
|
||||||
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
|
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
|
||||||
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
|
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
|
||||||
" ELSE '[]'",
|
" ELSE '[]'",
|
||||||
" END AS reason",
|
" END AS reason",
|
||||||
]
|
]
|
||||||
|
|
@ -355,34 +374,21 @@ async def _build_single_action_sql(
|
||||||
|
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
query_parts.append(
|
query_parts.append(
|
||||||
" , CASE WHEN ("
|
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
[
|
[
|
||||||
" FROM base b",
|
" FROM base b",
|
||||||
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
|
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
||||||
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
|
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
||||||
" CROSS JOIN global_agg ga",
|
" 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:
|
if include_is_private:
|
||||||
query_parts.extend(
|
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))"
|
||||||
" 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(")")
|
query_parts.append(")")
|
||||||
|
|
@ -392,31 +398,10 @@ async def _build_single_action_sql(
|
||||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||||
# with UNION ALL inside the restriction SQL statements
|
# with UNION ALL inside the restriction SQL statements
|
||||||
restriction_intersect = "\nINTERSECT\n".join(
|
restriction_intersect = "\nINTERSECT\n".join(
|
||||||
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
|
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||||
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(
|
query_parts.extend(
|
||||||
[
|
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
|
||||||
",",
|
|
||||||
"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",
|
|
||||||
")",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Final SELECT
|
# Final SELECT
|
||||||
|
|
@ -431,11 +416,10 @@ async def _build_single_action_sql(
|
||||||
# Add restriction filter if there are restrictions
|
# Add restriction filter if there are restrictions
|
||||||
if restriction_sqls:
|
if restriction_sqls:
|
||||||
query_parts.append("""
|
query_parts.append("""
|
||||||
AND (
|
AND EXISTS (
|
||||||
EXISTS (SELECT 1 FROM restriction_all)
|
SELECT 1 FROM restriction_list r
|
||||||
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
|
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
|
||||||
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
|
AND (r.child = decisions.child OR r.child IS NULL)
|
||||||
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
|
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
# Add parent filter if specified
|
# Add parent filter if specified
|
||||||
|
|
@ -491,7 +475,6 @@ async def build_permission_rules_sql(
|
||||||
union_parts = []
|
union_parts = []
|
||||||
all_params = {}
|
all_params = {}
|
||||||
restriction_sqls = []
|
restriction_sqls = []
|
||||||
child_collation = _child_collation(action_obj)
|
|
||||||
|
|
||||||
for permission_sql in permission_sqls:
|
for permission_sql in permission_sqls:
|
||||||
all_params.update(permission_sql.params or {})
|
all_params.update(permission_sql.params or {})
|
||||||
|
|
@ -505,7 +488,7 @@ async def build_permission_rules_sql(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
union_parts.append(f"""
|
union_parts.append(f"""
|
||||||
SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
|
||||||
{permission_sql.sql}
|
{permission_sql.sql}
|
||||||
)
|
)
|
||||||
""".strip())
|
""".strip())
|
||||||
|
|
@ -576,7 +559,6 @@ async def check_permissions_for_actions(
|
||||||
verdicts = {}
|
verdicts = {}
|
||||||
|
|
||||||
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
||||||
child_collation = _child_collation(datasette.actions[action])
|
|
||||||
prefix = f"a{i}_"
|
prefix = f"a{i}_"
|
||||||
rule_parts = []
|
rule_parts = []
|
||||||
restriction_parts = []
|
restriction_parts = []
|
||||||
|
|
@ -602,7 +584,7 @@ async def check_permissions_for_actions(
|
||||||
if sql is None:
|
if sql is None:
|
||||||
continue
|
continue
|
||||||
rule_parts.append(
|
rule_parts.append(
|
||||||
f"SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
|
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rule_parts:
|
if not rule_parts:
|
||||||
|
|
@ -636,8 +618,7 @@ async def check_permissions_for_actions(
|
||||||
if restriction_parts:
|
if restriction_parts:
|
||||||
# Database-level restrictions (parent, NULL) match all children
|
# Database-level restrictions (parent, NULL) match all children
|
||||||
restriction_intersect = "\nINTERSECT\n".join(
|
restriction_intersect = "\nINTERSECT\n".join(
|
||||||
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
|
f"SELECT * FROM ({sql})" for sql in restriction_parts
|
||||||
for sql in restriction_parts
|
|
||||||
)
|
)
|
||||||
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
||||||
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
||||||
|
|
@ -692,240 +673,3 @@ async def check_permission_for_resource(
|
||||||
child=child,
|
child=child,
|
||||||
)
|
)
|
||||||
return results[action]
|
return results[action]
|
||||||
|
|
||||||
|
|
||||||
async def explain_permission_for_resource(
|
|
||||||
*,
|
|
||||||
datasette: "Datasette",
|
|
||||||
actor: dict | None,
|
|
||||||
action: str,
|
|
||||||
parent: str | None,
|
|
||||||
child: str | None,
|
|
||||||
) -> dict:
|
|
||||||
"""Explain a permission decision for one action and resource.
|
|
||||||
|
|
||||||
This is intended for Datasette's permission debugging tools. It uses the
|
|
||||||
same ``permission_resources_sql`` hook results and the same resolution
|
|
||||||
rules as :func:`check_permissions_for_actions`, but also returns the
|
|
||||||
matching rules, actor restriction results and ``also_requires`` chain.
|
|
||||||
|
|
||||||
The returned dictionary is part of Datasette's unstable debugging API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
action_obj = datasette.actions.get(action)
|
|
||||||
if action_obj is None:
|
|
||||||
raise ValueError(f"Unknown action: {action}")
|
|
||||||
|
|
||||||
explanation = await _explain_single_action(
|
|
||||||
datasette=datasette,
|
|
||||||
actor=actor,
|
|
||||||
action=action,
|
|
||||||
parent=parent,
|
|
||||||
child=child,
|
|
||||||
)
|
|
||||||
|
|
||||||
required_actions = []
|
|
||||||
if action_obj.also_requires:
|
|
||||||
required = await explain_permission_for_resource(
|
|
||||||
datasette=datasette,
|
|
||||||
actor=actor,
|
|
||||||
action=action_obj.also_requires,
|
|
||||||
parent=parent,
|
|
||||||
child=child,
|
|
||||||
)
|
|
||||||
required_actions.append(required)
|
|
||||||
|
|
||||||
explanation["required_actions"] = required_actions
|
|
||||||
explanation["allowed"] = bool(
|
|
||||||
explanation["rule_allowed"]
|
|
||||||
and explanation["restriction_allowed"]
|
|
||||||
and all(required["allowed"] for required in required_actions)
|
|
||||||
)
|
|
||||||
explanation["summary"] = _permission_explanation_summary(explanation)
|
|
||||||
return explanation
|
|
||||||
|
|
||||||
|
|
||||||
async def _explain_single_action(
|
|
||||||
*,
|
|
||||||
datasette: "Datasette",
|
|
||||||
actor: dict | None,
|
|
||||||
action: str,
|
|
||||||
parent: str | None,
|
|
||||||
child: str | None,
|
|
||||||
) -> dict:
|
|
||||||
"""Return matching rules and restrictions for a single action."""
|
|
||||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
|
||||||
|
|
||||||
permission_sqls = await gather_permission_sql_from_hooks(
|
|
||||||
datasette=datasette,
|
|
||||||
actor=actor,
|
|
||||||
action=action,
|
|
||||||
)
|
|
||||||
|
|
||||||
if permission_sqls is SKIP_PERMISSION_CHECKS:
|
|
||||||
return {
|
|
||||||
"action": action,
|
|
||||||
"rule_allowed": True,
|
|
||||||
"restriction_allowed": True,
|
|
||||||
"winning_scope": "global",
|
|
||||||
"matched_rules": [
|
|
||||||
{
|
|
||||||
"scope": "global",
|
|
||||||
"effect": "allow",
|
|
||||||
"source": "skip_permission_checks",
|
|
||||||
"reason": "Permission checks were explicitly skipped",
|
|
||||||
"decisive": True,
|
|
||||||
"ignored_because": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"restrictions": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
db = datasette.get_internal_database()
|
|
||||||
matched_rules = []
|
|
||||||
restrictions = []
|
|
||||||
child_collation = _child_collation(datasette.actions[action])
|
|
||||||
|
|
||||||
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 COLLATE {child_collation} = :{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 COLLATE {child_collation} = :{child_param})
|
|
||||||
) AS resource_is_in_allowlist
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
restriction_allowed = bool(restriction_row[0])
|
|
||||||
restrictions.append(
|
|
||||||
{
|
|
||||||
"source": permission_sql.source,
|
|
||||||
"allowed": restriction_allowed,
|
|
||||||
"reason": params.get("deny")
|
|
||||||
or (
|
|
||||||
"Resource is included in this restriction allowlist"
|
|
||||||
if restriction_allowed
|
|
||||||
else "Resource is not included in this restriction allowlist"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
matched_rules.sort(
|
|
||||||
key=lambda rule: (
|
|
||||||
-rule["_specificity"],
|
|
||||||
0 if rule["effect"] == "deny" else 1,
|
|
||||||
rule["source"] or "",
|
|
||||||
rule["reason"] or "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if matched_rules:
|
|
||||||
winning_specificity = matched_rules[0]["_specificity"]
|
|
||||||
winning_rules = [
|
|
||||||
rule
|
|
||||||
for rule in matched_rules
|
|
||||||
if rule["_specificity"] == winning_specificity
|
|
||||||
]
|
|
||||||
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
|
|
||||||
winning_scope = winning_rules[0]["scope"]
|
|
||||||
else:
|
|
||||||
winning_specificity = None
|
|
||||||
rule_allowed = False
|
|
||||||
winning_scope = None
|
|
||||||
|
|
||||||
for rule in matched_rules:
|
|
||||||
specificity = rule.pop("_specificity")
|
|
||||||
if specificity != winning_specificity:
|
|
||||||
rule["decisive"] = False
|
|
||||||
rule["ignored_because"] = "A more specific rule matched"
|
|
||||||
elif not rule_allowed and rule["effect"] == "allow":
|
|
||||||
rule["decisive"] = False
|
|
||||||
rule["ignored_because"] = "A deny rule matched at the same scope"
|
|
||||||
else:
|
|
||||||
rule["decisive"] = True
|
|
||||||
rule["ignored_because"] = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"action": action,
|
|
||||||
"rule_allowed": rule_allowed,
|
|
||||||
"restriction_allowed": all(
|
|
||||||
restriction["allowed"] for restriction in restrictions
|
|
||||||
),
|
|
||||||
"winning_scope": winning_scope,
|
|
||||||
"matched_rules": matched_rules,
|
|
||||||
"restrictions": restrictions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _unused_parameter_name(params: dict, preferred: str) -> str:
|
|
||||||
"""Return a SQL parameter name that is not already in ``params``."""
|
|
||||||
candidate = preferred
|
|
||||||
suffix = 2
|
|
||||||
while candidate in params:
|
|
||||||
candidate = f"{preferred}_{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _permission_explanation_summary(explanation: dict) -> str:
|
|
||||||
denied_requirement = next(
|
|
||||||
(
|
|
||||||
required
|
|
||||||
for required in explanation["required_actions"]
|
|
||||||
if not required["allowed"]
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if denied_requirement:
|
|
||||||
return (
|
|
||||||
f"Denied because {explanation['action']} also requires "
|
|
||||||
f"{denied_requirement['action']}, which was denied."
|
|
||||||
)
|
|
||||||
if not explanation["matched_rules"]:
|
|
||||||
return "Denied because no permission rule matched this actor and resource."
|
|
||||||
if not explanation["rule_allowed"]:
|
|
||||||
return (
|
|
||||||
f"Denied by a {explanation['winning_scope']}-level rule. "
|
|
||||||
"Deny rules take precedence over allow rules at the same scope."
|
|
||||||
)
|
|
||||||
if not explanation["restriction_allowed"]:
|
|
||||||
return (
|
|
||||||
"Denied because the resource is not included in the actor's restrictions."
|
|
||||||
)
|
|
||||||
return f"Allowed by the matching {explanation['winning_scope']}-level rule."
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,28 @@
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import re
|
from typing import Optional
|
||||||
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 datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
|
||||||
from datasette.utils.multipart import (
|
from datasette.utils.multipart import (
|
||||||
DEFAULT_MAX_FIELD_SIZE,
|
parse_form_data,
|
||||||
DEFAULT_MAX_FIELDS,
|
MultipartParseError,
|
||||||
|
FormData,
|
||||||
DEFAULT_MAX_FILE_SIZE,
|
DEFAULT_MAX_FILE_SIZE,
|
||||||
|
DEFAULT_MAX_REQUEST_SIZE,
|
||||||
|
DEFAULT_MAX_FIELDS,
|
||||||
DEFAULT_MAX_FILES,
|
DEFAULT_MAX_FILES,
|
||||||
|
DEFAULT_MAX_PARTS,
|
||||||
|
DEFAULT_MAX_FIELD_SIZE,
|
||||||
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
DEFAULT_MAX_PART_HEADER_BYTES,
|
DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
DEFAULT_MAX_PART_HEADER_LINES,
|
DEFAULT_MAX_PART_HEADER_LINES,
|
||||||
DEFAULT_MAX_PARTS,
|
|
||||||
DEFAULT_MAX_REQUEST_SIZE,
|
|
||||||
DEFAULT_MIN_FREE_DISK_BYTES,
|
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
|
# Workaround for adding samesite support to pre 3.8 python
|
||||||
Morsel._reserved["samesite"] = "SameSite"
|
Morsel._reserved["samesite"] = "SameSite"
|
||||||
|
|
@ -83,19 +81,6 @@ SAMESITE_VALUES = ("strict", "lax", "none")
|
||||||
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
class _RequestHeaders(dict):
|
|
||||||
"""Incoming headers with lowercase keys and case-insensitive lookups."""
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return super().__getitem__(key.lower())
|
|
||||||
|
|
||||||
def get(self, key, default=None):
|
|
||||||
return super().get(key.lower(), default)
|
|
||||||
|
|
||||||
def __contains__(self, key):
|
|
||||||
return super().__contains__(key.lower())
|
|
||||||
|
|
||||||
|
|
||||||
class Request:
|
class Request:
|
||||||
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
||||||
self.scope = scope
|
self.scope = scope
|
||||||
|
|
@ -103,7 +88,7 @@ class Request:
|
||||||
self.max_post_body_bytes = max_post_body_bytes
|
self.max_post_body_bytes = max_post_body_bytes
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<asgi.Request method="{self.method}" url="{self.url}">'
|
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def method(self):
|
def method(self):
|
||||||
|
|
@ -125,10 +110,10 @@ class Request:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def headers(self):
|
def headers(self):
|
||||||
return _RequestHeaders(
|
return {
|
||||||
(k.decode("latin-1").lower(), v.decode("latin-1"))
|
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||||
for k, v in self.scope.get("headers") or []
|
for k, v in self.scope.get("headers") or []
|
||||||
)
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
|
|
@ -182,7 +167,7 @@ class Request:
|
||||||
if max_bytes is None:
|
if max_bytes is None:
|
||||||
max_bytes = self.max_post_body_bytes
|
max_bytes = self.max_post_body_bytes
|
||||||
too_large = PayloadTooLarge(
|
too_large = PayloadTooLarge(
|
||||||
f"Request body exceeded maximum size of {max_bytes} bytes"
|
"Request body exceeded maximum size of {} bytes".format(max_bytes)
|
||||||
)
|
)
|
||||||
if max_bytes:
|
if max_bytes:
|
||||||
# Reject early if the client declares an oversized body
|
# Reject early if the client declares an oversized body
|
||||||
|
|
@ -221,7 +206,7 @@ class Request:
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
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_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
@ -314,24 +299,12 @@ class AsgiLifespan:
|
||||||
while True:
|
while True:
|
||||||
message = await receive()
|
message = await receive()
|
||||||
if message["type"] == "lifespan.startup":
|
if message["type"] == "lifespan.startup":
|
||||||
try:
|
|
||||||
for fn in self.on_startup:
|
for fn in self.on_startup:
|
||||||
await fn()
|
await fn()
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
await send(
|
|
||||||
{"type": "lifespan.startup.failed", "message": str(e)}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await send({"type": "lifespan.startup.complete"})
|
await send({"type": "lifespan.startup.complete"})
|
||||||
elif message["type"] == "lifespan.shutdown":
|
elif message["type"] == "lifespan.shutdown":
|
||||||
try:
|
|
||||||
for fn in self.on_shutdown:
|
for fn in self.on_shutdown:
|
||||||
await fn()
|
await fn()
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
await send(
|
|
||||||
{"type": "lifespan.shutdown.failed", "message": str(e)}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await send({"type": "lifespan.shutdown.complete"})
|
await send({"type": "lifespan.shutdown.complete"})
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
|
@ -511,8 +484,6 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
await asgi_send_html(send, "404: File not found", 404)
|
await asgi_send_html(send, "404: File not found", 404)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only the actual static-file handler can bypass dynamic response privacy.
|
|
||||||
inner_static._datasette_static = True
|
|
||||||
return inner_static
|
return inner_static
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -558,9 +529,9 @@ class Response:
|
||||||
httponly=False,
|
httponly=False,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
):
|
):
|
||||||
assert (
|
assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
|
||||||
samesite in SAMESITE_VALUES
|
SAMESITE_VALUES
|
||||||
), f"samesite should be one of {SAMESITE_VALUES}"
|
)
|
||||||
cookie = SimpleCookie()
|
cookie = SimpleCookie()
|
||||||
cookie[key] = value
|
cookie[key] = value
|
||||||
for prop_name, prop_value in (
|
for prop_name, prop_value in (
|
||||||
|
|
@ -652,23 +623,10 @@ class AsgiRunOnFirstRequest:
|
||||||
self.asgi = asgi
|
self.asgi = asgi
|
||||||
self.on_startup = on_startup
|
self.on_startup = on_startup
|
||||||
self._started = False
|
self._started = False
|
||||||
# Guards against concurrent early requests interleaving with startup:
|
|
||||||
# without this, several requests could all observe `_started is
|
|
||||||
# False` and proceed before any of them finish running the hooks.
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
async def __call__(self, scope, receive, send):
|
||||||
# Leave "lifespan" scope events alone - this shim only exists as a
|
|
||||||
# fallback for hosts that never send them. It wraps AsgiLifespan, so
|
|
||||||
# if it ran on_startup here too, a startup exception would escape
|
|
||||||
# before AsgiLifespan's own try/except got a chance to turn it into
|
|
||||||
# a lifespan.startup.failed message.
|
|
||||||
if scope["type"] != "lifespan" and not self._started:
|
|
||||||
async with self._lock:
|
|
||||||
# Re-check: another request may have finished startup while
|
|
||||||
# we were waiting for the lock.
|
|
||||||
if not self._started:
|
if not self._started:
|
||||||
|
self._started = True
|
||||||
for hook in self.on_startup:
|
for hook in self.on_startup:
|
||||||
await hook()
|
await hook()
|
||||||
self._started = True
|
|
||||||
return await self.asgi(scope, receive, send)
|
return await self.asgi(scope, receive, send)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BaseConverter:
|
class BaseConverter(object):
|
||||||
decimal_digits = "0123456789"
|
decimal_digits = "0123456789"
|
||||||
|
|
||||||
def __init__(self, digits):
|
def __init__(self, digits):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import inspect
|
import inspect
|
||||||
import types
|
import types
|
||||||
from typing import Any, NamedTuple
|
from typing import NamedTuple, Any
|
||||||
|
|
||||||
|
|
||||||
class CallableStatus(NamedTuple):
|
class CallableStatus(NamedTuple):
|
||||||
|
|
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
|
||||||
if isinstance(obj, types.FunctionType):
|
if isinstance(obj, types.FunctionType):
|
||||||
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
||||||
|
|
||||||
if callable(obj):
|
if hasattr(obj, "__call__"):
|
||||||
return CallableStatus(True, inspect.iscoroutinefunction(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))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import textwrap
|
||||||
from sqlite_utils import Database as SQLiteUtilsDatabase
|
from sqlite_utils import Database as SQLiteUtilsDatabase
|
||||||
from sqlite_utils import Migrations
|
from sqlite_utils import Migrations
|
||||||
|
|
||||||
from datasette.utils import escape_sqlite, table_column_details
|
from datasette.utils import table_column_details
|
||||||
|
|
||||||
INTERNAL_DB_SCHEMA_TABLES = {
|
INTERNAL_DB_SCHEMA_TABLES = {
|
||||||
"catalog_databases",
|
"catalog_databases",
|
||||||
|
|
@ -180,9 +180,29 @@ async def init_internal_db(db):
|
||||||
await db.execute_write_fn(apply_migrations, transaction=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
|
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
|
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
||||||
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
||||||
|
|
||||||
|
|
@ -207,30 +227,25 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
||||||
columns = table_column_details(conn, table_name)
|
columns = table_column_details(conn, table_name)
|
||||||
columns_to_insert.extend(
|
columns_to_insert.extend(
|
||||||
{
|
{
|
||||||
"database_name": database_name,
|
**{"database_name": database_name, "table_name": table_name},
|
||||||
"table_name": table_name,
|
|
||||||
**column._asdict(),
|
**column._asdict(),
|
||||||
}
|
}
|
||||||
for column in columns
|
for column in columns
|
||||||
)
|
)
|
||||||
foreign_keys = conn.execute(
|
foreign_keys = conn.execute(
|
||||||
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
|
f"PRAGMA foreign_key_list([{table_name}])"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
foreign_keys_to_insert.extend(
|
foreign_keys_to_insert.extend(
|
||||||
{
|
{
|
||||||
"database_name": database_name,
|
**{"database_name": database_name, "table_name": table_name},
|
||||||
"table_name": table_name,
|
|
||||||
**dict(foreign_key),
|
**dict(foreign_key),
|
||||||
}
|
}
|
||||||
for foreign_key in foreign_keys
|
for foreign_key in foreign_keys
|
||||||
)
|
)
|
||||||
indexes = conn.execute(
|
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
|
||||||
f"PRAGMA index_list({escape_sqlite(table_name)})"
|
|
||||||
).fetchall()
|
|
||||||
indexes_to_insert.extend(
|
indexes_to_insert.extend(
|
||||||
{
|
{
|
||||||
"database_name": database_name,
|
**{"database_name": database_name, "table_name": table_name},
|
||||||
"table_name": table_name,
|
|
||||||
**dict(index),
|
**dict(index),
|
||||||
}
|
}
|
||||||
for index in indexes
|
for index in indexes
|
||||||
|
|
@ -251,48 +266,21 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
||||||
indexes_to_insert,
|
indexes_to_insert,
|
||||||
) = await db.execute_fn(collect_info)
|
) = await db.execute_fn(collect_info)
|
||||||
|
|
||||||
def replace_catalog(conn):
|
await internal_db.execute_write_many(
|
||||||
# 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,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"""
|
"""
|
||||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||||
values (?, ?, ?, ?)
|
values (?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
tables_to_insert,
|
tables_to_insert,
|
||||||
)
|
)
|
||||||
conn.executemany(
|
await internal_db.execute_write_many(
|
||||||
"""
|
"""
|
||||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||||
values (?, ?, ?, ?)
|
values (?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
views_to_insert,
|
views_to_insert,
|
||||||
)
|
)
|
||||||
conn.executemany(
|
await internal_db.execute_write_many(
|
||||||
"""
|
"""
|
||||||
INSERT INTO catalog_columns (
|
INSERT INTO catalog_columns (
|
||||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||||
|
|
@ -302,7 +290,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
||||||
""",
|
""",
|
||||||
columns_to_insert,
|
columns_to_insert,
|
||||||
)
|
)
|
||||||
conn.executemany(
|
await internal_db.execute_write_many(
|
||||||
"""
|
"""
|
||||||
INSERT INTO catalog_foreign_keys (
|
INSERT INTO catalog_foreign_keys (
|
||||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||||
|
|
@ -312,7 +300,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
||||||
""",
|
""",
|
||||||
foreign_keys_to_insert,
|
foreign_keys_to_insert,
|
||||||
)
|
)
|
||||||
conn.executemany(
|
await internal_db.execute_write_many(
|
||||||
"""
|
"""
|
||||||
INSERT INTO catalog_indexes (
|
INSERT INTO catalog_indexes (
|
||||||
database_name, table_name, seq, name, "unique", origin, partial
|
database_name, table_name, seq, name, "unique", origin, partial
|
||||||
|
|
@ -322,5 +310,3 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
||||||
""",
|
""",
|
||||||
indexes_to_insert,
|
indexes_to_insert,
|
||||||
)
|
)
|
||||||
|
|
||||||
await internal_db.execute_write_fn(replace_catalog)
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,15 @@ Supports:
|
||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
Union,
|
||||||
)
|
)
|
||||||
from urllib.parse import parse_qsl
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
|
|
@ -24,7 +29,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
|
||||||
DEFAULT_MAX_FIELDS = 1000
|
DEFAULT_MAX_FIELDS = 1000
|
||||||
DEFAULT_MAX_FILES = 100
|
DEFAULT_MAX_FILES = 100
|
||||||
# If max_parts is not specified, it defaults to max_fields + max_files
|
# If max_parts is not specified, it defaults to max_fields + max_files
|
||||||
DEFAULT_MAX_PARTS: int | None = None
|
DEFAULT_MAX_PARTS: Optional[int] = None
|
||||||
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
|
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
|
||||||
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
|
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
|
||||||
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
|
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
|
||||||
|
|
@ -35,6 +40,8 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
|
||||||
class MultipartParseError(Exception):
|
class MultipartParseError(Exception):
|
||||||
"""Raised when multipart parsing fails."""
|
"""Raised when multipart parsing fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UploadedFile:
|
class UploadedFile:
|
||||||
|
|
@ -50,7 +57,7 @@ class UploadedFile:
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
filename: str
|
filename: str
|
||||||
content_type: str | None
|
content_type: Optional[str]
|
||||||
size: int
|
size: int
|
||||||
_file: tempfile.SpooledTemporaryFile = field(repr=False)
|
_file: tempfile.SpooledTemporaryFile = field(repr=False)
|
||||||
|
|
||||||
|
|
@ -79,8 +86,7 @@ class UploadedFile:
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
try:
|
try:
|
||||||
self._file.close()
|
self._file.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# __del__ must never raise
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,27 +98,27 @@ class FormData:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._data: list[tuple[str, str | UploadedFile]] = []
|
self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
|
||||||
|
|
||||||
def append(self, key: str, value: str | UploadedFile) -> None:
|
def append(self, key: str, value: Union[str, UploadedFile]) -> None:
|
||||||
"""Add a key-value pair."""
|
"""Add a key-value pair."""
|
||||||
self._data.append((key, value))
|
self._data.append((key, value))
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> str | UploadedFile:
|
def __getitem__(self, key: str) -> Union[str, UploadedFile]:
|
||||||
"""Get the first value for a key."""
|
"""Get the first value for a key."""
|
||||||
for k, v in self._data:
|
for k, v in self._data:
|
||||||
if k == key:
|
if k == key:
|
||||||
return v
|
return v
|
||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> str | UploadedFile | None:
|
def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
|
||||||
"""Get the first value for a key, or default if not found."""
|
"""Get the first value for a key, or default if not found."""
|
||||||
try:
|
try:
|
||||||
return self[key]
|
return self[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def getlist(self, key: str) -> list[str | UploadedFile]:
|
def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
|
||||||
"""Get all values for a key."""
|
"""Get all values for a key."""
|
||||||
return [v for k, v in self._data if k == key]
|
return [v for k, v in self._data if k == key]
|
||||||
|
|
||||||
|
|
@ -136,15 +142,15 @@ class FormData:
|
||||||
"""Return unique keys."""
|
"""Return unique keys."""
|
||||||
return list(self)
|
return list(self)
|
||||||
|
|
||||||
def items(self) -> list[tuple[str, str | UploadedFile]]:
|
def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
|
||||||
"""Return all key-value pairs."""
|
"""Return all key-value pairs."""
|
||||||
return list(self._data)
|
return list(self._data)
|
||||||
|
|
||||||
def values(self) -> list[str | UploadedFile]:
|
def values(self) -> List[Union[str, UploadedFile]]:
|
||||||
"""Return all values."""
|
"""Return all values."""
|
||||||
return [v for _, v in self._data]
|
return [v for _, v in self._data]
|
||||||
|
|
||||||
def _uploaded_files(self) -> list[UploadedFile]:
|
def _uploaded_files(self) -> List[UploadedFile]:
|
||||||
"""Return UploadedFile instances contained in this form."""
|
"""Return UploadedFile instances contained in this form."""
|
||||||
return [v for _, v in self._data if isinstance(v, UploadedFile)]
|
return [v for _, v in self._data if isinstance(v, UploadedFile)]
|
||||||
|
|
||||||
|
|
@ -157,7 +163,7 @@ class FormData:
|
||||||
for uploaded in self._uploaded_files():
|
for uploaded in self._uploaded_files():
|
||||||
try:
|
try:
|
||||||
uploaded.close_sync()
|
uploaded.close_sync()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Best-effort cleanup; ignore close errors
|
# Best-effort cleanup; ignore close errors
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -166,7 +172,7 @@ class FormData:
|
||||||
for uploaded in self._uploaded_files():
|
for uploaded in self._uploaded_files():
|
||||||
try:
|
try:
|
||||||
await uploaded.close()
|
await uploaded.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Best-effort cleanup; ignore close errors
|
# Best-effort cleanup; ignore close errors
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -183,13 +189,13 @@ class FormData:
|
||||||
await self.aclose()
|
await self.aclose()
|
||||||
|
|
||||||
|
|
||||||
def parse_content_disposition(header: str) -> dict[str, str | None]:
|
def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Parse Content-Disposition header value.
|
Parse Content-Disposition header value.
|
||||||
|
|
||||||
Returns dict with 'name', 'filename' keys (filename may be None).
|
Returns dict with 'name', 'filename' keys (filename may be None).
|
||||||
"""
|
"""
|
||||||
result: dict[str, str | None] = {"name": None, "filename": None}
|
result: Dict[str, Optional[str]] = {"name": None, "filename": None}
|
||||||
|
|
||||||
# Split on semicolons, handling quoted strings
|
# Split on semicolons, handling quoted strings
|
||||||
parts = []
|
parts = []
|
||||||
|
|
@ -232,8 +238,7 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
result["filename"] = unquote(encoded, encoding="utf-8")
|
result["filename"] = unquote(encoded, encoding="utf-8")
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Malformed RFC 5987 filename* - fall back to the plain filename
|
|
||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -245,8 +250,9 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
|
||||||
|
|
||||||
if key == "name":
|
if key == "name":
|
||||||
result["name"] = value
|
result["name"] = value
|
||||||
# Only set filename if filename* hasn't already set it
|
elif key == "filename":
|
||||||
elif key == "filename" and result["filename"] is None:
|
# Only set if filename* hasn't already set it
|
||||||
|
if result["filename"] is None:
|
||||||
# Strip path components (security)
|
# Strip path components (security)
|
||||||
# Handle both Unix and Windows paths
|
# Handle both Unix and Windows paths
|
||||||
value = value.replace("\\", "/")
|
value = value.replace("\\", "/")
|
||||||
|
|
@ -257,7 +263,7 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def parse_content_type(header: str) -> tuple[str, dict[str, str]]:
|
def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Parse Content-Type header value.
|
Parse Content-Type header value.
|
||||||
|
|
||||||
|
|
@ -301,7 +307,7 @@ class MultipartParser:
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
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_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
@ -342,12 +348,12 @@ class MultipartParser:
|
||||||
self._tempdir = tempfile.gettempdir()
|
self._tempdir = tempfile.gettempdir()
|
||||||
|
|
||||||
# Current part state
|
# Current part state
|
||||||
self.current_headers: dict[str, str] = {}
|
self.current_headers: Dict[str, str] = {}
|
||||||
self.current_file: tempfile.SpooledTemporaryFile | None = None
|
self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
|
||||||
self.current_body = bytearray()
|
self.current_body = bytearray()
|
||||||
self.current_name: str | None = None
|
self.current_name: Optional[str] = None
|
||||||
self.current_filename: str | None = None
|
self.current_filename: Optional[str] = None
|
||||||
self.current_content_type: str | None = None
|
self.current_content_type: Optional[str] = None
|
||||||
|
|
||||||
def feed(self, chunk: bytes) -> None:
|
def feed(self, chunk: bytes) -> None:
|
||||||
"""Feed a chunk of data to the parser."""
|
"""Feed a chunk of data to the parser."""
|
||||||
|
|
@ -358,13 +364,6 @@ class MultipartParser:
|
||||||
self.buffer.extend(chunk)
|
self.buffer.extend(chunk)
|
||||||
self._process()
|
self._process()
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Discard completed uploads and any file still being received."""
|
|
||||||
if self.current_file is not None:
|
|
||||||
self.current_file.close()
|
|
||||||
self.current_file = None
|
|
||||||
self.form_data.close()
|
|
||||||
|
|
||||||
def _process(self) -> None:
|
def _process(self) -> None:
|
||||||
"""Process buffered data."""
|
"""Process buffered data."""
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -455,7 +454,7 @@ class MultipartParser:
|
||||||
# Parse header
|
# Parse header
|
||||||
try:
|
try:
|
||||||
line_str = line.decode("utf-8", errors="replace")
|
line_str = line.decode("utf-8", errors="replace")
|
||||||
except UnicodeDecodeError:
|
except Exception:
|
||||||
line_str = line.decode("latin-1")
|
line_str = line.decode("latin-1")
|
||||||
|
|
||||||
if ":" in line_str:
|
if ":" in line_str:
|
||||||
|
|
@ -482,9 +481,7 @@ class MultipartParser:
|
||||||
if self.file_count > self.max_files:
|
if self.file_count > self.max_files:
|
||||||
raise MultipartParseError("Too many files")
|
raise MultipartParseError("Too many files")
|
||||||
if self.handle_files:
|
if self.handle_files:
|
||||||
# Outlives this method - it is filled in across parser callbacks
|
self.current_file = tempfile.SpooledTemporaryFile(
|
||||||
# and then handed to the UploadedFile the caller consumes
|
|
||||||
self.current_file = tempfile.SpooledTemporaryFile( # noqa: SIM115
|
|
||||||
max_size=self.max_memory_file_size
|
max_size=self.max_memory_file_size
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -584,9 +581,6 @@ class MultipartParser:
|
||||||
def _finish_part(self) -> None:
|
def _finish_part(self) -> None:
|
||||||
"""Finalize current part and add to form data."""
|
"""Finalize current part and add to form data."""
|
||||||
if self.current_name is None:
|
if self.current_name is None:
|
||||||
if self.current_file is not None:
|
|
||||||
self.current_file.close()
|
|
||||||
self.current_file = None
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.current_filename is not None:
|
if self.current_filename is not None:
|
||||||
|
|
@ -650,7 +644,7 @@ async def parse_form_data(
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
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_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
@ -732,29 +726,12 @@ async def parse_form_data(
|
||||||
batch_target = 64 * 1024
|
batch_target = 64 * 1024
|
||||||
batch = bytearray()
|
batch = bytearray()
|
||||||
|
|
||||||
async def run_parser(fn, *args):
|
|
||||||
# Cancellation must not close files while a worker is using them.
|
|
||||||
task = asyncio.create_task(asyncio.to_thread(fn, *args))
|
|
||||||
try:
|
|
||||||
return await asyncio.shield(task)
|
|
||||||
except asyncio.CancelledError as cancelled:
|
|
||||||
try:
|
|
||||||
while not task.done():
|
|
||||||
try:
|
|
||||||
await asyncio.shield(task)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
continue
|
|
||||||
task.result()
|
|
||||||
finally:
|
|
||||||
raise cancelled
|
|
||||||
|
|
||||||
async def flush_batch() -> None:
|
async def flush_batch() -> None:
|
||||||
if batch:
|
if batch:
|
||||||
data = bytes(batch)
|
data = bytes(batch)
|
||||||
batch.clear()
|
batch.clear()
|
||||||
await run_parser(parser.feed, data)
|
await asyncio.to_thread(parser.feed, data)
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
while True:
|
||||||
message = await receive()
|
message = await receive()
|
||||||
message_type = message.get("type")
|
message_type = message.get("type")
|
||||||
|
|
@ -771,11 +748,7 @@ async def parse_form_data(
|
||||||
break
|
break
|
||||||
|
|
||||||
await flush_batch()
|
await flush_batch()
|
||||||
return await run_parser(parser.finalize)
|
return await asyncio.to_thread(parser.finalize)
|
||||||
except BaseException:
|
|
||||||
# No FormData is returned to the caller to take ownership on failure.
|
|
||||||
await asyncio.to_thread(parser.close)
|
|
||||||
raise
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise MultipartParseError(
|
raise MultipartParseError(
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Iterable, Sequence
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from datasette.permissions import PermissionSQL
|
from datasette.permissions import PermissionSQL
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
@ -16,7 +15,7 @@ SKIP_PERMISSION_CHECKS = object()
|
||||||
|
|
||||||
async def gather_permission_sql_from_hooks(
|
async def gather_permission_sql_from_hooks(
|
||||||
*, datasette, actor: dict | None, action: str
|
*, datasette, actor: dict | None, action: str
|
||||||
) -> list[PermissionSQL] | object:
|
) -> List[PermissionSQL] | object:
|
||||||
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
||||||
|
|
||||||
Ensures that each returned PermissionSQL has a populated ``source``.
|
Ensures that each returned PermissionSQL has a populated ``source``.
|
||||||
|
|
@ -35,7 +34,7 @@ async def gather_permission_sql_from_hooks(
|
||||||
hookimpls = hook_caller.get_hookimpls()
|
hookimpls = hook_caller.get_hookimpls()
|
||||||
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
||||||
|
|
||||||
collected: list[PermissionSQL] = []
|
collected: List[PermissionSQL] = []
|
||||||
actor_json = json.dumps(actor) if actor is not None else None
|
actor_json = json.dumps(actor) if actor is not None else None
|
||||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||||
|
|
||||||
|
|
@ -72,7 +71,7 @@ def _iter_permission_sql_from_result(
|
||||||
if isinstance(result, PermissionSQL):
|
if isinstance(result, PermissionSQL):
|
||||||
return [result]
|
return [result]
|
||||||
if isinstance(result, (list, tuple)):
|
if isinstance(result, (list, tuple)):
|
||||||
collected: list[PermissionSQL] = []
|
collected: List[PermissionSQL] = []
|
||||||
for item in result:
|
for item in result:
|
||||||
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
||||||
return collected
|
return collected
|
||||||
|
|
@ -91,7 +90,7 @@ def _iter_permission_sql_from_result(
|
||||||
|
|
||||||
def build_rules_union(
|
def build_rules_union(
|
||||||
actor: dict | None, plugins: Sequence[PermissionSQL]
|
actor: dict | None, plugins: Sequence[PermissionSQL]
|
||||||
) -> tuple[str, dict[str, Any]]:
|
) -> Tuple[str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Compose plugin SQL into a UNION ALL.
|
Compose plugin SQL into a UNION ALL.
|
||||||
|
|
||||||
|
|
@ -103,10 +102,10 @@ def build_rules_union(
|
||||||
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
|
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
|
||||||
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
|
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
|
||||||
"""
|
"""
|
||||||
parts: list[str] = []
|
parts: List[str] = []
|
||||||
actor_json = json.dumps(actor) if actor else None
|
actor_json = json.dumps(actor) if actor else None
|
||||||
actor_id = actor.get("id") if actor else None
|
actor_id = actor.get("id") if actor else None
|
||||||
params: dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
||||||
|
|
||||||
for p in plugins:
|
for p in plugins:
|
||||||
# No namespacing - just use plugin params as-is
|
# No namespacing - just use plugin params as-is
|
||||||
|
|
@ -142,10 +141,10 @@ async def resolve_permissions_from_catalog(
|
||||||
plugins: Sequence[Any],
|
plugins: Sequence[Any],
|
||||||
action: str,
|
action: str,
|
||||||
candidate_sql: str,
|
candidate_sql: str,
|
||||||
candidate_params: dict[str, Any] | None = None,
|
candidate_params: Dict[str, Any] | None = None,
|
||||||
*,
|
*,
|
||||||
implicit_deny: bool = True,
|
implicit_deny: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
||||||
|
|
||||||
|
|
@ -169,8 +168,8 @@ async def resolve_permissions_from_catalog(
|
||||||
- parent, child, allow, reason, source_plugin, depth
|
- parent, child, allow, reason, source_plugin, depth
|
||||||
- resource (rendered "/parent/child" or "/parent" or "/")
|
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||||
"""
|
"""
|
||||||
resolved_plugins: list[PermissionSQL] = []
|
resolved_plugins: List[PermissionSQL] = []
|
||||||
restriction_sqls: list[str] = []
|
restriction_sqls: List[str] = []
|
||||||
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
||||||
|
|
@ -399,11 +398,11 @@ async def resolve_permissions_with_candidates(
|
||||||
db,
|
db,
|
||||||
actor: dict | None,
|
actor: dict | None,
|
||||||
plugins: Sequence[Any],
|
plugins: Sequence[Any],
|
||||||
candidates: list[tuple[str, str | None]],
|
candidates: List[Tuple[str, str | None]],
|
||||||
action: str,
|
action: str,
|
||||||
*,
|
*,
|
||||||
implicit_deny: bool = True,
|
implicit_deny: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Resolve permissions without any external candidate table by embedding
|
Resolve permissions without any external candidate table by embedding
|
||||||
the candidates as a UNION of parameterized SELECTs in a CTE.
|
the candidates as a UNION of parameterized SELECTs in a CTE.
|
||||||
|
|
@ -412,8 +411,8 @@ async def resolve_permissions_with_candidates(
|
||||||
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
||||||
"""
|
"""
|
||||||
# Build a small CTE for candidates.
|
# Build a small CTE for candidates.
|
||||||
cand_rows_sql: list[str] = []
|
cand_rows_sql: List[str] = []
|
||||||
cand_params: dict[str, Any] = {}
|
cand_params: Dict[str, Any] = {}
|
||||||
for i, (parent, child) in enumerate(candidates):
|
for i, (parent, child) in enumerate(candidates):
|
||||||
pkey = f"cand_p_{i}"
|
pkey = f"cand_p_{i}"
|
||||||
ckey = f"cand_c_{i}"
|
ckey = f"cand_c_{i}"
|
||||||
|
|
|
||||||
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