mirror of
https://github.com/simonw/datasette.git
synced 2026-07-09 09:04:42 +02:00
Compare commits
No commits in common. "main" and "1.0a30" have entirely different histories.
175 changed files with 3407 additions and 39400 deletions
39
.github/actions/setup-sqlite-version/action.yml
vendored
39
.github/actions/setup-sqlite-version/action.yml
vendored
|
|
@ -1,39 +0,0 @@
|
||||||
name: "Setup SQLite version"
|
|
||||||
description: "Build and activate a specific SQLite version from its amalgamation archive"
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "The SQLite version to install"
|
|
||||||
required: true
|
|
||||||
cflags:
|
|
||||||
description: "CFLAGS to use when compiling SQLite"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
skip-activate:
|
|
||||||
description: "Set to true to skip modifying the library path"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
fallback-urls:
|
|
||||||
description: "Whitespace-separated fallback download URLs to try after sqlite.org"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
outputs:
|
|
||||||
sqlite-location:
|
|
||||||
description: "Directory containing the compiled SQLite library"
|
|
||||||
value: ${{ steps.build.outputs.sqlite-location }}
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- shell: bash
|
|
||||||
run: mkdir -p "$RUNNER_TEMP/sqlite-versions/downloads"
|
|
||||||
- uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: ${{ runner.temp }}/sqlite-versions/downloads
|
|
||||||
key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1
|
|
||||||
- id: build
|
|
||||||
shell: bash
|
|
||||||
run: bash "$GITHUB_ACTION_PATH/setup-sqlite-version.sh"
|
|
||||||
env:
|
|
||||||
SQLITE_VERSION: ${{ inputs.version }}
|
|
||||||
SQLITE_CFLAGS: ${{ inputs.cflags }}
|
|
||||||
SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}
|
|
||||||
SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}"
|
|
||||||
cflags="${SQLITE_CFLAGS:-}"
|
|
||||||
skip_activate="${SQLITE_SKIP_ACTIVATE:-false}"
|
|
||||||
extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}"
|
|
||||||
|
|
||||||
case "$version_spec" in
|
|
||||||
3.46 | 3.46.0)
|
|
||||||
sqlite_version="3.46.0"
|
|
||||||
sqlite_year="2024"
|
|
||||||
amalgamation_id="3460000"
|
|
||||||
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip"
|
|
||||||
;;
|
|
||||||
3.25 | 3.25.0)
|
|
||||||
sqlite_version="3.25.0"
|
|
||||||
sqlite_year="2018"
|
|
||||||
amalgamation_id="3250000"
|
|
||||||
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3250000.zip?v=1"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Linux)
|
|
||||||
library_name="libsqlite3.so.0"
|
|
||||||
library_path_var="LD_LIBRARY_PATH"
|
|
||||||
;;
|
|
||||||
Darwin)
|
|
||||||
library_name="libsqlite3.dylib"
|
|
||||||
library_path_var="DYLD_LIBRARY_PATH"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "::error::Unsupported platform $(uname -s)"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
runner_temp="${RUNNER_TEMP:-}"
|
|
||||||
if [ -z "$runner_temp" ]; then
|
|
||||||
runner_temp="$(mktemp -d)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
filename="sqlite-amalgamation-${amalgamation_id}"
|
|
||||||
official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip"
|
|
||||||
download_dir="${runner_temp}/sqlite-versions/downloads"
|
|
||||||
source_root="${runner_temp}/sqlite-versions/source"
|
|
||||||
source_dir="${source_root}/${filename}"
|
|
||||||
build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}"
|
|
||||||
archive_path="${download_dir}/${filename}.zip"
|
|
||||||
|
|
||||||
mkdir -p "$download_dir" "$source_root" "$build_dir"
|
|
||||||
|
|
||||||
download_archive() {
|
|
||||||
local url
|
|
||||||
local candidate_path="${archive_path}.tmp"
|
|
||||||
local urls=("$official_url")
|
|
||||||
|
|
||||||
for url in $builtin_fallback_urls $extra_fallback_urls; do
|
|
||||||
urls+=("$url")
|
|
||||||
done
|
|
||||||
|
|
||||||
rm -f "$candidate_path"
|
|
||||||
for url in "${urls[@]}"; do
|
|
||||||
echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}"
|
|
||||||
if curl \
|
|
||||||
--fail \
|
|
||||||
--location \
|
|
||||||
--show-error \
|
|
||||||
--retry 5 \
|
|
||||||
--retry-delay 2 \
|
|
||||||
--retry-max-time 180 \
|
|
||||||
--retry-all-errors \
|
|
||||||
--connect-timeout 20 \
|
|
||||||
--max-time 240 \
|
|
||||||
--output "$candidate_path" \
|
|
||||||
"$url"; then
|
|
||||||
mv "$candidate_path" "$archive_path"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "::warning::Download failed from ${url}"
|
|
||||||
rm -f "$candidate_path"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "::error::Could not download SQLite ${sqlite_version} amalgamation"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ ! -f "${source_dir}/sqlite3.c" ]; then
|
|
||||||
if [ ! -f "$archive_path" ]; then
|
|
||||||
download_archive
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf "$source_dir"
|
|
||||||
unzip -q "$archive_path" -d "$source_root"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "${source_dir}/sqlite3.c" ]; then
|
|
||||||
echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
read -r -a cflag_args <<< "$cflags"
|
|
||||||
|
|
||||||
echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}"
|
|
||||||
gcc \
|
|
||||||
-fPIC \
|
|
||||||
-shared \
|
|
||||||
"${cflag_args[@]}" \
|
|
||||||
"${source_dir}/sqlite3.c" \
|
|
||||||
"-I${source_dir}" \
|
|
||||||
-o "${build_dir}/${library_name}"
|
|
||||||
|
|
||||||
if [ "$library_name" = "libsqlite3.so.0" ]; then
|
|
||||||
ln -sf "$library_name" "${build_dir}/libsqlite3.so"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
|
||||||
echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "sqlite-location=${build_dir}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in
|
|
||||||
true | 1 | yes)
|
|
||||||
echo "Skipping ${library_path_var} activation"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
existing_value="${!library_path_var:-}"
|
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
|
||||||
if [ -n "$existing_value" ]; then
|
|
||||||
echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "Added ${build_dir} to ${library_path_var}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
39
.github/workflows/deploy-latest.yml
vendored
39
.github/workflows/deploy-latest.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out datasette
|
- name: Check out datasette
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
db.route = "alternative-route"
|
db.route = "alternative-route"
|
||||||
' > plugins/alternative_route.py
|
' > plugins/alternative_route.py
|
||||||
cp fixtures.db fixtures2.db
|
cp fixtures.db fixtures2.db
|
||||||
- name: And the counters writable stored query demo
|
- name: And the counters writable canned query demo
|
||||||
run: |
|
run: |
|
||||||
cat > plugins/counters.py <<EOF
|
cat > plugins/counters.py <<EOF
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
|
@ -69,24 +69,23 @@ jobs:
|
||||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
|
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
|
||||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
||||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
||||||
for name in ("counter_a", "counter_b", "counter_c"):
|
|
||||||
await datasette.add_query(
|
|
||||||
"counters",
|
|
||||||
"increment_{}".format(name),
|
|
||||||
"update counters set value = value + 1 where name = '{}'".format(name),
|
|
||||||
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
|
||||||
is_write=True,
|
|
||||||
is_trusted=True,
|
|
||||||
)
|
|
||||||
await datasette.add_query(
|
|
||||||
"counters",
|
|
||||||
"decrement_{}".format(name),
|
|
||||||
"update counters set value = value - 1 where name = '{}'".format(name),
|
|
||||||
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
|
||||||
is_write=True,
|
|
||||||
is_trusted=True,
|
|
||||||
)
|
|
||||||
return inner
|
return inner
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(database):
|
||||||
|
if database == "counters":
|
||||||
|
queries = {}
|
||||||
|
for name in ("counter_a", "counter_b", "counter_c"):
|
||||||
|
queries["increment_{}".format(name)] = {
|
||||||
|
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
|
||||||
|
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||||
|
"write": True,
|
||||||
|
}
|
||||||
|
queries["decrement_{}".format(name)] = {
|
||||||
|
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
|
||||||
|
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||||
|
"write": True,
|
||||||
|
}
|
||||||
|
return queries
|
||||||
EOF
|
EOF
|
||||||
# - name: Make some modifications to metadata.json
|
# - name: Make some modifications to metadata.json
|
||||||
# run: |
|
# run: |
|
||||||
|
|
@ -117,7 +116,7 @@ jobs:
|
||||||
--plugins-dir=plugins \
|
--plugins-dir=plugins \
|
||||||
--branch=$GITHUB_SHA \
|
--branch=$GITHUB_SHA \
|
||||||
--version-note=$GITHUB_SHA \
|
--version-note=$GITHUB_SHA \
|
||||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
|
||||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||||
--service "datasette-latest$SUFFIX" \
|
--service "datasette-latest$SUFFIX" \
|
||||||
--secret $LATEST_DATASETTE_SECRET
|
--secret $LATEST_DATASETTE_SECRET
|
||||||
|
|
|
||||||
16
.github/workflows/documentation-links.yml
vendored
Normal file
16
.github/workflows/documentation-links.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
name: Read the Docs Pull Request Preview
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
documentation-links:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: readthedocs/actions/preview@v1
|
||||||
|
with:
|
||||||
|
project-slug: "datasette"
|
||||||
48
.github/workflows/playwright.yml
vendored
48
.github/workflows/playwright.yml
vendored
|
|
@ -1,48 +0,0 @@
|
||||||
name: Playwright
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
browser: [chromium, firefox, webkit]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Set up Python 3.14
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: "3.14"
|
|
||||||
allow-prereleases: true
|
|
||||||
cache: pip
|
|
||||||
cache-dependency-path: pyproject.toml
|
|
||||||
- name: Cache uv
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: ~/.cache/uv
|
|
||||||
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-py3.14-uv-
|
|
||||||
- name: Cache Playwright browsers
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright/
|
|
||||||
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-playwright-${{ matrix.browser }}-
|
|
||||||
- name: Install uv
|
|
||||||
run: python -m pip install uv
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --group dev --group playwright
|
|
||||||
- name: Install ${{ matrix.browser }}
|
|
||||||
run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }}
|
|
||||||
- name: Run Playwright tests
|
|
||||||
run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }}
|
|
||||||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
- uses: actions/cache@v6
|
- uses: actions/cache@v5
|
||||||
name: Configure npm caching
|
name: Configure npm caching
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
|
|
|
||||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -35,7 +35,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
if: "!github.event.release.prerelease"
|
if: "!github.event.release.prerelease"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -92,7 +92,7 @@ jobs:
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
if: "!github.event.release.prerelease"
|
if: "!github.event.release.prerelease"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Build and push to Docker Hub
|
- name: Build and push to Docker Hub
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
|
|
||||||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
deploy_docker:
|
deploy_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Build and push to Docker Hub
|
- name: Build and push to Docker Hub
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
|
|
||||||
2
.github/workflows/spellcheck.yml
vendored
2
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
||||||
spellcheck:
|
spellcheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/stable-docs.yml
vendored
2
.github/workflows/stable-docs.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||||
- name: Set up Git user
|
- name: Set up Git user
|
||||||
|
|
|
||||||
2
.github/workflows/test-coverage.yml
vendored
2
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out datasette
|
- name: Check out datasette
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
4
.github/workflows/test-pyodide.yml
vendored
4
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
cache-dependency-path: '**/pyproject.toml'
|
cache-dependency-path: '**/pyproject.toml'
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v6
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright/
|
path: ~/.cache/ms-playwright/
|
||||||
key: ${{ runner.os }}-browsers
|
key: ${{ runner.os }}-browsers
|
||||||
|
|
|
||||||
4
.github/workflows/test-sqlite-support.yml
vendored
4
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
#"3.23.1" # 2018-04-10, before UPSERT
|
#"3.23.1" # 2018-04-10, before UPSERT
|
||||||
]
|
]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: pyproject.toml
|
cache-dependency-path: pyproject.toml
|
||||||
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
||||||
uses: ./.github/actions/setup-sqlite-version
|
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
|
||||||
with:
|
with:
|
||||||
version: ${{ matrix.sqlite-version }}
|
version: ${{ matrix.sqlite-version }}
|
||||||
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
||||||
|
|
|
||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
|
@ -9,11 +9,10 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
|
|
||||||
2
.github/workflows/tmate.yml
vendored
2
.github/workflows/tmate.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,12 +1,8 @@
|
||||||
build-metadata.json
|
build-metadata.json
|
||||||
datasets.json
|
datasets.json
|
||||||
|
|
||||||
.playwright-mcp
|
|
||||||
|
|
||||||
scratchpad
|
scratchpad
|
||||||
|
|
||||||
ignored/
|
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
uv.lock
|
uv.lock
|
||||||
|
|
|
||||||
19
Justfile
19
Justfile
|
|
@ -11,33 +11,16 @@ export DATASETTE_SECRET := "not_a_secret"
|
||||||
@test *options: init
|
@test *options: init
|
||||||
uv run pytest -n auto {{options}}
|
uv run pytest -n auto {{options}}
|
||||||
|
|
||||||
# Install Playwright browser support, Chromium by default
|
|
||||||
@playwright-install browser="chromium":
|
|
||||||
uv run --group playwright playwright install {{browser}}
|
|
||||||
|
|
||||||
# Install all Playwright browsers used by the test suite
|
|
||||||
@playwright-install-all:
|
|
||||||
uv run --group playwright playwright install chromium firefox webkit
|
|
||||||
|
|
||||||
# Run Playwright tests, Chromium by default
|
|
||||||
@playwright browser="chromium" *options:
|
|
||||||
uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}}
|
|
||||||
|
|
||||||
# Run Playwright tests against all supported browsers
|
|
||||||
@playwright-all *options:
|
|
||||||
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}}
|
|
||||||
|
|
||||||
@codespell:
|
@codespell:
|
||||||
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||||
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||||
|
|
||||||
# Run linters: black, ruff, prettier, cog
|
# Run linters: black, ruff, cog
|
||||||
@lint: codespell
|
@lint: codespell
|
||||||
uv run black datasette tests --check
|
uv run black datasette tests --check
|
||||||
uv run ruff check datasette tests
|
uv run ruff check datasette tests
|
||||||
npm run prettier -- --check
|
|
||||||
uv run cog --check README.md docs/*.rst
|
uv run cog --check README.md docs/*.rst
|
||||||
|
|
||||||
# Apply ruff fixes
|
# Apply ruff fixes
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
from datasette.permissions import Permission # noqa
|
from datasette.permissions import Permission # noqa
|
||||||
from datasette.version import __version_info__, __version__ # noqa
|
from datasette.version import __version_info__, __version__ # noqa
|
||||||
from datasette.events import Event # noqa
|
from datasette.events import Event # noqa
|
||||||
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
|
||||||
from datasette.utils.asgi import ( # noqa
|
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
||||||
Forbidden,
|
|
||||||
NotFound,
|
|
||||||
PayloadTooLarge,
|
|
||||||
Request,
|
|
||||||
Response,
|
|
||||||
)
|
|
||||||
from datasette.utils import actor_matches_allow # noqa
|
from datasette.utils import actor_matches_allow # noqa
|
||||||
from datasette.views import Context # noqa
|
from datasette.views import Context # noqa
|
||||||
from .hookspecs import hookimpl # noqa
|
from .hookspecs import hookimpl # noqa
|
||||||
|
|
|
||||||
|
|
@ -19,40 +19,25 @@ import weakref
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from datasette.app import Datasette
|
||||||
|
|
||||||
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
|
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
|
||||||
"datasette_active_instances", default=None
|
"datasette_active_instances", default=None
|
||||||
)
|
)
|
||||||
|
|
||||||
_original_init = None
|
|
||||||
|
|
||||||
|
|
||||||
def _install_tracking():
|
|
||||||
# datasette.app is imported lazily here rather than at module level:
|
|
||||||
# as a pytest11 entry point this module is imported during pytest
|
|
||||||
# startup, before pytest-cov starts measuring, so a module-level
|
|
||||||
# import would drag in all of datasette and make every import-time
|
|
||||||
# line in the package invisible to coverage
|
|
||||||
global _original_init
|
|
||||||
if _original_init is not None:
|
|
||||||
return
|
|
||||||
from datasette.app import Datasette
|
|
||||||
|
|
||||||
_original_init = Datasette.__init__
|
_original_init = Datasette.__init__
|
||||||
|
|
||||||
|
|
||||||
def _tracking_init(self, *args, **kwargs):
|
def _tracking_init(self, *args, **kwargs):
|
||||||
_original_init(self, *args, **kwargs)
|
_original_init(self, *args, **kwargs)
|
||||||
instances = _active_instances.get()
|
instances = _active_instances.get()
|
||||||
if instances is not None:
|
if instances is not None:
|
||||||
instances.append(weakref.ref(self))
|
instances.append(weakref.ref(self))
|
||||||
|
|
||||||
|
|
||||||
Datasette.__init__ = _tracking_init
|
Datasette.__init__ = _tracking_init
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
|
||||||
if _enabled(config):
|
|
||||||
_install_tracking()
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
parser.addini(
|
parser.addini(
|
||||||
"datasette_autoclose",
|
"datasette_autoclose",
|
||||||
|
|
|
||||||
677
datasette/app.py
677
datasette/app.py
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextvars
|
import contextvars
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.permissions import Resource
|
from datasette.permissions import Resource
|
||||||
|
|
@ -12,6 +12,7 @@ import dataclasses
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
import glob
|
import glob
|
||||||
|
import hashlib
|
||||||
import httpx
|
import httpx
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import inspect
|
import inspect
|
||||||
|
|
@ -34,7 +35,6 @@ from jinja2 import (
|
||||||
ChoiceLoader,
|
ChoiceLoader,
|
||||||
Environment,
|
Environment,
|
||||||
FileSystemLoader,
|
FileSystemLoader,
|
||||||
pass_context,
|
|
||||||
PrefixLoader,
|
PrefixLoader,
|
||||||
)
|
)
|
||||||
from jinja2.environment import Template
|
from jinja2.environment import Template
|
||||||
|
|
@ -42,36 +42,12 @@ from jinja2.exceptions import TemplateNotFound
|
||||||
|
|
||||||
from .events import Event
|
from .events import Event
|
||||||
from .column_types import SQLiteType
|
from .column_types import SQLiteType
|
||||||
from . import stored_queries, write_sql
|
|
||||||
from .views import Context
|
from .views import Context
|
||||||
from .views.database import (
|
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
|
||||||
database_download,
|
|
||||||
DatabaseView,
|
|
||||||
QueryView,
|
|
||||||
)
|
|
||||||
from .views.table_create_alter import (
|
|
||||||
DatabaseForeignKeyTargetsView,
|
|
||||||
TableAlterView,
|
|
||||||
TableCreateView,
|
|
||||||
TableForeignKeySuggestionsView,
|
|
||||||
)
|
|
||||||
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
|
||||||
from .views.stored_queries import (
|
|
||||||
QueryCreateAnalyzeView,
|
|
||||||
QueryDeleteView,
|
|
||||||
QueryDefinitionView,
|
|
||||||
QueryEditView,
|
|
||||||
GlobalQueryListView,
|
|
||||||
QueryListView,
|
|
||||||
QueryParametersView,
|
|
||||||
QueryStoreView,
|
|
||||||
QueryUpdateView,
|
|
||||||
)
|
|
||||||
from .views.index import IndexView
|
from .views.index import IndexView
|
||||||
from .views.special import (
|
from .views.special import (
|
||||||
JsonDataView,
|
JsonDataView,
|
||||||
PatternPortfolioView,
|
PatternPortfolioView,
|
||||||
AutocompleteDebugView,
|
|
||||||
AuthTokenView,
|
AuthTokenView,
|
||||||
ApiExplorerView,
|
ApiExplorerView,
|
||||||
CreateTokenView,
|
CreateTokenView,
|
||||||
|
|
@ -88,12 +64,10 @@ from .views.special import (
|
||||||
TableSchemaView,
|
TableSchemaView,
|
||||||
)
|
)
|
||||||
from .views.table import (
|
from .views.table import (
|
||||||
TableAutocompleteView,
|
|
||||||
TableInsertView,
|
TableInsertView,
|
||||||
TableUpsertView,
|
TableUpsertView,
|
||||||
TableSetColumnTypeView,
|
TableSetColumnTypeView,
|
||||||
TableDropView,
|
TableDropView,
|
||||||
TableFragmentView,
|
|
||||||
table_view,
|
table_view,
|
||||||
)
|
)
|
||||||
from .views.row import RowView, RowDeleteView, RowUpdateView
|
from .views.row import RowView, RowDeleteView, RowUpdateView
|
||||||
|
|
@ -111,7 +85,6 @@ from .utils import (
|
||||||
baseconv,
|
baseconv,
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
detect_json1,
|
detect_json1,
|
||||||
add_cors_headers,
|
|
||||||
display_actor,
|
display_actor,
|
||||||
escape_css_string,
|
escape_css_string,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
|
|
@ -123,7 +96,6 @@ from .utils import (
|
||||||
parse_metadata,
|
parse_metadata,
|
||||||
resolve_env_secrets,
|
resolve_env_secrets,
|
||||||
resolve_routes,
|
resolve_routes,
|
||||||
sha256_file,
|
|
||||||
tilde_decode,
|
tilde_decode,
|
||||||
tilde_encode,
|
tilde_encode,
|
||||||
to_css_class,
|
to_css_class,
|
||||||
|
|
@ -131,10 +103,8 @@ from .utils import (
|
||||||
redact_keys,
|
redact_keys,
|
||||||
row_sql_params_pks,
|
row_sql_params_pks,
|
||||||
)
|
)
|
||||||
from .tokens import TokenInvalid
|
|
||||||
from .utils.asgi import (
|
from .utils.asgi import (
|
||||||
AsgiLifespan,
|
AsgiLifespan,
|
||||||
BadRequest,
|
|
||||||
Forbidden,
|
Forbidden,
|
||||||
NotFound,
|
NotFound,
|
||||||
DatabaseNotFound,
|
DatabaseNotFound,
|
||||||
|
|
@ -209,11 +179,6 @@ SETTINGS = (
|
||||||
100,
|
100,
|
||||||
"Maximum rows that can be inserted at a time using the bulk insert API",
|
"Maximum rows that can be inserted at a time using the bulk insert API",
|
||||||
),
|
),
|
||||||
Setting(
|
|
||||||
"max_post_body_bytes",
|
|
||||||
2 * 1024 * 1024,
|
|
||||||
"Maximum size in bytes for a POST body read into memory, e.g. JSON API requests - set 0 to disable this limit",
|
|
||||||
),
|
|
||||||
Setting(
|
Setting(
|
||||||
"num_sql_threads",
|
"num_sql_threads",
|
||||||
3,
|
3,
|
||||||
|
|
@ -308,21 +273,12 @@ DEFAULT_NOT_SET = object()
|
||||||
ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params"))
|
ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params"))
|
||||||
|
|
||||||
|
|
||||||
def _permission_cache_key(actor, action, parent, child):
|
|
||||||
# Key on the full serialized actor so actors differing in any field
|
|
||||||
# (e.g. token restrictions) never share cache entries
|
|
||||||
actor_key = (
|
|
||||||
json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
|
|
||||||
)
|
|
||||||
return (actor_key, action, parent, child)
|
|
||||||
|
|
||||||
|
|
||||||
async def favicon(request, send):
|
async def favicon(request, send):
|
||||||
await asgi_send_file(
|
await asgi_send_file(
|
||||||
send,
|
send,
|
||||||
str(FAVICON_PATH),
|
str(FAVICON_PATH),
|
||||||
content_type="image/png",
|
content_type="image/png",
|
||||||
headers={"Cache-Control": "max-age=3600, public"},
|
headers={"Cache-Control": "max-age=3600, immutable, public"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -339,57 +295,6 @@ def _to_string(value):
|
||||||
return json.dumps(value, default=str)
|
return json.dumps(value, default=str)
|
||||||
|
|
||||||
|
|
||||||
def _template_context_json_default(value):
|
|
||||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
||||||
return {
|
|
||||||
field.name: getattr(value, field.name)
|
|
||||||
for field in dataclasses.fields(value)
|
|
||||||
}
|
|
||||||
return repr(value)
|
|
||||||
|
|
||||||
|
|
||||||
@pass_context
|
|
||||||
def _legacy_template_csrftoken(context):
|
|
||||||
request = context.get("request")
|
|
||||||
if request and "csrftoken" in request.scope:
|
|
||||||
return request.scope["csrftoken"]()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_static_asset_path(root_path, path):
|
|
||||||
root = Path(root_path).resolve()
|
|
||||||
full_path = (root / path).resolve()
|
|
||||||
try:
|
|
||||||
full_path.relative_to(root)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("Static asset path cannot escape static root") from None
|
|
||||||
return full_path
|
|
||||||
|
|
||||||
|
|
||||||
# Documentation for the variables Datasette.render_template() adds to the
|
|
||||||
# context for every page. This is part of the documented template contract:
|
|
||||||
# keys added in render_template() must be documented here - the contract
|
|
||||||
# tests in tests/test_template_context.py enforce this, and the docs in
|
|
||||||
# docs/template_context.rst are generated from it.
|
|
||||||
TEMPLATE_BASE_CONTEXT = {
|
|
||||||
"request": "The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.",
|
|
||||||
"crumb_items": 'Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.',
|
|
||||||
"urls": "Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.",
|
|
||||||
"actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.",
|
|
||||||
"menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.",
|
|
||||||
"display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.",
|
|
||||||
"show_logout": "True if the logout link should be shown in the navigation menu",
|
|
||||||
"zip": "Python's ``zip()`` builtin, made available to template logic",
|
|
||||||
"body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``<script type="module">``; otherwise Datasette wraps it in a regular ``<script>`` block.',
|
|
||||||
"format_bytes": "Function that accepts a byte count integer and returns a human-readable string such as ``1.2 MB``.",
|
|
||||||
"show_messages": "Function returning any messages set for the current user, clearing them in the process. Returns a list of ``(message, type)`` pairs, where ``type`` is one of Datasette's ``INFO``, ``WARNING`` or ``ERROR`` constants.",
|
|
||||||
"extra_css_urls": "List of extra CSS stylesheets to include on the page. Each item is a dictionary with ``url`` and optional ``sri`` keys, from plugins and configuration.",
|
|
||||||
"extra_js_urls": "List of extra JavaScript URLs to include on the page. Each item is a dictionary with ``url`` plus optional ``sri`` and ``module`` keys, from plugins and configuration.",
|
|
||||||
"base_url": "The configured :ref:`setting_base_url` setting",
|
|
||||||
"datasette_version": "The version of Datasette that is running",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Datasette:
|
class Datasette:
|
||||||
# Message constants:
|
# Message constants:
|
||||||
INFO = 1
|
INFO = 1
|
||||||
|
|
@ -482,7 +387,6 @@ class Datasette:
|
||||||
self._internal_database.name = INTERNAL_DB_NAME
|
self._internal_database.name = INTERNAL_DB_NAME
|
||||||
|
|
||||||
self.cache_headers = cache_headers
|
self.cache_headers = cache_headers
|
||||||
self._static_asset_hashes = {}
|
|
||||||
self.cors = cors
|
self.cors = cors
|
||||||
config_files = []
|
config_files = []
|
||||||
metadata_files = []
|
metadata_files = []
|
||||||
|
|
@ -623,8 +527,6 @@ class Datasette:
|
||||||
)
|
)
|
||||||
environment.filters["escape_css_string"] = escape_css_string
|
environment.filters["escape_css_string"] = escape_css_string
|
||||||
environment.filters["quote_plus"] = urllib.parse.quote_plus
|
environment.filters["quote_plus"] = urllib.parse.quote_plus
|
||||||
environment.globals["csrftoken"] = _legacy_template_csrftoken
|
|
||||||
environment.globals["static"] = self.static
|
|
||||||
self._jinja_env = environment
|
self._jinja_env = environment
|
||||||
environment.filters["escape_sqlite"] = escape_sqlite
|
environment.filters["escape_sqlite"] = escape_sqlite
|
||||||
environment.filters["to_css_class"] = to_css_class
|
environment.filters["to_css_class"] = to_css_class
|
||||||
|
|
@ -669,9 +571,6 @@ class Datasette:
|
||||||
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
|
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
|
||||||
# a warning to user that they should delete their metadata.json file
|
# a warning to user that they should delete their metadata.json file
|
||||||
|
|
||||||
async def _save_queries_from_config(self):
|
|
||||||
await stored_queries.save_queries_from_config(self)
|
|
||||||
|
|
||||||
def get_jinja_environment(self, request: Request = None) -> Environment:
|
def get_jinja_environment(self, request: Request = None) -> Environment:
|
||||||
environment = self._jinja_env
|
environment = self._jinja_env
|
||||||
if request:
|
if request:
|
||||||
|
|
@ -693,12 +592,9 @@ class Datasette:
|
||||||
return action
|
return action
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def refresh_schemas(self, *, force=False):
|
async def refresh_schemas(self):
|
||||||
# Throttle schema refreshes to at most once per second
|
# Throttle schema refreshes to at most once per second
|
||||||
if (
|
if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0:
|
||||||
not force
|
|
||||||
and time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
self._last_schema_refresh = time.monotonic()
|
self._last_schema_refresh = time.monotonic()
|
||||||
if self._refresh_schemas_lock.locked():
|
if self._refresh_schemas_lock.locked():
|
||||||
|
|
@ -835,7 +731,6 @@ class Datasette:
|
||||||
await await_me_maybe(hook)
|
await await_me_maybe(hook)
|
||||||
# Ensure internal tables and metadata are populated before startup hooks
|
# Ensure internal tables and metadata are populated before startup hooks
|
||||||
await self._refresh_schemas()
|
await self._refresh_schemas()
|
||||||
await self._save_queries_from_config()
|
|
||||||
# Load column_types from config into internal DB
|
# Load column_types from config into internal DB
|
||||||
await self._apply_column_types_config()
|
await self._apply_column_types_config()
|
||||||
for hook in pm.hook.startup(datasette=self):
|
for hook in pm.hook.startup(datasette=self):
|
||||||
|
|
@ -913,9 +808,7 @@ class Datasette:
|
||||||
Verify an API token by trying all registered token handlers.
|
Verify an API token by trying all registered token handlers.
|
||||||
|
|
||||||
Returns an actor dict from the first handler that recognizes the
|
Returns an actor dict from the first handler that recognizes the
|
||||||
token, or None if no handler accepts it. A handler may raise
|
token, or None if no handler accepts it.
|
||||||
TokenInvalid for a token it recognizes but rejects (bad signature,
|
|
||||||
expired) - Datasette turns that into a 401 response.
|
|
||||||
"""
|
"""
|
||||||
for token_handler in self._token_handlers():
|
for token_handler in self._token_handlers():
|
||||||
result = await token_handler.verify_token(self, token)
|
result = await token_handler.verify_token(self, token)
|
||||||
|
|
@ -1114,180 +1007,6 @@ class Datasette:
|
||||||
[database_name, resource_name, column_name, key, value],
|
[database_name, resource_name, column_name, key, value],
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None:
|
|
||||||
return stored_queries.query_row_to_stored_query(row)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _query_options_json(options):
|
|
||||||
return stored_queries.query_options_json(options)
|
|
||||||
|
|
||||||
async def add_query(
|
|
||||||
self,
|
|
||||||
database: str,
|
|
||||||
name: str,
|
|
||||||
sql: str,
|
|
||||||
*,
|
|
||||||
title: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
description_html: str | None = None,
|
|
||||||
hide_sql: bool = False,
|
|
||||||
fragment: str | None = None,
|
|
||||||
parameters: Iterable[str] | None = None,
|
|
||||||
is_write: bool = False,
|
|
||||||
is_private: bool = False,
|
|
||||||
is_trusted: bool = False,
|
|
||||||
source: str = "plugin",
|
|
||||||
owner_id: str | None = None,
|
|
||||||
on_success_message: str | None = None,
|
|
||||||
on_success_message_sql: str | None = None,
|
|
||||||
on_success_redirect: str | None = None,
|
|
||||||
on_error_message: str | None = None,
|
|
||||||
on_error_redirect: str | None = None,
|
|
||||||
replace: bool = True,
|
|
||||||
) -> None:
|
|
||||||
return await stored_queries.add_query(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
name,
|
|
||||||
sql,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
description_html=description_html,
|
|
||||||
hide_sql=hide_sql,
|
|
||||||
fragment=fragment,
|
|
||||||
parameters=parameters,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
on_success_message=on_success_message,
|
|
||||||
on_success_message_sql=on_success_message_sql,
|
|
||||||
on_success_redirect=on_success_redirect,
|
|
||||||
on_error_message=on_error_message,
|
|
||||||
on_error_redirect=on_error_redirect,
|
|
||||||
replace=replace,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update_query(
|
|
||||||
self,
|
|
||||||
database: str,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
sql=stored_queries.UNCHANGED,
|
|
||||||
title=stored_queries.UNCHANGED,
|
|
||||||
description=stored_queries.UNCHANGED,
|
|
||||||
description_html=stored_queries.UNCHANGED,
|
|
||||||
hide_sql=stored_queries.UNCHANGED,
|
|
||||||
fragment=stored_queries.UNCHANGED,
|
|
||||||
parameters=stored_queries.UNCHANGED,
|
|
||||||
is_write=stored_queries.UNCHANGED,
|
|
||||||
is_private=stored_queries.UNCHANGED,
|
|
||||||
is_trusted=stored_queries.UNCHANGED,
|
|
||||||
source=stored_queries.UNCHANGED,
|
|
||||||
owner_id=stored_queries.UNCHANGED,
|
|
||||||
on_success_message=stored_queries.UNCHANGED,
|
|
||||||
on_success_message_sql=stored_queries.UNCHANGED,
|
|
||||||
on_success_redirect=stored_queries.UNCHANGED,
|
|
||||||
on_error_message=stored_queries.UNCHANGED,
|
|
||||||
on_error_redirect=stored_queries.UNCHANGED,
|
|
||||||
) -> None:
|
|
||||||
return await stored_queries.update_query(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
name,
|
|
||||||
sql=sql,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
description_html=description_html,
|
|
||||||
hide_sql=hide_sql,
|
|
||||||
fragment=fragment,
|
|
||||||
parameters=parameters,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
on_success_message=on_success_message,
|
|
||||||
on_success_message_sql=on_success_message_sql,
|
|
||||||
on_success_redirect=on_success_redirect,
|
|
||||||
on_error_message=on_error_message,
|
|
||||||
on_error_redirect=on_error_redirect,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def remove_query(
|
|
||||||
self, database: str, name: str, source: str | None = None
|
|
||||||
) -> None:
|
|
||||||
return await stored_queries.remove_query(self, database, name, source=source)
|
|
||||||
|
|
||||||
async def get_query(
|
|
||||||
self, database: str, name: str
|
|
||||||
) -> stored_queries.StoredQuery | None:
|
|
||||||
return await stored_queries.get_query(self, database, name)
|
|
||||||
|
|
||||||
async def count_queries(
|
|
||||||
self,
|
|
||||||
database: str | None = None,
|
|
||||||
*,
|
|
||||||
actor: dict[str, Any] | None = None,
|
|
||||||
q: str | None = None,
|
|
||||||
is_write: bool | None = None,
|
|
||||||
is_private: bool | None = None,
|
|
||||||
is_trusted: bool | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
owner_id: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
return await stored_queries.count_queries(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
actor=actor,
|
|
||||||
q=q,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def list_queries(
|
|
||||||
self,
|
|
||||||
database: str | None = None,
|
|
||||||
*,
|
|
||||||
actor: dict[str, Any] | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
cursor: str | None = None,
|
|
||||||
q: str | None = None,
|
|
||||||
is_write: bool | None = None,
|
|
||||||
is_private: bool | None = None,
|
|
||||||
is_trusted: bool | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
owner_id: str | None = None,
|
|
||||||
include_private: bool = False,
|
|
||||||
) -> stored_queries.StoredQueryPage:
|
|
||||||
return await stored_queries.list_queries(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
actor=actor,
|
|
||||||
limit=limit,
|
|
||||||
cursor=cursor,
|
|
||||||
q=q,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
include_private=include_private,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def ensure_query_write_permissions(
|
|
||||||
self, database, sql, *, actor=None, params=None, analysis=None
|
|
||||||
):
|
|
||||||
# Raise Forbidden or QueryWriteRejected if SQL should not run
|
|
||||||
return await write_sql.ensure_query_write_permissions(
|
|
||||||
self, database, sql, actor=actor, params=params, analysis=analysis
|
|
||||||
)
|
|
||||||
|
|
||||||
# Column types API
|
# Column types API
|
||||||
|
|
||||||
async def _get_resource_column_details(self, database: str, resource: str):
|
async def _get_resource_column_details(self, database: str, resource: str):
|
||||||
|
|
@ -1500,55 +1219,47 @@ class Datasette:
|
||||||
|
|
||||||
return db_plugin_config
|
return db_plugin_config
|
||||||
|
|
||||||
def _static_asset_path(self, path):
|
def static_hash(self, filename):
|
||||||
return _resolve_static_asset_path(app_root / "datasette" / "static", path)
|
if not hasattr(self, "_static_hashes"):
|
||||||
|
self._static_hashes = {}
|
||||||
|
path = os.path.join(str(app_root), "datasette/static", filename)
|
||||||
|
signature = (os.path.getmtime(path), os.path.getsize(path))
|
||||||
|
cached = self._static_hashes.get(filename)
|
||||||
|
if cached and cached["signature"] == signature:
|
||||||
|
return cached["hash"]
|
||||||
|
with open(path) as fp:
|
||||||
|
static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6]
|
||||||
|
self._static_hashes[filename] = {
|
||||||
|
"signature": signature,
|
||||||
|
"hash": static_hash,
|
||||||
|
}
|
||||||
|
return static_hash
|
||||||
|
|
||||||
def _static_plugin_asset_path(self, plugin_name, path):
|
def app_css_hash(self):
|
||||||
for plugin in get_plugins():
|
return self.static_hash("app.css")
|
||||||
if not plugin["static_path"]:
|
|
||||||
continue
|
|
||||||
possible_names = {plugin["name"], plugin["name"].replace("-", "_")}
|
|
||||||
if plugin_name in possible_names:
|
|
||||||
return _resolve_static_asset_path(plugin["static_path"], path)
|
|
||||||
raise FileNotFoundError(
|
|
||||||
"No static assets found for plugin {}".format(plugin_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _static_mounted_asset(self, mount_name, path):
|
async def get_canned_queries(self, database_name, actor):
|
||||||
mount_name = mount_name.strip("/")
|
queries = {}
|
||||||
for mount, dirname in self.static_mounts:
|
for more_queries in pm.hook.canned_queries(
|
||||||
if mount.strip("/") == mount_name:
|
datasette=self,
|
||||||
return (
|
database=database_name,
|
||||||
_resolve_static_asset_path(dirname, path),
|
actor=actor,
|
||||||
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))),
|
):
|
||||||
)
|
more_queries = await await_me_maybe(more_queries)
|
||||||
raise FileNotFoundError("No static mount found for {}".format(mount_name))
|
queries.update(more_queries or {})
|
||||||
|
# Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
|
||||||
|
for key in queries:
|
||||||
|
if not isinstance(queries[key], dict):
|
||||||
|
queries[key] = {"sql": queries[key]}
|
||||||
|
# Also make sure "name" is available:
|
||||||
|
queries[key]["name"] = key
|
||||||
|
return queries
|
||||||
|
|
||||||
def _static_asset_hash(self, filepath):
|
async def get_canned_query(self, database_name, query_name, actor):
|
||||||
filepath = Path(filepath)
|
queries = await self.get_canned_queries(database_name, actor)
|
||||||
if self.cache_headers:
|
query = queries.get(query_name)
|
||||||
cached = self._static_asset_hashes.get(filepath)
|
if query:
|
||||||
if cached:
|
return query
|
||||||
return cached
|
|
||||||
digest = sha256_file(filepath)[:12]
|
|
||||||
if self.cache_headers:
|
|
||||||
self._static_asset_hashes[filepath] = digest
|
|
||||||
return digest
|
|
||||||
|
|
||||||
def static(self, path, plugin=None, mount=None):
|
|
||||||
if plugin and mount:
|
|
||||||
raise ValueError("Use either plugin= or mount=, not both")
|
|
||||||
if plugin:
|
|
||||||
filepath = self._static_plugin_asset_path(plugin, path)
|
|
||||||
url = self.urls.static_plugins(plugin, path)
|
|
||||||
elif mount:
|
|
||||||
filepath, url = self._static_mounted_asset(mount, path)
|
|
||||||
else:
|
|
||||||
filepath = self._static_asset_path(path)
|
|
||||||
url = self.urls.static(path)
|
|
||||||
hash_value = self._static_asset_hash(filepath)
|
|
||||||
separator = "&" if "?" in url else "?"
|
|
||||||
return url + separator + urllib.parse.urlencode({"_hash": hash_value})
|
|
||||||
|
|
||||||
def _prepare_connection(self, conn, database):
|
def _prepare_connection(self, conn, database):
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
@ -1933,124 +1644,46 @@ class Datasette:
|
||||||
# For global actions, resource can be omitted:
|
# For global actions, resource can be omitted:
|
||||||
can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
|
can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
|
||||||
"""
|
"""
|
||||||
results = await self.allowed_many(
|
from datasette.utils.actions_sql import check_permission_for_resource
|
||||||
actions=[action], resource=resource, actor=actor
|
|
||||||
)
|
|
||||||
return results[action]
|
|
||||||
|
|
||||||
async def allowed_many(
|
# For global actions, resource remains None
|
||||||
self,
|
|
||||||
*,
|
|
||||||
actions: Sequence[str],
|
|
||||||
resource: "Resource" = None,
|
|
||||||
actor: dict | None = None,
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
"""
|
|
||||||
Check several actions against one resource for one actor.
|
|
||||||
|
|
||||||
Resolves every action (plus any also_requires dependencies) with a
|
# Check if this action has also_requires - if so, check that action first
|
||||||
single internal database query, instead of one or two queries per
|
action_obj = self.actions.get(action)
|
||||||
action. Results are stored in the request-scoped permission cache,
|
if action_obj and action_obj.also_requires:
|
||||||
so subsequent datasette.allowed() calls for the same checks within
|
# Must have the required action first
|
||||||
the same request are served from the cache.
|
if not await self.allowed(
|
||||||
|
action=action_obj.also_requires,
|
||||||
Example:
|
resource=resource,
|
||||||
from datasette.resources import TableResource
|
|
||||||
results = await datasette.allowed_many(
|
|
||||||
actions=["edit-schema", "drop-table", "insert-row"],
|
|
||||||
resource=TableResource(database="data", table="exercise"),
|
|
||||||
actor=actor,
|
actor=actor,
|
||||||
)
|
):
|
||||||
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
return False
|
||||||
"""
|
|
||||||
from datasette.utils.actions_sql import check_permissions_for_actions
|
|
||||||
from datasette.permissions import (
|
|
||||||
_permission_check_cache,
|
|
||||||
_skip_permission_checks,
|
|
||||||
)
|
|
||||||
|
|
||||||
# For global actions, resource is None
|
# For global actions, resource is None
|
||||||
parent = resource.parent if resource else None
|
parent = resource.parent if resource else None
|
||||||
child = resource.child if resource else None
|
child = resource.child if resource else None
|
||||||
|
|
||||||
# Expand also_requires dependencies (transitively) so that each
|
result = await check_permission_for_resource(
|
||||||
# dependency is resolved within the same batch
|
|
||||||
expanded = []
|
|
||||||
|
|
||||||
def add_action(name):
|
|
||||||
if name in expanded:
|
|
||||||
return
|
|
||||||
action_obj = self.actions.get(name)
|
|
||||||
if action_obj is None:
|
|
||||||
raise ValueError(f"Unknown action: {name}")
|
|
||||||
expanded.append(name)
|
|
||||||
if action_obj.also_requires:
|
|
||||||
add_action(action_obj.also_requires)
|
|
||||||
|
|
||||||
requested = list(dict.fromkeys(actions))
|
|
||||||
for name in requested:
|
|
||||||
add_action(name)
|
|
||||||
|
|
||||||
# Consult the request-scoped cache, unless permission checks are
|
|
||||||
# being skipped (skip-mode verdicts must never be cached)
|
|
||||||
skip = _skip_permission_checks.get()
|
|
||||||
cache = None if skip else _permission_check_cache.get()
|
|
||||||
|
|
||||||
final = {}
|
|
||||||
to_check = []
|
|
||||||
for name in expanded:
|
|
||||||
if cache is not None:
|
|
||||||
key = _permission_cache_key(actor, name, parent, child)
|
|
||||||
if key in cache:
|
|
||||||
final[name] = cache[key]
|
|
||||||
continue
|
|
||||||
to_check.append(name)
|
|
||||||
|
|
||||||
raw = {}
|
|
||||||
if to_check:
|
|
||||||
raw = await check_permissions_for_actions(
|
|
||||||
datasette=self,
|
datasette=self,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
actions=to_check,
|
action=action,
|
||||||
parent=parent,
|
parent=parent,
|
||||||
child=child,
|
child=child,
|
||||||
)
|
)
|
||||||
|
|
||||||
def resolve(name):
|
# Log the permission check for debugging
|
||||||
# final verdict = own rules AND verdict of also_requires chain
|
|
||||||
if name in final:
|
|
||||||
return final[name]
|
|
||||||
result = raw[name]
|
|
||||||
action_obj = self.actions.get(name)
|
|
||||||
if result and action_obj.also_requires:
|
|
||||||
result = resolve(action_obj.also_requires)
|
|
||||||
final[name] = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
for name in expanded:
|
|
||||||
resolve(name)
|
|
||||||
|
|
||||||
# Cache the freshly computed checks
|
|
||||||
if cache is not None:
|
|
||||||
for name in to_check:
|
|
||||||
cache[_permission_cache_key(actor, name, parent, child)] = final[name]
|
|
||||||
|
|
||||||
# Log every check (including cache hits) for the debug page,
|
|
||||||
# dependencies before the actions that required them
|
|
||||||
when = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
||||||
for name in reversed(expanded):
|
|
||||||
self._permission_checks.append(
|
self._permission_checks.append(
|
||||||
PermissionCheck(
|
PermissionCheck(
|
||||||
when=when,
|
when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
actor=actor,
|
actor=actor,
|
||||||
action=name,
|
action=action,
|
||||||
parent=parent,
|
parent=parent,
|
||||||
child=child,
|
child=child,
|
||||||
result=final[name],
|
result=result,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {name: final[name] for name in requested}
|
return result
|
||||||
|
|
||||||
async def ensure_permission(
|
async def ensure_permission(
|
||||||
self,
|
self,
|
||||||
|
|
@ -2123,11 +1756,6 @@ class Datasette:
|
||||||
|
|
||||||
other_table = fk["other_table"]
|
other_table = fk["other_table"]
|
||||||
other_column = fk["other_column"]
|
other_column = fk["other_column"]
|
||||||
if other_column is None:
|
|
||||||
other_pks = await db.primary_keys(other_table)
|
|
||||||
if len(other_pks) != 1:
|
|
||||||
return {}
|
|
||||||
other_column = other_pks[0]
|
|
||||||
visible, _ = await self.check_visibility(
|
visible, _ = await self.check_visibility(
|
||||||
actor,
|
actor,
|
||||||
action="view-table",
|
action="view-table",
|
||||||
|
|
@ -2178,18 +1806,6 @@ class Datasette:
|
||||||
for name, d in self.databases.items()
|
for name, d in self.databases.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
async def _connected_databases_for_actor(self, actor):
|
|
||||||
page = await self.allowed_resources("view-database", actor)
|
|
||||||
allowed_names = {resource.parent async for resource in page.all()}
|
|
||||||
return [
|
|
||||||
database
|
|
||||||
for database in self._connected_databases()
|
|
||||||
if database["name"] in allowed_names
|
|
||||||
]
|
|
||||||
|
|
||||||
async def _databases_data(self, request):
|
|
||||||
return {"databases": await self._connected_databases_for_actor(request.actor)}
|
|
||||||
|
|
||||||
def _versions(self):
|
def _versions(self):
|
||||||
conn = sqlite3.connect(":memory:")
|
conn = sqlite3.connect(":memory:")
|
||||||
self._prepare_connection(conn, "_memory")
|
self._prepare_connection(conn, "_memory")
|
||||||
|
|
@ -2373,11 +1989,7 @@ class Datasette:
|
||||||
templates = [templates]
|
templates = [templates]
|
||||||
template = self.get_jinja_environment(request).select_template(templates)
|
template = self.get_jinja_environment(request).select_template(templates)
|
||||||
if dataclasses.is_dataclass(context):
|
if dataclasses.is_dataclass(context):
|
||||||
# Shallow conversion - asdict() would deep-copy values, which
|
context = dataclasses.asdict(context)
|
||||||
# is wasteful and fails on values like sqlite3.Row
|
|
||||||
context = {
|
|
||||||
f.name: getattr(context, f.name) for f in dataclasses.fields(context)
|
|
||||||
}
|
|
||||||
body_scripts = []
|
body_scripts = []
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
for extra_script in pm.hook.extra_body_script(
|
for extra_script in pm.hook.extra_body_script(
|
||||||
|
|
@ -2427,8 +2039,6 @@ class Datasette:
|
||||||
links.extend(extra_links)
|
links.extend(extra_links)
|
||||||
return links
|
return links
|
||||||
|
|
||||||
# Keys added here must be documented in TEMPLATE_BASE_CONTEXT -
|
|
||||||
# the contract tests fail otherwise
|
|
||||||
template_context = {
|
template_context = {
|
||||||
**context,
|
**context,
|
||||||
**{
|
**{
|
||||||
|
|
@ -2441,6 +2051,7 @@ class Datasette:
|
||||||
"show_logout": request is not None
|
"show_logout": request is not None
|
||||||
and "ds_actor" in request.cookies
|
and "ds_actor" in request.cookies
|
||||||
and request.actor,
|
and request.actor,
|
||||||
|
"app_css_hash": self.app_css_hash(),
|
||||||
"zip": zip,
|
"zip": zip,
|
||||||
"body_scripts": body_scripts,
|
"body_scripts": body_scripts,
|
||||||
"format_bytes": format_bytes,
|
"format_bytes": format_bytes,
|
||||||
|
|
@ -2452,19 +2063,18 @@ class Datasette:
|
||||||
"extra_js_urls", template, context, request, view_name
|
"extra_js_urls", template, context, request, view_name
|
||||||
),
|
),
|
||||||
"base_url": self.setting("base_url"),
|
"base_url": self.setting("base_url"),
|
||||||
|
"csrftoken": (
|
||||||
|
request.scope["csrftoken"]
|
||||||
|
if request and "csrftoken" in request.scope
|
||||||
|
else lambda: ""
|
||||||
|
),
|
||||||
"datasette_version": __version__,
|
"datasette_version": __version__,
|
||||||
},
|
},
|
||||||
**extra_template_vars,
|
**extra_template_vars,
|
||||||
}
|
}
|
||||||
if request and request.args.get("_context") and self.setting("template_debug"):
|
if request and request.args.get("_context") and self.setting("template_debug"):
|
||||||
return "<pre>{}</pre>".format(
|
return "<pre>{}</pre>".format(
|
||||||
escape(
|
escape(json.dumps(template_context, default=repr, indent=4))
|
||||||
json.dumps(
|
|
||||||
template_context,
|
|
||||||
default=_template_context_json_default,
|
|
||||||
indent=4,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return await template.render_async(template_context)
|
return await template.render_async(template_context)
|
||||||
|
|
@ -2536,9 +2146,10 @@ class Datasette:
|
||||||
def add_route(view, regex):
|
def add_route(view, regex):
|
||||||
routes.append((regex, view))
|
routes.append((regex, view))
|
||||||
|
|
||||||
add_route(IndexView.as_view(self), r"/(\.(?P<format>json))?$")
|
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
|
||||||
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>json))?$")
|
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
|
||||||
add_route(permanent_redirect("/-/"), r"/-$")
|
add_route(permanent_redirect("/-/"), r"/-$")
|
||||||
|
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
|
||||||
add_route(favicon, "/favicon.ico")
|
add_route(favicon, "/favicon.ico")
|
||||||
|
|
||||||
add_route(
|
add_route(
|
||||||
|
|
@ -2573,10 +2184,7 @@ class Datasette:
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
JsonDataView.as_view(
|
JsonDataView.as_view(
|
||||||
self,
|
self, "plugins.json", self._plugins, needs_request=True
|
||||||
"plugins.json",
|
|
||||||
lambda request: {"plugins": self._plugins(request)},
|
|
||||||
needs_request=True,
|
|
||||||
),
|
),
|
||||||
r"/-/plugins(\.(?P<format>json))?$",
|
r"/-/plugins(\.(?P<format>json))?$",
|
||||||
)
|
)
|
||||||
|
|
@ -2589,18 +2197,11 @@ class Datasette:
|
||||||
r"/-/config(\.(?P<format>json))?$",
|
r"/-/config(\.(?P<format>json))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
JsonDataView.as_view(
|
JsonDataView.as_view(self, "threads.json", self._threads),
|
||||||
self, "threads.json", self._threads, permission="permissions-debug"
|
|
||||||
),
|
|
||||||
r"/-/threads(\.(?P<format>json))?$",
|
r"/-/threads(\.(?P<format>json))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
JsonDataView.as_view(
|
JsonDataView.as_view(self, "databases.json", self._connected_databases),
|
||||||
self,
|
|
||||||
"databases.json",
|
|
||||||
self._databases_data,
|
|
||||||
needs_request=True,
|
|
||||||
),
|
|
||||||
r"/-/databases(\.(?P<format>json))?$",
|
r"/-/databases(\.(?P<format>json))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
|
|
@ -2613,7 +2214,7 @@ class Datasette:
|
||||||
JsonDataView.as_view(
|
JsonDataView.as_view(
|
||||||
self,
|
self,
|
||||||
"actions.json",
|
"actions.json",
|
||||||
lambda: {"actions": self._actions()},
|
self._actions,
|
||||||
template="debug_actions.html",
|
template="debug_actions.html",
|
||||||
permission="permissions-debug",
|
permission="permissions-debug",
|
||||||
),
|
),
|
||||||
|
|
@ -2635,10 +2236,6 @@ class Datasette:
|
||||||
JumpView.as_view(self),
|
JumpView.as_view(self),
|
||||||
r"/-/jump(\.(?P<format>json))?$",
|
r"/-/jump(\.(?P<format>json))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
GlobalQueryListView.as_view(self),
|
|
||||||
r"/-/queries(\.(?P<format>json))?$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
InstanceSchemaView.as_view(self),
|
InstanceSchemaView.as_view(self),
|
||||||
r"/-/schema(\.(?P<format>json|md))?$",
|
r"/-/schema(\.(?P<format>json|md))?$",
|
||||||
|
|
@ -2675,10 +2272,6 @@ class Datasette:
|
||||||
wrap_view(PatternPortfolioView, self),
|
wrap_view(PatternPortfolioView, self),
|
||||||
r"/-/patterns$",
|
r"/-/patterns$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
AutocompleteDebugView.as_view(self),
|
|
||||||
r"/-/debug/autocomplete$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(database_download, self),
|
wrap_view(database_download, self),
|
||||||
r"/(?P<database>[^\/\.]+)\.db$",
|
r"/(?P<database>[^\/\.]+)\.db$",
|
||||||
|
|
@ -2688,58 +2281,14 @@ class Datasette:
|
||||||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||||
)
|
)
|
||||||
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
||||||
add_route(
|
|
||||||
DatabaseForeignKeyTargetsView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/foreign-key-targets$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryListView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryCreateAnalyzeView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/queries/analyze$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryStoreView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/queries/store$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
ExecuteWriteAnalyzeView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/execute-write/analyze$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
ExecuteWriteView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/execute-write$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
DatabaseSchemaView.as_view(self),
|
DatabaseSchemaView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
QueryParametersView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(QueryView, self),
|
wrap_view(QueryView, self),
|
||||||
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
QueryDefinitionView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryEditView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/edit$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryUpdateView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryDeleteView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(table_view, self),
|
wrap_view(table_view, self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||||
|
|
@ -2756,26 +2305,10 @@ class Datasette:
|
||||||
TableUpsertView.as_view(self),
|
TableUpsertView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
TableAlterView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/alter$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
TableForeignKeySuggestionsView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/foreign-key-suggestions$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
TableSetColumnTypeView.as_view(self),
|
TableSetColumnTypeView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
TableFragmentView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
TableAutocompleteView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/autocomplete$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
TableDropView.as_view(self),
|
TableDropView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
|
||||||
|
|
@ -2821,10 +2354,6 @@ class Datasette:
|
||||||
db, table_name, _ = await self.resolve_table(request)
|
db, table_name, _ = await self.resolve_table(request)
|
||||||
pk_values = urlsafe_components(request.url_vars["pks"])
|
pk_values = urlsafe_components(request.url_vars["pks"])
|
||||||
sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)
|
sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)
|
||||||
if len(pk_values) != len(pks):
|
|
||||||
raise BadRequest(
|
|
||||||
"URL row identifier does not match the primary key for this table"
|
|
||||||
)
|
|
||||||
results = await db.execute(sql, params, truncate=True)
|
results = await db.execute(sql, params, truncate=True)
|
||||||
row = results.first()
|
row = results.first()
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
@ -2866,16 +2395,7 @@ class DatasetteRouter:
|
||||||
if raw_path:
|
if raw_path:
|
||||||
path = raw_path.decode("ascii")
|
path = raw_path.decode("ascii")
|
||||||
path = path.partition("?")[0]
|
path = path.partition("?")[0]
|
||||||
# Give each request a fresh permission check cache, so repeated
|
|
||||||
# datasette.allowed() checks within the request are memoized but
|
|
||||||
# results never persist beyond it
|
|
||||||
from datasette.permissions import _permission_check_cache
|
|
||||||
|
|
||||||
cache_token = _permission_check_cache.set({})
|
|
||||||
try:
|
|
||||||
return await self.route_path(scope, receive, send, path)
|
return await self.route_path(scope, receive, send, path)
|
||||||
finally:
|
|
||||||
_permission_check_cache.reset(cache_token)
|
|
||||||
|
|
||||||
async def route_path(self, scope, receive, send, path):
|
async def route_path(self, scope, receive, send, path):
|
||||||
# Strip off base_url if present before routing
|
# Strip off base_url if present before routing
|
||||||
|
|
@ -2883,11 +2403,7 @@ class DatasetteRouter:
|
||||||
if base_url != "/" and path.startswith(base_url):
|
if base_url != "/" and path.startswith(base_url):
|
||||||
path = "/" + path[len(base_url) :]
|
path = "/" + path[len(base_url) :]
|
||||||
scope = dict(scope, route_path=path)
|
scope = dict(scope, route_path=path)
|
||||||
request = Request(
|
request = Request(scope, receive)
|
||||||
scope,
|
|
||||||
receive,
|
|
||||||
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
|
|
||||||
)
|
|
||||||
# Populate request_messages if ds_messages cookie is present
|
# Populate request_messages if ds_messages cookie is present
|
||||||
try:
|
try:
|
||||||
request._messages = self.ds.unsign(
|
request._messages = self.ds.unsign(
|
||||||
|
|
@ -2907,24 +2423,13 @@ class DatasetteRouter:
|
||||||
# Handle authentication
|
# Handle authentication
|
||||||
default_actor = scope.get("actor") or None
|
default_actor = scope.get("actor") or None
|
||||||
actor = None
|
actor = None
|
||||||
token_error = None
|
|
||||||
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
|
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
|
||||||
for result in results:
|
for result in results:
|
||||||
try:
|
|
||||||
result = await await_me_maybe(result)
|
result = await await_me_maybe(result)
|
||||||
except TokenInvalid as ex:
|
|
||||||
# A presented token was recognized but rejected - fail the
|
|
||||||
# request with a 401 even if another credential is valid,
|
|
||||||
# but keep awaiting the remaining coroutines first
|
|
||||||
if token_error is None:
|
|
||||||
token_error = ex
|
|
||||||
continue
|
|
||||||
if result and actor is None:
|
if result and actor is None:
|
||||||
actor = result
|
actor = result
|
||||||
# Don't break — we must await all coroutines to avoid
|
# Don't break — we must await all coroutines to avoid
|
||||||
# "coroutine was never awaited" warnings
|
# "coroutine was never awaited" warnings
|
||||||
if token_error is not None:
|
|
||||||
return await self.handle_401(request, send, token_error)
|
|
||||||
scope_modifications["actor"] = actor or default_actor
|
scope_modifications["actor"] = actor or default_actor
|
||||||
scope = dict(scope, **scope_modifications)
|
scope = dict(scope, **scope_modifications)
|
||||||
|
|
||||||
|
|
@ -2956,15 +2461,6 @@ class DatasetteRouter:
|
||||||
except Exception as exception:
|
except Exception as exception:
|
||||||
return await self.handle_exception(request, send, exception)
|
return await self.handle_exception(request, send, exception)
|
||||||
|
|
||||||
async def handle_401(self, request, send, exception):
|
|
||||||
# A presented bearer token was recognized by a handler but rejected.
|
|
||||||
# Bearer tokens are API credentials, so this is always JSON.
|
|
||||||
headers = {"www-authenticate": 'Bearer error="invalid_token"'}
|
|
||||||
if self.ds.cors:
|
|
||||||
add_cors_headers(headers)
|
|
||||||
response = Response.error([str(exception)], 401, headers=headers)
|
|
||||||
await response.asgi_send(send)
|
|
||||||
|
|
||||||
async def handle_404(self, request, send, exception=None):
|
async def handle_404(self, request, send, exception=None):
|
||||||
# If path contains % encoding, redirect to tilde encoding
|
# If path contains % encoding, redirect to tilde encoding
|
||||||
if "%" in request.path:
|
if "%" in request.path:
|
||||||
|
|
@ -3162,22 +2658,19 @@ def wrap_view_function(view_fn, datasette):
|
||||||
|
|
||||||
|
|
||||||
def permanent_redirect(path, forward_query_string=False, forward_rest=False):
|
def permanent_redirect(path, forward_query_string=False, forward_rest=False):
|
||||||
def view(request, send):
|
return wrap_view(
|
||||||
redirect_path = (
|
lambda request, send: Response.redirect(
|
||||||
path
|
path
|
||||||
+ (request.url_vars["rest"] if forward_rest else "")
|
+ (request.url_vars["rest"] if forward_rest else "")
|
||||||
+ (
|
+ (
|
||||||
("?" + request.query_string)
|
("?" + request.query_string)
|
||||||
if forward_query_string and request.query_string
|
if forward_query_string and request.query_string
|
||||||
else ""
|
else ""
|
||||||
|
),
|
||||||
|
status=301,
|
||||||
|
),
|
||||||
|
datasette=None,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
route_path = request.scope.get("route_path")
|
|
||||||
if route_path and request.path.endswith(route_path):
|
|
||||||
redirect_path = request.path[: -len(route_path)] + redirect_path
|
|
||||||
return Response.redirect(redirect_path, status=301)
|
|
||||||
|
|
||||||
return wrap_view(view, datasette=None)
|
|
||||||
|
|
||||||
|
|
||||||
_curly_re = re.compile(r"({.*?})")
|
_curly_re = re.compile(r"({.*?})")
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ from .app import (
|
||||||
SQLITE_LIMIT_ATTACHED,
|
SQLITE_LIMIT_ATTACHED,
|
||||||
pm,
|
pm,
|
||||||
)
|
)
|
||||||
from .inspect import inspect_tables
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
LoadExtension,
|
LoadExtension,
|
||||||
StartupError,
|
StartupError,
|
||||||
|
|
@ -155,14 +154,14 @@ async def inspect_(files, sqlite_extensions):
|
||||||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||||
data = {}
|
data = {}
|
||||||
for name, database in app.databases.items():
|
for name, database in app.databases.items():
|
||||||
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
|
counts = await database.table_counts(limit=3600 * 1000)
|
||||||
data[name] = {
|
data[name] = {
|
||||||
"hash": database.hash,
|
"hash": database.hash,
|
||||||
"size": database.size,
|
"size": database.size,
|
||||||
"file": database.path,
|
"file": database.path,
|
||||||
"tables": {
|
"tables": {
|
||||||
table_name: {"count": table["count"]}
|
table_name: {"count": table_count}
|
||||||
for table_name, table in tables.items()
|
for table_name, table_count in counts.items()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,19 @@ class SQLiteType(Enum):
|
||||||
INTEGER = "INTEGER"
|
INTEGER = "INTEGER"
|
||||||
REAL = "REAL"
|
REAL = "REAL"
|
||||||
BLOB = "BLOB"
|
BLOB = "BLOB"
|
||||||
NUMERIC = "NUMERIC"
|
NULL = "NULL"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType":
|
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
|
||||||
if declared_type is None:
|
if declared_type is None:
|
||||||
return cls.BLOB
|
return cls.NULL
|
||||||
|
|
||||||
normalized = declared_type.strip().upper()
|
normalized = declared_type.strip().upper()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return cls.BLOB
|
return cls.NULL
|
||||||
|
|
||||||
|
if normalized == cls.NULL.value:
|
||||||
|
return cls.NULL
|
||||||
if "INT" in normalized:
|
if "INT" in normalized:
|
||||||
return cls.INTEGER
|
return cls.INTEGER
|
||||||
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
||||||
|
|
@ -29,7 +31,7 @@ class SQLiteType(Enum):
|
||||||
):
|
):
|
||||||
return cls.REAL
|
return cls.REAL
|
||||||
|
|
||||||
return cls.NUMERIC
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ColumnType:
|
class ColumnType:
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,11 @@ from .utils import (
|
||||||
table_columns,
|
table_columns,
|
||||||
table_column_details,
|
table_column_details,
|
||||||
)
|
)
|
||||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
from .utils.sqlite import sqlite_version
|
||||||
from .utils.sqlite import sqlite_hidden_table_names
|
|
||||||
from .inspect import inspect_hash
|
from .inspect import inspect_hash
|
||||||
|
|
||||||
connections = threading.local()
|
connections = threading.local()
|
||||||
|
|
||||||
EXECUTE_WRITE_RETURNING_LIMIT = 10
|
|
||||||
|
|
||||||
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
|
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -238,24 +235,11 @@ class Database:
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def execute_write(
|
async def execute_write(self, sql, params=None, block=True, request=None):
|
||||||
self,
|
|
||||||
sql,
|
|
||||||
params=None,
|
|
||||||
block=True,
|
|
||||||
request=None,
|
|
||||||
return_all=False,
|
|
||||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
|
||||||
):
|
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
if returning_limit < 0:
|
|
||||||
raise ValueError("returning_limit must be >= 0")
|
|
||||||
|
|
||||||
def _inner(conn):
|
def _inner(conn):
|
||||||
cursor = conn.execute(sql, params or [])
|
return conn.execute(sql, params or [])
|
||||||
return ExecuteWriteResult.from_cursor(
|
|
||||||
cursor, return_all=return_all, returning_limit=returning_limit
|
|
||||||
)
|
|
||||||
|
|
||||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||||
|
|
@ -298,14 +282,13 @@ class Database:
|
||||||
|
|
||||||
async def execute_isolated_fn(self, fn):
|
async def execute_isolated_fn(self, fn):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
# Open a new connection just for the duration of this function,
|
# Open a new connection just for the duration of this function
|
||||||
# blocking the write queue to avoid any writes occurring during it
|
# blocking the write queue to avoid any writes occurring during it
|
||||||
write = self.is_mutable
|
if self.ds.executor is None:
|
||||||
|
# non-threaded mode
|
||||||
def _run():
|
isolated_connection = self.connect(write=True)
|
||||||
isolated_connection = self.connect(write=write)
|
|
||||||
try:
|
try:
|
||||||
return fn(isolated_connection)
|
result = fn(isolated_connection)
|
||||||
finally:
|
finally:
|
||||||
isolated_connection.close()
|
isolated_connection.close()
|
||||||
try:
|
try:
|
||||||
|
|
@ -313,26 +296,11 @@ class Database:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Was probably a memory connection
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
|
return result
|
||||||
if self.ds.executor is None:
|
else:
|
||||||
# non-threaded mode
|
|
||||||
return _run()
|
|
||||||
if not write:
|
|
||||||
# Immutable database - no writes can ever occur, so there is no
|
|
||||||
# write queue to block; run against a fresh read-only connection
|
|
||||||
return await asyncio.get_running_loop().run_in_executor(
|
|
||||||
self.ds.executor, _run
|
|
||||||
)
|
|
||||||
# Threaded mode - send to write thread
|
# Threaded mode - send to write thread
|
||||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||||
|
|
||||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
|
||||||
self._check_not_closed()
|
|
||||||
|
|
||||||
return await self.execute_isolated_fn(
|
|
||||||
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
pending_events = []
|
pending_events = []
|
||||||
|
|
@ -458,10 +426,13 @@ class Database:
|
||||||
if conn_exception is not None:
|
if conn_exception is not None:
|
||||||
exception = conn_exception
|
exception = conn_exception
|
||||||
elif task.isolated_connection:
|
elif task.isolated_connection:
|
||||||
try:
|
|
||||||
isolated_connection = self.connect(write=True)
|
isolated_connection = self.connect(write=True)
|
||||||
try:
|
try:
|
||||||
result = task.fn(isolated_connection)
|
result = task.fn(isolated_connection)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("{}\n".format(e))
|
||||||
|
sys.stderr.flush()
|
||||||
|
exception = e
|
||||||
finally:
|
finally:
|
||||||
isolated_connection.close()
|
isolated_connection.close()
|
||||||
try:
|
try:
|
||||||
|
|
@ -469,10 +440,6 @@ class Database:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Was probably a memory connection
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
|
||||||
sys.stderr.write("{}\n".format(e))
|
|
||||||
sys.stderr.flush()
|
|
||||||
exception = e
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
if task.transaction:
|
if task.transaction:
|
||||||
|
|
@ -727,7 +694,83 @@ class Database:
|
||||||
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
||||||
]
|
]
|
||||||
|
|
||||||
hidden_tables += await self.execute_fn(sqlite_hidden_table_names)
|
if sqlite_version()[1] >= 37:
|
||||||
|
hidden_tables += [x[0] for x in await self.execute("""
|
||||||
|
with shadow_tables as (
|
||||||
|
select name
|
||||||
|
from pragma_table_list
|
||||||
|
where [type] = 'shadow'
|
||||||
|
order by name
|
||||||
|
),
|
||||||
|
core_tables as (
|
||||||
|
select name
|
||||||
|
from sqlite_master
|
||||||
|
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||||
|
OR substr(name, 1, 1) == '_'
|
||||||
|
),
|
||||||
|
combined as (
|
||||||
|
select name from shadow_tables
|
||||||
|
union all
|
||||||
|
select name from core_tables
|
||||||
|
)
|
||||||
|
select name from combined order by 1
|
||||||
|
""")]
|
||||||
|
else:
|
||||||
|
hidden_tables += [x[0] for x in await self.execute("""
|
||||||
|
WITH base AS (
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||||
|
OR substr(name, 1, 1) == '_'
|
||||||
|
),
|
||||||
|
fts_suffixes AS (
|
||||||
|
SELECT column1 AS suffix
|
||||||
|
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
|
||||||
|
),
|
||||||
|
fts5_names AS (
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
||||||
|
),
|
||||||
|
fts5_shadow_tables AS (
|
||||||
|
SELECT
|
||||||
|
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
|
||||||
|
FROM fts5_names
|
||||||
|
JOIN fts_suffixes
|
||||||
|
),
|
||||||
|
fts3_suffixes AS (
|
||||||
|
SELECT column1 AS suffix
|
||||||
|
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
|
||||||
|
),
|
||||||
|
fts3_names AS (
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
|
||||||
|
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
|
||||||
|
),
|
||||||
|
fts3_shadow_tables AS (
|
||||||
|
SELECT
|
||||||
|
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
|
||||||
|
FROM fts3_names
|
||||||
|
JOIN fts3_suffixes
|
||||||
|
),
|
||||||
|
final AS (
|
||||||
|
SELECT name FROM base
|
||||||
|
UNION ALL
|
||||||
|
SELECT name FROM fts5_shadow_tables
|
||||||
|
UNION ALL
|
||||||
|
SELECT name FROM fts3_shadow_tables
|
||||||
|
)
|
||||||
|
SELECT name FROM final ORDER BY 1
|
||||||
|
""")]
|
||||||
|
# Also hide any FTS tables that have a content= argument
|
||||||
|
hidden_tables += [x[0] for x in await self.execute("""
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE sql LIKE '%VIRTUAL TABLE%'
|
||||||
|
AND sql LIKE '%USING FTS%'
|
||||||
|
AND sql LIKE '%content=%'
|
||||||
|
""")]
|
||||||
|
|
||||||
has_spatialite = await self.execute_fn(detect_spatialite)
|
has_spatialite = await self.execute_fn(detect_spatialite)
|
||||||
if has_spatialite:
|
if has_spatialite:
|
||||||
|
|
@ -829,10 +872,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||||
# Execute the actual write
|
# Execute the actual write
|
||||||
try:
|
try:
|
||||||
result = fn(conn)
|
result = fn(conn)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# Throw exception into generator so it can handle it
|
# Throw exception into generator so it can handle it
|
||||||
try:
|
try:
|
||||||
gen.throw(e)
|
gen.throw(*sys.exc_info())
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
pass
|
pass
|
||||||
# Re-raise the original exception
|
# Re-raise the original exception
|
||||||
|
|
@ -902,44 +945,6 @@ class MultipleValues(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ExecuteWriteResult:
|
|
||||||
def __init__(self, rowcount, lastrowid, description, rows, truncated):
|
|
||||||
self.rowcount = rowcount
|
|
||||||
self.lastrowid = lastrowid
|
|
||||||
self.description = description
|
|
||||||
self.truncated = truncated
|
|
||||||
self._rows = rows
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_cursor(
|
|
||||||
cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT
|
|
||||||
):
|
|
||||||
rows = []
|
|
||||||
truncated = False
|
|
||||||
description = cursor.description
|
|
||||||
lastrowid = cursor.lastrowid
|
|
||||||
try:
|
|
||||||
if description is not None:
|
|
||||||
if return_all:
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
else:
|
|
||||||
rows = cursor.fetchmany(returning_limit + 1)
|
|
||||||
if len(rows) > returning_limit:
|
|
||||||
rows = rows[:returning_limit]
|
|
||||||
truncated = True
|
|
||||||
rowcount = cursor.rowcount
|
|
||||||
finally:
|
|
||||||
cursor.close()
|
|
||||||
if description is not None and not return_all and truncated:
|
|
||||||
rowcount = -1
|
|
||||||
return cls(rowcount, lastrowid, description, rows, truncated)
|
|
||||||
|
|
||||||
def fetchall(self):
|
|
||||||
rows = self._rows
|
|
||||||
self._rows = []
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
class Results:
|
class Results:
|
||||||
def __init__(self, rows, truncated, description):
|
def __init__(self, rows, truncated, description):
|
||||||
self.rows = rows
|
self.rows = rows
|
||||||
|
|
|
||||||
|
|
@ -48,32 +48,12 @@ def register_actions():
|
||||||
resource_class=DatabaseResource,
|
resource_class=DatabaseResource,
|
||||||
also_requires="view-database",
|
also_requires="view-database",
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="execute-write-sql",
|
|
||||||
abbr="ews",
|
|
||||||
description="Execute writable SQL queries",
|
|
||||||
resource_class=DatabaseResource,
|
|
||||||
also_requires="view-database",
|
|
||||||
),
|
|
||||||
Action(
|
Action(
|
||||||
name="create-table",
|
name="create-table",
|
||||||
abbr="ct",
|
abbr="ct",
|
||||||
description="Create tables",
|
description="Create tables",
|
||||||
resource_class=DatabaseResource,
|
resource_class=DatabaseResource,
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="create-view",
|
|
||||||
abbr="cv",
|
|
||||||
description="Create views",
|
|
||||||
resource_class=DatabaseResource,
|
|
||||||
),
|
|
||||||
Action(
|
|
||||||
name="store-query",
|
|
||||||
abbr="sq",
|
|
||||||
description="Create stored queries",
|
|
||||||
resource_class=DatabaseResource,
|
|
||||||
also_requires="execute-sql",
|
|
||||||
),
|
|
||||||
# Table-level actions (child-level)
|
# Table-level actions (child-level)
|
||||||
Action(
|
Action(
|
||||||
name="view-table",
|
name="view-table",
|
||||||
|
|
@ -117,12 +97,6 @@ def register_actions():
|
||||||
description="Drop tables",
|
description="Drop tables",
|
||||||
resource_class=TableResource,
|
resource_class=TableResource,
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="drop-view",
|
|
||||||
abbr="dv",
|
|
||||||
description="Drop views",
|
|
||||||
resource_class=TableResource,
|
|
||||||
),
|
|
||||||
# Query-level actions (child-level)
|
# Query-level actions (child-level)
|
||||||
Action(
|
Action(
|
||||||
name="view-query",
|
name="view-query",
|
||||||
|
|
@ -130,16 +104,4 @@ def register_actions():
|
||||||
description="View named query results",
|
description="View named query results",
|
||||||
resource_class=QueryResource,
|
resource_class=QueryResource,
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="update-query",
|
|
||||||
abbr="uq",
|
|
||||||
description="Update stored queries",
|
|
||||||
resource_class=QueryResource,
|
|
||||||
),
|
|
||||||
Action(
|
|
||||||
name="delete-query",
|
|
||||||
abbr="dq",
|
|
||||||
description="Delete stored queries",
|
|
||||||
resource_class=QueryResource,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,6 @@ class JsonColumnType(ColumnType):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TextareaColumnType(ColumnType):
|
|
||||||
name = "textarea"
|
|
||||||
description = "Multiline text"
|
|
||||||
sqlite_types = (SQLiteType.TEXT,)
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def register_column_types(datasette):
|
def register_column_types(datasette):
|
||||||
return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType]
|
return [UrlColumnType, EmailColumnType, JsonColumnType]
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.resources import DatabaseResource
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def database_actions(datasette, actor, database, request):
|
|
||||||
async def inner():
|
|
||||||
if not datasette.get_database(database).is_mutable:
|
|
||||||
return []
|
|
||||||
if not await datasette.allowed(
|
|
||||||
action="execute-write-sql",
|
|
||||||
resource=DatabaseResource(database),
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"href": datasette.urls.database(database) + "/-/execute-write",
|
|
||||||
"label": "Execute write SQL",
|
|
||||||
"description": "Run writable SQL with table permission checks.",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
@ -37,11 +37,6 @@ DEBUG_MENU_ITEMS = (
|
||||||
"Debug allow rules",
|
"Debug allow rules",
|
||||||
"Explore how allow blocks match actors against permission rules.",
|
"Explore how allow blocks match actors against permission rules.",
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"/-/debug/autocomplete",
|
|
||||||
"Debug autocomplete",
|
|
||||||
"Try out table autocomplete against a detected label column.",
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"/-/threads",
|
"/-/threads",
|
||||||
"Debug threads",
|
"Debug threads",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ UNION/INTERSECT operations. The order of evaluation is:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datasette.app import Datasette
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
# Re-export all hooks and public utilities
|
# Re-export all hooks and public utilities
|
||||||
from .restrictions import (
|
from .restrictions import (
|
||||||
actor_restrictions_sql as actor_restrictions_sql,
|
actor_restrictions_sql as actor_restrictions_sql,
|
||||||
|
|
@ -26,9 +33,16 @@ from .restrictions import (
|
||||||
from .root import root_user_permissions_sql as root_user_permissions_sql
|
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||||
from .config import config_permissions_sql as config_permissions_sql
|
from .config import config_permissions_sql as config_permissions_sql
|
||||||
from .defaults import (
|
from .defaults import (
|
||||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
|
||||||
default_allow_sql_check as default_allow_sql_check,
|
default_allow_sql_check as default_allow_sql_check,
|
||||||
default_action_permissions_sql as default_action_permissions_sql,
|
default_action_permissions_sql as default_action_permissions_sql,
|
||||||
default_query_permissions_sql as default_query_permissions_sql,
|
|
||||||
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
|
||||||
|
"""Return canned queries defined in datasette.yaml configuration."""
|
||||||
|
queries = (
|
||||||
|
((datasette.config or {}).get("databases") or {}).get(database) or {}
|
||||||
|
).get("queries") or {}
|
||||||
|
return queries
|
||||||
|
|
|
||||||
|
|
@ -67,48 +67,3 @@ async def default_action_permissions_sql(
|
||||||
return PermissionSQL.allow(reason=reason)
|
return PermissionSQL.allow(reason=reason)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
|
||||||
async def default_query_permissions_sql(
|
|
||||||
datasette: "Datasette",
|
|
||||||
actor: Optional[dict],
|
|
||||||
action: str,
|
|
||||||
) -> Optional[PermissionSQL]:
|
|
||||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
|
||||||
|
|
||||||
if action not in {"view-query", "update-query", "delete-query"}:
|
|
||||||
return None
|
|
||||||
|
|
||||||
params = {"query_owner_id": actor_id}
|
|
||||||
rule_sqls = []
|
|
||||||
if actor_id is not None:
|
|
||||||
if action in {"update-query", "delete-query"}:
|
|
||||||
# Query owner can update/delete query
|
|
||||||
rule_sqls.append("""
|
|
||||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
|
||||||
'query owner' AS reason
|
|
||||||
FROM queries
|
|
||||||
WHERE source = 'user'
|
|
||||||
AND owner_id = :query_owner_id
|
|
||||||
""")
|
|
||||||
else:
|
|
||||||
# Query owner can view-query
|
|
||||||
rule_sqls.append("""
|
|
||||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
|
||||||
'query owner' AS reason
|
|
||||||
FROM queries
|
|
||||||
WHERE owner_id = :query_owner_id
|
|
||||||
""")
|
|
||||||
|
|
||||||
# restriction_sql enforces private queries ONLY visible/mutable by owner
|
|
||||||
return PermissionSQL(
|
|
||||||
sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None,
|
|
||||||
restriction_sql="""
|
|
||||||
SELECT database_name AS parent, name AS child
|
|
||||||
FROM queries
|
|
||||||
WHERE is_private = 0
|
|
||||||
OR owner_id = :query_owner_id
|
|
||||||
""",
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.resources import QueryResource
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def query_actions(datasette, actor, database, query_name, request):
|
|
||||||
# Only stored queries (with a name) can be edited or deleted
|
|
||||||
if not query_name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def inner():
|
|
||||||
query = await datasette.get_query(database, query_name)
|
|
||||||
if query is None:
|
|
||||||
return []
|
|
||||||
# Config-defined and trusted queries are managed outside the UI
|
|
||||||
if query.source == "config" or query.is_trusted:
|
|
||||||
return []
|
|
||||||
|
|
||||||
links = []
|
|
||||||
if await datasette.allowed(
|
|
||||||
action="update-query",
|
|
||||||
resource=QueryResource(database, query_name),
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
links.append(
|
|
||||||
{
|
|
||||||
"href": datasette.urls.table(database, query_name) + "/-/edit",
|
|
||||||
"label": "Edit this query",
|
|
||||||
"description": (
|
|
||||||
"Change the title, description, SQL or visibility."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if await datasette.allowed(
|
|
||||||
action="delete-query",
|
|
||||||
resource=QueryResource(database, query_name),
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
links.append(
|
|
||||||
{
|
|
||||||
"href": datasette.urls.table(database, query_name) + "/-/delete",
|
|
||||||
"label": "Delete this query",
|
|
||||||
"description": "Permanently remove this saved query.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return links
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.resources import TableResource
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def table_actions(datasette, actor, database, table, request):
|
|
||||||
async def inner():
|
|
||||||
db = datasette.get_database(database)
|
|
||||||
if not db.is_mutable:
|
|
||||||
return []
|
|
||||||
if not await datasette.allowed(
|
|
||||||
action="alter-table",
|
|
||||||
resource=TableResource(database=database, table=table),
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"type": "button",
|
|
||||||
"label": "Alter table",
|
|
||||||
"description": "Change columns and primary key for this table.",
|
|
||||||
"attrs": {
|
|
||||||
"aria-label": "Alter table {}".format(table),
|
|
||||||
"data-table-action": "alter-table",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from asyncinject import Registry
|
|
||||||
|
|
||||||
from datasette.utils.asgi import BadRequest
|
|
||||||
|
|
||||||
|
|
||||||
def extra_names_from_request(request):
|
|
||||||
extra_bits = request.args.getlist("_extra")
|
|
||||||
extras = set()
|
|
||||||
for bit in extra_bits:
|
|
||||||
extras.update(part for part in bit.split(",") if part)
|
|
||||||
return extras
|
|
||||||
|
|
||||||
|
|
||||||
class ExtraScope(Enum):
|
|
||||||
TABLE = "table"
|
|
||||||
ROW = "row"
|
|
||||||
QUERY = "query"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ExtraExample:
|
|
||||||
path: str | None = None
|
|
||||||
key: str | None = None
|
|
||||||
value: object | None = None
|
|
||||||
note: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Provider:
|
|
||||||
name: ClassVar[str | None] = None
|
|
||||||
scopes: ClassVar[set[ExtraScope]] = set()
|
|
||||||
public: ClassVar[bool] = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def key(cls):
|
|
||||||
return cls.name or _camel_to_snake(cls.__name__)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def available_for(cls, scope):
|
|
||||||
return scope in cls.scopes
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class Extra(Provider):
|
|
||||||
description: ClassVar[str | None] = None
|
|
||||||
example: ClassVar[ExtraExample | None] = None
|
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {}
|
|
||||||
public: ClassVar[bool] = True
|
|
||||||
expensive: ClassVar[bool] = False
|
|
||||||
docs_note: ClassVar[str | None] = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def example_for_scope(cls, scope):
|
|
||||||
return cls.examples.get(scope, cls.example)
|
|
||||||
|
|
||||||
|
|
||||||
class ExtraRegistry:
|
|
||||||
def __init__(self, classes):
|
|
||||||
self.classes = list(classes)
|
|
||||||
self.classes_by_name = {cls.key(): cls for cls in self.classes}
|
|
||||||
# Lazily-built shared state, keyed by scope. Safe to share across
|
|
||||||
# requests because Extra instances are stateless and asyncinject's
|
|
||||||
# Registry keeps per-call state local to each resolve_multi() call.
|
|
||||||
# If extras classes ever become registerable at runtime (e.g. via a
|
|
||||||
# plugin hook) these caches will need invalidating.
|
|
||||||
self._scope_registries = {}
|
|
||||||
self._allowed_names = {}
|
|
||||||
|
|
||||||
def classes_for_scope(self, scope, include_internal=True):
|
|
||||||
classes = [
|
|
||||||
cls
|
|
||||||
for cls in self.classes
|
|
||||||
if cls.available_for(scope) and (include_internal or cls.public)
|
|
||||||
]
|
|
||||||
return classes
|
|
||||||
|
|
||||||
def public_classes_for_scope(self, scope):
|
|
||||||
return self.classes_for_scope(scope, include_internal=False)
|
|
||||||
|
|
||||||
def internal_classes_for_scope(self, scope):
|
|
||||||
# Extras that are available to HTML templates but excluded from
|
|
||||||
# JSON responses - plain Providers are dependency plumbing and
|
|
||||||
# never surface as keys, so they are not included
|
|
||||||
return [
|
|
||||||
cls
|
|
||||||
for cls in self.classes_for_scope(scope)
|
|
||||||
if issubclass(cls, Extra) and not cls.public
|
|
||||||
]
|
|
||||||
|
|
||||||
def _registry_for_scope(self, scope):
|
|
||||||
registry = self._scope_registries.get(scope)
|
|
||||||
if registry is None:
|
|
||||||
registry = Registry()
|
|
||||||
for cls in self.classes_for_scope(scope):
|
|
||||||
registry.register(cls().resolve, name=cls.key())
|
|
||||||
self._scope_registries[scope] = registry
|
|
||||||
return registry
|
|
||||||
|
|
||||||
def _allowed_names_for_scope(self, scope, include_internal):
|
|
||||||
key = (scope, include_internal)
|
|
||||||
names = self._allowed_names.get(key)
|
|
||||||
if names is None:
|
|
||||||
names = {
|
|
||||||
cls.key()
|
|
||||||
for cls in self.classes_for_scope(
|
|
||||||
scope, include_internal=include_internal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
self._allowed_names[key] = names
|
|
||||||
return names
|
|
||||||
|
|
||||||
def validate_requested(self, requested, scope):
|
|
||||||
"""
|
|
||||||
Raise BadRequest if any requested extra name is not a public extra
|
|
||||||
for this scope. Used by data formats such as .json - HTML pages
|
|
||||||
silently ignore unknown names instead.
|
|
||||||
"""
|
|
||||||
allowed = self._allowed_names_for_scope(scope, include_internal=False)
|
|
||||||
unknown = sorted(name for name in requested if name not in allowed)
|
|
||||||
if unknown:
|
|
||||||
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
|
|
||||||
|
|
||||||
async def resolve(self, requested, context, scope, include_internal=False):
|
|
||||||
allowed_names = self._allowed_names_for_scope(scope, include_internal)
|
|
||||||
requested_names = [name for name in requested if name in allowed_names]
|
|
||||||
resolved = await self._registry_for_scope(scope).resolve_multi(
|
|
||||||
requested_names, results={"context": context}
|
|
||||||
)
|
|
||||||
return {name: resolved[name] for name in requested_names}
|
|
||||||
|
|
||||||
|
|
||||||
def _camel_to_snake(name):
|
|
||||||
name = re.sub(r"(Extra|Provider)$", "", name)
|
|
||||||
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
||||||
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
|
|
||||||
|
|
@ -83,7 +83,7 @@ class Facet:
|
||||||
self.ds = ds
|
self.ds = ds
|
||||||
self.request = request
|
self.request = request
|
||||||
self.database = database
|
self.database = database
|
||||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
# For foreign key expansion. Can be None for e.g. canned SQL queries:
|
||||||
self.table = table
|
self.table = table
|
||||||
self.sql = sql or f"select * from [{table}]"
|
self.sql = sql or f"select * from [{table}]"
|
||||||
self.params = params or []
|
self.params = params or []
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,9 @@
|
||||||
from datasette import hookimpl, Response
|
from datasette import hookimpl, Response
|
||||||
from .utils import add_cors_headers
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(trylast=True)
|
@hookimpl(trylast=True)
|
||||||
def forbidden(datasette, request, message):
|
def forbidden(datasette, request, message):
|
||||||
async def inner():
|
async def inner():
|
||||||
if (
|
|
||||||
request.path.split("?")[0].endswith(".json")
|
|
||||||
or "application/json" in (request.headers.get("accept") or "")
|
|
||||||
or request.headers.get("content-type") == "application/json"
|
|
||||||
):
|
|
||||||
headers = {}
|
|
||||||
if datasette.cors:
|
|
||||||
add_cors_headers(headers)
|
|
||||||
return Response.error(message, 403, headers=headers)
|
|
||||||
return Response.html(
|
return Response.html(
|
||||||
await datasette.render_template(
|
await datasette.render_template(
|
||||||
"error.html",
|
"error.html",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from datasette import hookimpl, Response
|
from datasette import hookimpl, Response
|
||||||
from .utils import add_cors_headers, error_body
|
from .utils import add_cors_headers
|
||||||
from .utils.asgi import (
|
from .utils.asgi import (
|
||||||
Base400,
|
Base400,
|
||||||
)
|
)
|
||||||
|
|
@ -28,7 +28,6 @@ def handle_exception(datasette, request, exception):
|
||||||
rich.get_console().print_exception(show_locals=True)
|
rich.get_console().print_exception(show_locals=True)
|
||||||
|
|
||||||
title = None
|
title = None
|
||||||
plain_message = None
|
|
||||||
if isinstance(exception, Base400):
|
if isinstance(exception, Base400):
|
||||||
status = exception.status
|
status = exception.status
|
||||||
info = {}
|
info = {}
|
||||||
|
|
@ -37,7 +36,6 @@ def handle_exception(datasette, request, exception):
|
||||||
status = exception.status
|
status = exception.status
|
||||||
info = exception.error_dict
|
info = exception.error_dict
|
||||||
message = exception.message
|
message = exception.message
|
||||||
plain_message = exception.plain_message
|
|
||||||
if exception.message_is_html:
|
if exception.message_is_html:
|
||||||
message = Markup(message)
|
message = Markup(message)
|
||||||
title = exception.title
|
title = exception.title
|
||||||
|
|
@ -47,13 +45,6 @@ def handle_exception(datasette, request, exception):
|
||||||
message = str(exception)
|
message = str(exception)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
templates = [f"{status}.html", "error.html"]
|
templates = [f"{status}.html", "error.html"]
|
||||||
headers = {}
|
|
||||||
if datasette.cors:
|
|
||||||
add_cors_headers(headers)
|
|
||||||
if request.path.split("?")[0].endswith(".json"):
|
|
||||||
body = dict(info)
|
|
||||||
body.update(error_body(plain_message or message, status))
|
|
||||||
return Response.json(body, status=status, headers=headers)
|
|
||||||
info.update(
|
info.update(
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
|
|
@ -62,6 +53,12 @@ def handle_exception(datasette, request, exception):
|
||||||
"title": title,
|
"title": title,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
headers = {}
|
||||||
|
if datasette.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
if request.path.split("?")[0].endswith(".json"):
|
||||||
|
return Response.json(info, status=status, headers=headers)
|
||||||
|
else:
|
||||||
environment = datasette.get_jinja_environment(request)
|
environment = datasette.get_jinja_environment(request)
|
||||||
template = environment.select_template(templates)
|
template = environment.select_template(templates)
|
||||||
return Response.html(
|
return Response.html(
|
||||||
|
|
@ -69,6 +66,7 @@ def handle_exception(datasette, request, exception):
|
||||||
dict(
|
dict(
|
||||||
info,
|
info,
|
||||||
urls=datasette.urls,
|
urls=datasette.urls,
|
||||||
|
app_css_hash=datasette.app_css_hash(),
|
||||||
menu_links=lambda: [],
|
menu_links=lambda: [],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,11 @@ def permission_resources_sql(datasette, actor, action):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def canned_queries(datasette, database, actor):
|
||||||
|
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def register_magic_parameters(datasette):
|
def register_magic_parameters(datasette):
|
||||||
"""Return a list of (name, function) magic parameter functions"""
|
"""Return a list of (name, function) magic parameter functions"""
|
||||||
|
|
@ -159,32 +164,32 @@ def jump_items_sql(datasette, actor, request):
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def row_actions(datasette, actor, request, database, table, row):
|
def row_actions(datasette, actor, request, database, table, row):
|
||||||
"""Items for the row actions menu"""
|
"""Links for the row actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def table_actions(datasette, actor, database, table, request):
|
def table_actions(datasette, actor, database, table, request):
|
||||||
"""Items for the table actions menu"""
|
"""Links for the table actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def view_actions(datasette, actor, database, view, request):
|
def view_actions(datasette, actor, database, view, request):
|
||||||
"""Items for the view actions menu"""
|
"""Links for the view actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||||
"""Items for the query and stored query actions menu"""
|
"""Links for the query and canned query actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def database_actions(datasette, actor, database, request):
|
def database_actions(datasette, actor, database, request):
|
||||||
"""Items for the database actions menu"""
|
"""Links for the database actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def homepage_actions(datasette, actor, request):
|
def homepage_actions(datasette, actor, request):
|
||||||
"""Items for the homepage actions menu"""
|
"""Links for the homepage actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
|
|
@ -228,8 +233,8 @@ def top_query(datasette, request, database, sql):
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def top_stored_query(datasette, request, database, query_name):
|
def top_canned_query(datasette, request, database, query_name):
|
||||||
"""HTML to include at the top of the stored query page"""
|
"""HTML to include at the top of the canned query page"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,6 @@ _skip_permission_checks = contextvars.ContextVar(
|
||||||
"skip_permission_checks", default=False
|
"skip_permission_checks", default=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Request-scoped cache of permission check results. The ASGI router sets
|
|
||||||
# this to a fresh dict at the start of each request, so cached verdicts
|
|
||||||
# never outlive a request or leak between actors. Keys are
|
|
||||||
# (actor_json, action, parent, child) tuples, values are booleans.
|
|
||||||
_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
|
|
||||||
"permission_check_cache", default=None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SkipPermissions:
|
class SkipPermissions:
|
||||||
"""Context manager to temporarily skip permission checks.
|
"""Context manager to temporarily skip permission checks.
|
||||||
|
|
@ -66,16 +58,6 @@ class Resource(ABC):
|
||||||
self.child = child
|
self.child = child
|
||||||
self._private = None # Sentinel to track if private was set
|
self._private = None # Sentinel to track if private was set
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return "/".join(
|
|
||||||
str(part) for part in (self.parent, self.child) if part is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "{}(parent={!r}, child={!r})".format(
|
|
||||||
self.__class__.__name__, self.parent, self.child
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def private(self) -> bool:
|
def private(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,6 @@ DEFAULT_PLUGINS = (
|
||||||
"datasette.blob_renderer",
|
"datasette.blob_renderer",
|
||||||
"datasette.default_debug_menu",
|
"datasette.default_debug_menu",
|
||||||
"datasette.default_jump_items",
|
"datasette.default_jump_items",
|
||||||
"datasette.default_database_actions",
|
|
||||||
"datasette.default_table_actions",
|
|
||||||
"datasette.default_query_actions",
|
|
||||||
"datasette.handle_exception",
|
"datasette.handle_exception",
|
||||||
"datasette.forbidden",
|
"datasette.forbidden",
|
||||||
"datasette.events",
|
"datasette.events",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import json
|
import json
|
||||||
from datasette.extras import extra_names_from_request
|
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
error_body,
|
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
remove_infinites,
|
remove_infinites,
|
||||||
CustomJSONEncoder,
|
CustomJSONEncoder,
|
||||||
|
|
@ -53,7 +51,8 @@ def json_renderer(request, args, data, error, truncated=None):
|
||||||
if error:
|
if error:
|
||||||
shape = "objects"
|
shape = "objects"
|
||||||
status_code = 400
|
status_code = 400
|
||||||
data.update(error_body(error, status_code))
|
data["error"] = error
|
||||||
|
data["ok"] = False
|
||||||
|
|
||||||
if truncated is not None:
|
if truncated is not None:
|
||||||
data["truncated"] = truncated
|
data["truncated"] = truncated
|
||||||
|
|
@ -87,8 +86,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
||||||
object_rows[pk_string] = row
|
object_rows[pk_string] = row
|
||||||
data = object_rows
|
data = object_rows
|
||||||
if shape_error:
|
if shape_error:
|
||||||
status_code = 400
|
data = {"ok": False, "error": shape_error}
|
||||||
data = error_body(shape_error, status_code)
|
|
||||||
elif shape == "array":
|
elif shape == "array":
|
||||||
data = data["rows"]
|
data = data["rows"]
|
||||||
|
|
||||||
|
|
@ -101,11 +99,16 @@ def json_renderer(request, args, data, error, truncated=None):
|
||||||
data["rows"] = [list(row.values()) for row in data["rows"]]
|
data["rows"] = [list(row.values()) for row in data["rows"]]
|
||||||
else:
|
else:
|
||||||
status_code = 400
|
status_code = 400
|
||||||
data = error_body(f"Invalid _shape: {shape}", status_code)
|
data = {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Invalid _shape: {shape}",
|
||||||
|
"status": 400,
|
||||||
|
"title": None,
|
||||||
|
}
|
||||||
|
|
||||||
# Don't include "columns" in output
|
# Don't include "columns" in output
|
||||||
# https://github.com/simonw/datasette/issues/2136
|
# https://github.com/simonw/datasette/issues/2136
|
||||||
if isinstance(data, dict) and "columns" not in extra_names_from_request(request):
|
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
|
||||||
data.pop("columns", None)
|
data.pop("columns", None)
|
||||||
|
|
||||||
# Handle _nl option for _shape=array
|
# Handle _nl option for _shape=array
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class TableResource(Resource):
|
||||||
|
|
||||||
|
|
||||||
class QueryResource(Resource):
|
class QueryResource(Resource):
|
||||||
"""A stored query in a database."""
|
"""A canned query in a database."""
|
||||||
|
|
||||||
name = "query"
|
name = "query"
|
||||||
parent_class = DatabaseResource
|
parent_class = DatabaseResource
|
||||||
|
|
@ -51,8 +51,42 @@ class QueryResource(Resource):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||||
return """
|
from datasette.plugins import pm
|
||||||
SELECT q.database_name AS parent, q.name AS child
|
from datasette.utils import await_me_maybe
|
||||||
FROM queries q
|
|
||||||
JOIN catalog_databases cd ON cd.database_name = q.database_name
|
# Get all databases from catalog
|
||||||
"""
|
db = datasette.get_internal_database()
|
||||||
|
result = await db.execute("SELECT database_name FROM catalog_databases")
|
||||||
|
databases = [row[0] for row in result.rows]
|
||||||
|
|
||||||
|
# Gather canned queries for this actor from all databases.
|
||||||
|
# This keeps allowed_resources("view-query", actor=...) consistent with
|
||||||
|
# actor-specific canned_queries() implementations.
|
||||||
|
query_pairs = []
|
||||||
|
for database_name in databases:
|
||||||
|
# Call the hook to get queries (including from config via default plugin)
|
||||||
|
for queries_result in pm.hook.canned_queries(
|
||||||
|
datasette=datasette,
|
||||||
|
database=database_name,
|
||||||
|
actor=actor,
|
||||||
|
):
|
||||||
|
queries = await await_me_maybe(queries_result)
|
||||||
|
if queries:
|
||||||
|
for query_name in queries.keys():
|
||||||
|
query_pairs.append((database_name, query_name))
|
||||||
|
|
||||||
|
# Build SQL
|
||||||
|
if not query_pairs:
|
||||||
|
return "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||||
|
|
||||||
|
# Generate UNION ALL query
|
||||||
|
selects = []
|
||||||
|
for db_name, query_name in query_pairs:
|
||||||
|
# Escape single quotes by doubling them
|
||||||
|
db_escaped = db_name.replace("'", "''")
|
||||||
|
query_escaped = query_name.replace("'", "''")
|
||||||
|
selects.append(
|
||||||
|
f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child"
|
||||||
|
)
|
||||||
|
|
||||||
|
return " UNION ALL ".join(selects)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,344 +0,0 @@
|
||||||
(function () {
|
|
||||||
function autocompleteValueFromRow(row) {
|
|
||||||
var pks = (row && row.pks) || {};
|
|
||||||
var keys = Object.keys(pks);
|
|
||||||
if (!keys.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (keys.length === 1) {
|
|
||||||
return String(pks[keys[0]]);
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
.map(function (key) {
|
|
||||||
return key + "=" + pks[key];
|
|
||||||
})
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function autocompleteLabelFromRow(row) {
|
|
||||||
var value = autocompleteValueFromRow(row);
|
|
||||||
if (row.label && String(row.label) !== value) {
|
|
||||||
return row.label + " (" + value + ")";
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.customElements || customElements.get("datasette-autocomplete")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
class DatasetteAutocomplete extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.input = null;
|
|
||||||
this.listbox = null;
|
|
||||||
this.status = null;
|
|
||||||
this.results = [];
|
|
||||||
this.activeIndex = -1;
|
|
||||||
this.fetchId = 0;
|
|
||||||
this.searchTimer = null;
|
|
||||||
this.boundInput = this.handleInput.bind(this);
|
|
||||||
this.boundKeydown = this.handleKeydown.bind(this);
|
|
||||||
this.boundBlur = this.handleBlur.bind(this);
|
|
||||||
this.boundFocus = this.handleFocus.bind(this);
|
|
||||||
this.boundPositionListbox = this.positionListbox.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
if (this.input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.input = this.querySelector("input");
|
|
||||||
if (!this.input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var inputId =
|
|
||||||
this.input.id ||
|
|
||||||
"datasette-autocomplete-" + Math.random().toString(36).slice(2);
|
|
||||||
this.input.id = inputId;
|
|
||||||
var listboxId = inputId + "-listbox";
|
|
||||||
var statusId = inputId + "-status";
|
|
||||||
|
|
||||||
this.classList.add("datasette-autocomplete");
|
|
||||||
this.input.setAttribute("role", "combobox");
|
|
||||||
this.input.setAttribute("aria-autocomplete", "list");
|
|
||||||
this.input.setAttribute("aria-expanded", "false");
|
|
||||||
this.input.setAttribute("aria-controls", listboxId);
|
|
||||||
this.input.setAttribute("autocomplete", "off");
|
|
||||||
|
|
||||||
this.listbox = document.createElement("div");
|
|
||||||
this.listbox.className = "datasette-autocomplete-list";
|
|
||||||
this.listbox.id = listboxId;
|
|
||||||
this.listbox.setAttribute("role", "listbox");
|
|
||||||
this.listbox.hidden = true;
|
|
||||||
|
|
||||||
this.status = document.createElement("span");
|
|
||||||
this.status.className = "datasette-autocomplete-status";
|
|
||||||
this.status.id = statusId;
|
|
||||||
this.status.setAttribute("role", "status");
|
|
||||||
this.status.setAttribute("aria-live", "polite");
|
|
||||||
|
|
||||||
this.input.setAttribute(
|
|
||||||
"aria-describedby",
|
|
||||||
[this.input.getAttribute("aria-describedby"), statusId]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" "),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.appendChild(this.listbox);
|
|
||||||
this.appendChild(this.status);
|
|
||||||
|
|
||||||
this.input.addEventListener("input", this.boundInput);
|
|
||||||
this.input.addEventListener("keydown", this.boundKeydown);
|
|
||||||
this.input.addEventListener("blur", this.boundBlur);
|
|
||||||
this.input.addEventListener("focus", this.boundFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
if (!this.input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.input.removeEventListener("input", this.boundInput);
|
|
||||||
this.input.removeEventListener("keydown", this.boundKeydown);
|
|
||||||
this.input.removeEventListener("blur", this.boundBlur);
|
|
||||||
this.input.removeEventListener("focus", this.boundFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput() {
|
|
||||||
this.scheduleSearch();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFocus() {
|
|
||||||
if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) {
|
|
||||||
this.scheduleSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBlur() {
|
|
||||||
window.setTimeout(() => this.close(), 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeydown(ev) {
|
|
||||||
if (ev.key === "Escape") {
|
|
||||||
if (!this.listbox.hidden) {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.key === "ArrowDown") {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (this.listbox.hidden) {
|
|
||||||
this.scheduleSearch();
|
|
||||||
} else {
|
|
||||||
this.setActiveIndex(this.activeIndex + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.key === "ArrowUp") {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!this.listbox.hidden) {
|
|
||||||
this.setActiveIndex(this.activeIndex - 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.chooseIndex(this.activeIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSearch() {
|
|
||||||
window.clearTimeout(this.searchTimer);
|
|
||||||
this.searchTimer = window.setTimeout(() => this.search(), 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
async search() {
|
|
||||||
var query = this.input.value.trim();
|
|
||||||
var initial = !query && this.hasAttribute("suggest-on-focus");
|
|
||||||
if (!query && !initial) {
|
|
||||||
this.close();
|
|
||||||
this.status.textContent = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var src = this.getAttribute("src");
|
|
||||||
if (!src) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = new URL(src, location.href);
|
|
||||||
url.searchParams.set("q", query);
|
|
||||||
if (initial) {
|
|
||||||
url.searchParams.set("_initial", "1");
|
|
||||||
} else {
|
|
||||||
url.searchParams.delete("_initial");
|
|
||||||
}
|
|
||||||
var fetchId = this.fetchId + 1;
|
|
||||||
this.fetchId = fetchId;
|
|
||||||
this.status.textContent = "Searching...";
|
|
||||||
|
|
||||||
try {
|
|
||||||
var response = await fetch(url.toString(), {
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("HTTP " + response.status);
|
|
||||||
}
|
|
||||||
var data = await response.json();
|
|
||||||
if (fetchId !== this.fetchId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.results = (data && data.rows) || [];
|
|
||||||
this.render();
|
|
||||||
} catch (_error) {
|
|
||||||
if (fetchId !== this.fetchId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.results = [];
|
|
||||||
this.close();
|
|
||||||
this.status.textContent = "Could not load suggestions";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
this.listbox.textContent = "";
|
|
||||||
this.activeIndex = -1;
|
|
||||||
if (!this.results.length) {
|
|
||||||
this.close();
|
|
||||||
this.status.textContent = "No matches";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.forEach((row, index) => {
|
|
||||||
var option = document.createElement("div");
|
|
||||||
option.className = "datasette-autocomplete-option";
|
|
||||||
option.id = this.input.id + "-option-" + index;
|
|
||||||
option.setAttribute("role", "option");
|
|
||||||
option.setAttribute("aria-selected", "false");
|
|
||||||
option.dataset.index = String(index);
|
|
||||||
option.dataset.value = autocompleteValueFromRow(row);
|
|
||||||
option.textContent = autocompleteLabelFromRow(row);
|
|
||||||
option.addEventListener("mousedown", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.chooseIndex(index);
|
|
||||||
});
|
|
||||||
this.listbox.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.listbox.hidden = false;
|
|
||||||
this.input.setAttribute("aria-expanded", "true");
|
|
||||||
this.status.textContent =
|
|
||||||
this.results.length + (this.results.length === 1 ? " match" : " matches");
|
|
||||||
this.positionListbox();
|
|
||||||
this.setActiveIndex(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
positionListbox() {
|
|
||||||
if (!this.input || !this.listbox || this.listbox.hidden) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var gap = 3;
|
|
||||||
var margin = 8;
|
|
||||||
var inputRect = this.input.getBoundingClientRect();
|
|
||||||
this.listbox.style.maxHeight = "";
|
|
||||||
var defaultMaxHeight = parseFloat(
|
|
||||||
window.getComputedStyle(this.listbox).maxHeight,
|
|
||||||
);
|
|
||||||
if (!Number.isFinite(defaultMaxHeight)) {
|
|
||||||
defaultMaxHeight = 256;
|
|
||||||
}
|
|
||||||
var scrollHeight = Math.ceil(this.listbox.scrollHeight);
|
|
||||||
var desiredHeight = Math.min(scrollHeight, defaultMaxHeight);
|
|
||||||
var availableBelow = Math.max(
|
|
||||||
0,
|
|
||||||
(window.innerHeight || document.documentElement.clientHeight) -
|
|
||||||
inputRect.bottom -
|
|
||||||
gap -
|
|
||||||
margin,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.listbox.style.left = inputRect.left + "px";
|
|
||||||
this.listbox.style.top = inputRect.bottom + gap + "px";
|
|
||||||
this.listbox.style.width = inputRect.width + "px";
|
|
||||||
if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) {
|
|
||||||
this.listbox.style.maxHeight = "none";
|
|
||||||
} else {
|
|
||||||
this.listbox.style.maxHeight =
|
|
||||||
Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) +
|
|
||||||
"px";
|
|
||||||
}
|
|
||||||
window.addEventListener("resize", this.boundPositionListbox);
|
|
||||||
document.addEventListener("scroll", this.boundPositionListbox, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveIndex(index) {
|
|
||||||
var options = this.listbox.querySelectorAll("[role='option']");
|
|
||||||
if (!options.length) {
|
|
||||||
this.activeIndex = -1;
|
|
||||||
this.input.removeAttribute("aria-activedescendant");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (index < 0) {
|
|
||||||
index = options.length - 1;
|
|
||||||
}
|
|
||||||
if (index >= options.length) {
|
|
||||||
index = 0;
|
|
||||||
}
|
|
||||||
options.forEach((option, optionIndex) => {
|
|
||||||
option.setAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
optionIndex === index ? "true" : "false",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.activeIndex = index;
|
|
||||||
this.input.setAttribute("aria-activedescendant", options[index].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
chooseIndex(index) {
|
|
||||||
var row = this.results[index];
|
|
||||||
if (!row) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var value = autocompleteValueFromRow(row);
|
|
||||||
var label = autocompleteLabelFromRow(row);
|
|
||||||
this.input.value = value;
|
|
||||||
this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
this.close();
|
|
||||||
this.status.textContent = "Selected " + label;
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent("datasette-autocomplete-select", {
|
|
||||||
bubbles: true,
|
|
||||||
detail: {
|
|
||||||
row: row,
|
|
||||||
value: value,
|
|
||||||
label: label,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this.listbox) {
|
|
||||||
this.listbox.hidden = true;
|
|
||||||
this.listbox.textContent = "";
|
|
||||||
this.listbox.style.left = "";
|
|
||||||
this.listbox.style.maxHeight = "";
|
|
||||||
this.listbox.style.top = "";
|
|
||||||
this.listbox.style.width = "";
|
|
||||||
}
|
|
||||||
if (this.input) {
|
|
||||||
this.input.setAttribute("aria-expanded", "false");
|
|
||||||
this.input.removeAttribute("aria-activedescendant");
|
|
||||||
}
|
|
||||||
window.removeEventListener("resize", this.boundPositionListbox);
|
|
||||||
document.removeEventListener("scroll", this.boundPositionListbox, true);
|
|
||||||
this.activeIndex = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("datasette-autocomplete", DatasetteAutocomplete);
|
|
||||||
})();
|
|
||||||
|
|
@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {
|
||||||
<style>
|
<style>
|
||||||
:host {
|
:host {
|
||||||
--ink: #0f0f0f;
|
--ink: #0f0f0f;
|
||||||
--paper: #eef6ff;
|
--paper: #f5f3ef;
|
||||||
--muted: #6b6b6b;
|
--muted: #6b6b6b;
|
||||||
--rule: #d8e6f5;
|
--rule: #e2dfd8;
|
||||||
--accent: #1a56db;
|
--accent: #1a56db;
|
||||||
--accent-light: #e8effd;
|
--accent-light: #e8effd;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
|
|
|
||||||
|
|
@ -82,35 +82,6 @@ const datasetteManager = {
|
||||||
return columnActions;
|
return columnActions;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
|
|
||||||
* for specific Datasette column types.
|
|
||||||
*
|
|
||||||
* The first plugin to return a control object wins. Returning null or
|
|
||||||
* undefined means "I do not handle this field".
|
|
||||||
*/
|
|
||||||
makeColumnField: (context) => {
|
|
||||||
for (const [pluginName, plugin] of datasetteManager.plugins) {
|
|
||||||
if (!plugin.makeColumnField) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let control = null;
|
|
||||||
try {
|
|
||||||
control = plugin.makeColumnField(context);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error in makeColumnField() for plugin ${pluginName}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (control) {
|
|
||||||
return Object.assign({ pluginName }, control);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
|
|
||||||
makeJumpSections: (context) => {
|
makeJumpSections: (context) => {
|
||||||
let jumpSections = [];
|
let jumpSections = [];
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,7 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
|
|
||||||
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
|
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
|
||||||
var setColumnTypeDialogState = null;
|
var setColumnTypeDialogState = null;
|
||||||
|
|
||||||
function getParams() {
|
function getParams() {
|
||||||
return new URLSearchParams(location.search);
|
return new URLSearchParams(location.search);
|
||||||
}
|
}
|
||||||
|
|
@ -633,151 +634,32 @@ const initDatasetteTable = function (manager) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function filterRowSelector(manager) {
|
/* Add x buttons to the filter rows */
|
||||||
return manager.selectors.filterRows || manager.selectors.filterRow;
|
function addButtonsToFilterRows(manager) {
|
||||||
}
|
var x = "✖";
|
||||||
|
var rows = Array.from(
|
||||||
function filterRowsWithControls(manager) {
|
document.querySelectorAll(manager.selectors.filterRow),
|
||||||
return Array.from(
|
|
||||||
document.querySelectorAll(filterRowSelector(manager)),
|
|
||||||
).filter((el) => el.querySelector(".filter-op"));
|
).filter((el) => el.querySelector(".filter-op"));
|
||||||
}
|
rows.forEach((row) => {
|
||||||
|
var a = document.createElement("a");
|
||||||
function filterRowNumberFromName(name) {
|
a.setAttribute("href", "#");
|
||||||
var match = name && name.match(/^_filter_column_(\d+)$/);
|
a.setAttribute("aria-label", "Remove this filter");
|
||||||
return match ? parseInt(match[1], 10) : 0;
|
a.style.textDecoration = "none";
|
||||||
}
|
a.innerText = x;
|
||||||
|
a.addEventListener("click", (ev) => {
|
||||||
function nextFilterRowNumber(manager) {
|
ev.preventDefault();
|
||||||
return filterRowsWithControls(manager).reduce((max, row) => {
|
let row = ev.target.closest("div");
|
||||||
var column = row.querySelector("select");
|
|
||||||
return Math.max(max, filterRowNumberFromName(column && column.name));
|
|
||||||
}, 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFilterRowNumber(row, number) {
|
|
||||||
row.querySelector("select").name = `_filter_column_${number}`;
|
|
||||||
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
|
|
||||||
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilterRow(row) {
|
|
||||||
row.querySelector("select").value = "";
|
row.querySelector("select").value = "";
|
||||||
row.querySelector(".filter-op select").value = "exact";
|
row.querySelector(".filter-op select").value = "exact";
|
||||||
row.querySelector("input.filter-value").value = "";
|
row.querySelector("input.filter-value").value = "";
|
||||||
}
|
ev.target.closest("a").style.display = "none";
|
||||||
|
});
|
||||||
function updateFilterRowButtons(manager) {
|
row.appendChild(a);
|
||||||
var rows = filterRowsWithControls(manager);
|
|
||||||
rows.forEach((row, index) => {
|
|
||||||
var removeButton = row.querySelector(".filter-row-remove");
|
|
||||||
var addButton = row.querySelector(".filter-row-add");
|
|
||||||
var column = row.querySelector("select");
|
var column = row.querySelector("select");
|
||||||
if (removeButton) {
|
if (!column.value) {
|
||||||
removeButton.hidden = index === 0;
|
a.style.display = "none";
|
||||||
}
|
|
||||||
if (addButton) {
|
|
||||||
addButton.hidden = index !== rows.length - 1 || !column.value;
|
|
||||||
}
|
|
||||||
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
|
|
||||||
return button && !button.hidden;
|
|
||||||
}).length;
|
|
||||||
row.classList.toggle(
|
|
||||||
"filter-controls-row-has-buttons",
|
|
||||||
visibleButtonCount > 0,
|
|
||||||
);
|
|
||||||
row.classList.toggle(
|
|
||||||
"filter-controls-row-one-button",
|
|
||||||
visibleButtonCount === 1,
|
|
||||||
);
|
|
||||||
row.classList.toggle(
|
|
||||||
"filter-controls-row-two-buttons",
|
|
||||||
visibleButtonCount === 2,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneFilterRow(row) {
|
|
||||||
var clone = row.cloneNode(true);
|
|
||||||
clone.querySelector("select").name = "_filter_column";
|
|
||||||
clone.querySelector(".filter-op select").name = "_filter_op";
|
|
||||||
clone.querySelector("input.filter-value").name = "_filter_value";
|
|
||||||
resetFilterRow(clone);
|
|
||||||
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
|
|
||||||
return clone;
|
|
||||||
}
|
|
||||||
|
|
||||||
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 6h18"></path>
|
|
||||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
|
|
||||||
<path d="M10 11v6"></path>
|
|
||||||
<path d="M14 11v6"></path>
|
|
||||||
</svg>`;
|
|
||||||
|
|
||||||
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M5 12h14"></path>
|
|
||||||
<path d="M12 5v14"></path>
|
|
||||||
</svg>`;
|
|
||||||
|
|
||||||
function addFilterRowButtons(row, manager) {
|
|
||||||
var removeButton = document.createElement("button");
|
|
||||||
removeButton.type = "button";
|
|
||||||
removeButton.className = "filter-row-icon filter-row-remove";
|
|
||||||
removeButton.setAttribute("aria-label", "Remove this filter");
|
|
||||||
removeButton.title = "Remove this filter";
|
|
||||||
removeButton.tabIndex = 0;
|
|
||||||
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
|
|
||||||
removeButton.addEventListener("click", (ev) => {
|
|
||||||
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
|
||||||
var rows = filterRowsWithControls(manager);
|
|
||||||
var rowIndex = rows.indexOf(row);
|
|
||||||
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
|
|
||||||
row.remove();
|
|
||||||
updateFilterRowButtons(manager);
|
|
||||||
if (focusRow) {
|
|
||||||
var focusTarget =
|
|
||||||
focusRow.querySelector(".filter-row-add:not([hidden])") ||
|
|
||||||
focusRow.querySelector("select");
|
|
||||||
if (focusTarget) {
|
|
||||||
focusTarget.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.appendChild(removeButton);
|
|
||||||
|
|
||||||
var addButton = document.createElement("button");
|
|
||||||
addButton.type = "button";
|
|
||||||
addButton.className = "filter-row-icon filter-row-add";
|
|
||||||
addButton.setAttribute("aria-label", "Add another filter");
|
|
||||||
addButton.title = "Add another filter";
|
|
||||||
addButton.tabIndex = 0;
|
|
||||||
addButton.innerHTML = FILTER_ADD_ICON_SVG;
|
|
||||||
addButton.addEventListener("click", (ev) => {
|
|
||||||
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
|
||||||
if (row.querySelector("select").name === "_filter_column") {
|
|
||||||
setFilterRowNumber(row, nextFilterRowNumber(manager));
|
|
||||||
}
|
|
||||||
var clone = cloneFilterRow(row);
|
|
||||||
addFilterRowButtons(clone, manager);
|
|
||||||
row.parentNode.insertBefore(clone, row.nextSibling);
|
|
||||||
updateFilterRowButtons(manager);
|
|
||||||
clone.querySelector("select").focus();
|
|
||||||
});
|
|
||||||
row.appendChild(addButton);
|
|
||||||
|
|
||||||
row.querySelector("select").addEventListener("change", () => {
|
|
||||||
updateFilterRowButtons(manager);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add buttons to the filter rows */
|
|
||||||
function addButtonsToFilterRows(manager) {
|
|
||||||
var rows = filterRowsWithControls(manager);
|
|
||||||
rows.forEach((row) => {
|
|
||||||
addFilterRowButtons(row, manager);
|
|
||||||
});
|
|
||||||
updateFilterRowButtons(manager);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Set up datalist autocomplete for filter values */
|
/* Set up datalist autocomplete for filter values */
|
||||||
|
|
@ -806,11 +688,11 @@ function initAutocompleteForFilterValues(manager) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
createDataLists();
|
createDataLists();
|
||||||
// When any filter column select changes, update the datalist
|
// When any select with name=_filter_column changes, update the datalist
|
||||||
document.body.addEventListener("change", function (event) {
|
document.body.addEventListener("change", function (event) {
|
||||||
if (event.target.name && event.target.name.startsWith("_filter_column")) {
|
if (event.target.name === "_filter_column") {
|
||||||
event.target
|
event.target
|
||||||
.closest(filterRowSelector(manager))
|
.closest(manager.selectors.filterRow)
|
||||||
.querySelector(".filter-value")
|
.querySelector(".filter-value")
|
||||||
.setAttribute("list", "datalist-" + event.target.value);
|
.setAttribute("list", "datalist-" + event.target.value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,579 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
|
||||||
from typing import Any, Iterable
|
|
||||||
|
|
||||||
from .utils import tilde_encode, urlsafe_components
|
|
||||||
|
|
||||||
UNCHANGED = object()
|
|
||||||
|
|
||||||
|
|
||||||
QUERY_OPTION_FIELDS = (
|
|
||||||
"hide_sql",
|
|
||||||
"fragment",
|
|
||||||
"on_success_message",
|
|
||||||
"on_success_message_sql",
|
|
||||||
"on_success_redirect",
|
|
||||||
"on_error_message",
|
|
||||||
"on_error_redirect",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StoredQuery:
|
|
||||||
database: str
|
|
||||||
name: str
|
|
||||||
sql: str
|
|
||||||
title: str | None
|
|
||||||
description: str | None
|
|
||||||
description_html: str | None
|
|
||||||
hide_sql: bool
|
|
||||||
fragment: str | None
|
|
||||||
parameters: list[str]
|
|
||||||
is_write: bool
|
|
||||||
is_private: bool
|
|
||||||
is_trusted: bool
|
|
||||||
source: str
|
|
||||||
owner_id: str | None
|
|
||||||
on_success_message: str | None
|
|
||||||
on_success_message_sql: str | None
|
|
||||||
on_success_redirect: str | None
|
|
||||||
on_error_message: str | None
|
|
||||||
on_error_redirect: str | None
|
|
||||||
private: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StoredQueryPage:
|
|
||||||
queries: list[StoredQuery]
|
|
||||||
next: str | None
|
|
||||||
has_more: bool
|
|
||||||
limit: int
|
|
||||||
|
|
||||||
|
|
||||||
def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
|
|
||||||
data = {
|
|
||||||
"database": query.database,
|
|
||||||
"name": query.name,
|
|
||||||
"sql": query.sql,
|
|
||||||
"title": query.title,
|
|
||||||
"description": query.description,
|
|
||||||
"description_html": query.description_html,
|
|
||||||
"hide_sql": query.hide_sql,
|
|
||||||
"fragment": query.fragment,
|
|
||||||
"parameters": list(query.parameters),
|
|
||||||
"is_write": query.is_write,
|
|
||||||
"is_private": query.is_private,
|
|
||||||
"is_trusted": query.is_trusted,
|
|
||||||
"source": query.source,
|
|
||||||
"owner_id": query.owner_id,
|
|
||||||
"on_success_message": query.on_success_message,
|
|
||||||
"on_success_message_sql": query.on_success_message_sql,
|
|
||||||
"on_success_redirect": query.on_success_redirect,
|
|
||||||
"on_error_message": query.on_error_message,
|
|
||||||
"on_error_redirect": query.on_error_redirect,
|
|
||||||
}
|
|
||||||
if query.private is not None:
|
|
||||||
data["private"] = query.private
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
|
||||||
"next": page.next,
|
|
||||||
"limit": page.limit,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def save_queries_from_config(datasette: Any) -> None:
|
|
||||||
# Apply configured query entries from datasette.yaml to the internal table.
|
|
||||||
await datasette.get_internal_database().execute_write(
|
|
||||||
"DELETE FROM queries WHERE source = 'config'"
|
|
||||||
)
|
|
||||||
for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items():
|
|
||||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
|
||||||
if not isinstance(query_config, dict):
|
|
||||||
query_config = {"sql": query_config}
|
|
||||||
await datasette.add_query(
|
|
||||||
dbname,
|
|
||||||
query_name,
|
|
||||||
query_config["sql"],
|
|
||||||
title=query_config.get("title"),
|
|
||||||
description=query_config.get("description"),
|
|
||||||
description_html=query_config.get("description_html"),
|
|
||||||
hide_sql=bool(query_config.get("hide_sql")),
|
|
||||||
fragment=query_config.get("fragment"),
|
|
||||||
parameters=query_config.get("params"),
|
|
||||||
is_write=bool(query_config.get("write")),
|
|
||||||
is_trusted=bool(query_config.get("is_trusted", True)),
|
|
||||||
source="config",
|
|
||||||
on_success_message=query_config.get("on_success_message"),
|
|
||||||
on_success_message_sql=query_config.get("on_success_message_sql"),
|
|
||||||
on_success_redirect=query_config.get("on_success_redirect"),
|
|
||||||
on_error_message=query_config.get("on_error_message"),
|
|
||||||
on_error_redirect=query_config.get("on_error_redirect"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def query_row_to_stored_query(
|
|
||||||
row: Any, private: bool | None = None
|
|
||||||
) -> StoredQuery | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
parameters = json.loads(row["parameters"] or "[]")
|
|
||||||
options = json.loads(row["options"] or "{}")
|
|
||||||
return StoredQuery(
|
|
||||||
database=row["database_name"],
|
|
||||||
name=row["name"],
|
|
||||||
sql=row["sql"],
|
|
||||||
title=row["title"],
|
|
||||||
description=row["description"],
|
|
||||||
description_html=row["description_html"],
|
|
||||||
hide_sql=bool(options.get("hide_sql")),
|
|
||||||
fragment=options.get("fragment"),
|
|
||||||
parameters=parameters,
|
|
||||||
is_write=bool(row["is_write"]),
|
|
||||||
is_private=bool(row["is_private"]),
|
|
||||||
is_trusted=bool(row["is_trusted"]),
|
|
||||||
source=row["source"],
|
|
||||||
owner_id=row["owner_id"],
|
|
||||||
on_success_message=options.get("on_success_message"),
|
|
||||||
on_success_message_sql=options.get("on_success_message_sql"),
|
|
||||||
on_success_redirect=options.get("on_success_redirect"),
|
|
||||||
on_error_message=options.get("on_error_message"),
|
|
||||||
on_error_redirect=options.get("on_error_redirect"),
|
|
||||||
private=private,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def query_options_json(options: dict[str, Any]) -> str:
|
|
||||||
options_dict = {}
|
|
||||||
for field in QUERY_OPTION_FIELDS:
|
|
||||||
value = options.get(field)
|
|
||||||
if field == "hide_sql":
|
|
||||||
if value:
|
|
||||||
options_dict[field] = True
|
|
||||||
elif value is not None:
|
|
||||||
options_dict[field] = value
|
|
||||||
return json.dumps(options_dict, sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def add_query(
|
|
||||||
datasette: Any,
|
|
||||||
database: str,
|
|
||||||
name: str,
|
|
||||||
sql: str,
|
|
||||||
*,
|
|
||||||
title: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
description_html: str | None = None,
|
|
||||||
hide_sql: bool = False,
|
|
||||||
fragment: str | None = None,
|
|
||||||
parameters: Iterable[str] | None = None,
|
|
||||||
is_write: bool = False,
|
|
||||||
is_private: bool = False,
|
|
||||||
is_trusted: bool = False,
|
|
||||||
source: str = "plugin",
|
|
||||||
owner_id: str | None = None,
|
|
||||||
on_success_message: str | None = None,
|
|
||||||
on_success_message_sql: str | None = None,
|
|
||||||
on_success_redirect: str | None = None,
|
|
||||||
on_error_message: str | None = None,
|
|
||||||
on_error_redirect: str | None = None,
|
|
||||||
replace: bool = True,
|
|
||||||
) -> None:
|
|
||||||
parameters_json = json.dumps(list(parameters or []))
|
|
||||||
options_json = query_options_json(
|
|
||||||
{
|
|
||||||
"hide_sql": hide_sql,
|
|
||||||
"fragment": fragment,
|
|
||||||
"on_success_message": on_success_message,
|
|
||||||
"on_success_message_sql": on_success_message_sql,
|
|
||||||
"on_success_redirect": on_success_redirect,
|
|
||||||
"on_error_message": on_error_message,
|
|
||||||
"on_error_redirect": on_error_redirect,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
sql_statement = """
|
|
||||||
INSERT INTO queries (
|
|
||||||
database_name, name, sql, title, description, description_html,
|
|
||||||
options, parameters, is_write, is_private, is_trusted, source, owner_id
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"""
|
|
||||||
if replace:
|
|
||||||
sql_statement += """
|
|
||||||
ON CONFLICT(database_name, name) DO UPDATE SET
|
|
||||||
sql = excluded.sql,
|
|
||||||
title = excluded.title,
|
|
||||||
description = excluded.description,
|
|
||||||
description_html = excluded.description_html,
|
|
||||||
options = excluded.options,
|
|
||||||
parameters = excluded.parameters,
|
|
||||||
is_write = excluded.is_write,
|
|
||||||
is_private = excluded.is_private,
|
|
||||||
is_trusted = excluded.is_trusted,
|
|
||||||
source = excluded.source,
|
|
||||||
owner_id = excluded.owner_id,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
"""
|
|
||||||
await datasette.get_internal_database().execute_write(
|
|
||||||
sql_statement,
|
|
||||||
[
|
|
||||||
database,
|
|
||||||
name,
|
|
||||||
sql,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
description_html,
|
|
||||||
options_json,
|
|
||||||
parameters_json,
|
|
||||||
int(bool(is_write)),
|
|
||||||
int(bool(is_private)),
|
|
||||||
int(bool(is_trusted)),
|
|
||||||
source,
|
|
||||||
owner_id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_query(
|
|
||||||
datasette: Any,
|
|
||||||
database: str,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
sql=UNCHANGED,
|
|
||||||
title=UNCHANGED,
|
|
||||||
description=UNCHANGED,
|
|
||||||
description_html=UNCHANGED,
|
|
||||||
hide_sql=UNCHANGED,
|
|
||||||
fragment=UNCHANGED,
|
|
||||||
parameters=UNCHANGED,
|
|
||||||
is_write=UNCHANGED,
|
|
||||||
is_private=UNCHANGED,
|
|
||||||
is_trusted=UNCHANGED,
|
|
||||||
source=UNCHANGED,
|
|
||||||
owner_id=UNCHANGED,
|
|
||||||
on_success_message=UNCHANGED,
|
|
||||||
on_success_message_sql=UNCHANGED,
|
|
||||||
on_success_redirect=UNCHANGED,
|
|
||||||
on_error_message=UNCHANGED,
|
|
||||||
on_error_redirect=UNCHANGED,
|
|
||||||
) -> None:
|
|
||||||
fields = {
|
|
||||||
"sql": sql,
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"description_html": description_html,
|
|
||||||
"parameters": parameters,
|
|
||||||
"is_write": is_write,
|
|
||||||
"is_private": is_private,
|
|
||||||
"is_trusted": is_trusted,
|
|
||||||
"source": source,
|
|
||||||
"owner_id": owner_id,
|
|
||||||
}
|
|
||||||
option_fields = {
|
|
||||||
"hide_sql": hide_sql,
|
|
||||||
"fragment": fragment,
|
|
||||||
"on_success_message": on_success_message,
|
|
||||||
"on_success_message_sql": on_success_message_sql,
|
|
||||||
"on_success_redirect": on_success_redirect,
|
|
||||||
"on_error_message": on_error_message,
|
|
||||||
"on_error_redirect": on_error_redirect,
|
|
||||||
}
|
|
||||||
updates = []
|
|
||||||
params = []
|
|
||||||
for field, value in fields.items():
|
|
||||||
if value is UNCHANGED:
|
|
||||||
continue
|
|
||||||
if field in {"is_write", "is_private", "is_trusted"}:
|
|
||||||
value = int(bool(value))
|
|
||||||
elif field == "parameters":
|
|
||||||
value = json.dumps(list(value or []))
|
|
||||||
updates.append(f"{field} = ?")
|
|
||||||
params.append(value)
|
|
||||||
changed_options = {
|
|
||||||
field: value for field, value in option_fields.items() if value is not UNCHANGED
|
|
||||||
}
|
|
||||||
if changed_options:
|
|
||||||
rows = await datasette.get_internal_database().execute(
|
|
||||||
"""
|
|
||||||
SELECT options FROM queries
|
|
||||||
WHERE database_name = ? AND name = ?
|
|
||||||
""",
|
|
||||||
[database, name],
|
|
||||||
)
|
|
||||||
row = rows.first()
|
|
||||||
options = json.loads(row["options"] or "{}") if row is not None else {}
|
|
||||||
for field, value in changed_options.items():
|
|
||||||
if field == "hide_sql":
|
|
||||||
if value:
|
|
||||||
options[field] = True
|
|
||||||
else:
|
|
||||||
options.pop(field, None)
|
|
||||||
elif value is None:
|
|
||||||
options.pop(field, None)
|
|
||||||
else:
|
|
||||||
options[field] = value
|
|
||||||
updates.append("options = ?")
|
|
||||||
params.append(json.dumps(options, sort_keys=True))
|
|
||||||
if not updates:
|
|
||||||
return
|
|
||||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
||||||
params.extend([database, name])
|
|
||||||
await datasette.get_internal_database().execute_write(
|
|
||||||
"""
|
|
||||||
UPDATE queries
|
|
||||||
SET {}
|
|
||||||
WHERE database_name = ? AND name = ?
|
|
||||||
""".format(", ".join(updates)),
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_query(
|
|
||||||
datasette: Any, database: str, name: str, source: str | None = None
|
|
||||||
) -> None:
|
|
||||||
sql = "DELETE FROM queries WHERE database_name = ? AND name = ?"
|
|
||||||
params = [database, name]
|
|
||||||
if source is not None:
|
|
||||||
sql += " AND source = ?"
|
|
||||||
params.append(source)
|
|
||||||
await datasette.get_internal_database().execute_write(sql, params)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None:
|
|
||||||
rows = await datasette.get_internal_database().execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM queries
|
|
||||||
WHERE database_name = ? AND name = ?
|
|
||||||
""",
|
|
||||||
[database, name],
|
|
||||||
)
|
|
||||||
return query_row_to_stored_query(rows.first())
|
|
||||||
|
|
||||||
|
|
||||||
async def count_queries(
|
|
||||||
datasette: Any,
|
|
||||||
database: str | None = None,
|
|
||||||
*,
|
|
||||||
actor: dict[str, Any] | None = None,
|
|
||||||
q: str | None = None,
|
|
||||||
is_write: bool | None = None,
|
|
||||||
is_private: bool | None = None,
|
|
||||||
is_trusted: bool | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
owner_id: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
|
||||||
action="view-query",
|
|
||||||
actor=actor,
|
|
||||||
parent=database,
|
|
||||||
)
|
|
||||||
params = dict(allowed_params)
|
|
||||||
where_clauses = []
|
|
||||||
if database is not None:
|
|
||||||
params["query_database"] = database
|
|
||||||
where_clauses.append("q.database_name = :query_database")
|
|
||||||
|
|
||||||
if q:
|
|
||||||
where_clauses.append("""
|
|
||||||
(
|
|
||||||
q.name LIKE :query_search
|
|
||||||
OR q.title LIKE :query_search
|
|
||||||
OR q.description LIKE :query_search
|
|
||||||
OR q.sql LIKE :query_search
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
params["query_search"] = "%{}%".format(q)
|
|
||||||
if is_write is not None:
|
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
|
||||||
params["query_is_write"] = int(bool(is_write))
|
|
||||||
if is_private is not None:
|
|
||||||
where_clauses.append("q.is_private = :query_is_private")
|
|
||||||
params["query_is_private"] = int(bool(is_private))
|
|
||||||
if is_trusted is not None:
|
|
||||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
|
||||||
params["query_is_trusted"] = int(bool(is_trusted))
|
|
||||||
if source is not None:
|
|
||||||
where_clauses.append("q.source = :query_source")
|
|
||||||
params["query_source"] = source
|
|
||||||
if owner_id is not None:
|
|
||||||
where_clauses.append("q.owner_id = :query_owner_id")
|
|
||||||
params["query_owner_id"] = owner_id
|
|
||||||
|
|
||||||
row = (
|
|
||||||
await datasette.get_internal_database().execute(
|
|
||||||
"""
|
|
||||||
SELECT count(*) AS count
|
|
||||||
FROM queries q
|
|
||||||
JOIN (
|
|
||||||
{allowed_sql}
|
|
||||||
) allowed
|
|
||||||
ON allowed.parent = q.database_name
|
|
||||||
AND allowed.child = q.name
|
|
||||||
WHERE {where}
|
|
||||||
""".format(
|
|
||||||
allowed_sql=allowed_sql,
|
|
||||||
where=" AND ".join(where_clauses) or "1 = 1",
|
|
||||||
),
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
return row["count"]
|
|
||||||
|
|
||||||
|
|
||||||
async def list_queries(
|
|
||||||
datasette: Any,
|
|
||||||
database: str | None = None,
|
|
||||||
*,
|
|
||||||
actor: dict[str, Any] | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
cursor: str | None = None,
|
|
||||||
q: str | None = None,
|
|
||||||
is_write: bool | None = None,
|
|
||||||
is_private: bool | None = None,
|
|
||||||
is_trusted: bool | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
owner_id: str | None = None,
|
|
||||||
include_private: bool = False,
|
|
||||||
) -> StoredQueryPage:
|
|
||||||
limit = min(max(1, int(limit)), 1000)
|
|
||||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
|
||||||
action="view-query",
|
|
||||||
actor=actor,
|
|
||||||
parent=database,
|
|
||||||
include_is_private=include_private,
|
|
||||||
)
|
|
||||||
params = dict(allowed_params)
|
|
||||||
params.update({"limit": limit + 1})
|
|
||||||
sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))"
|
|
||||||
where_clauses = []
|
|
||||||
order_by = "q.database_name, sort_key, q.name"
|
|
||||||
if database is not None:
|
|
||||||
params["query_database"] = database
|
|
||||||
where_clauses.append("q.database_name = :query_database")
|
|
||||||
order_by = "sort_key, q.name"
|
|
||||||
|
|
||||||
if cursor:
|
|
||||||
try:
|
|
||||||
components = urlsafe_components(cursor)
|
|
||||||
except ValueError:
|
|
||||||
components = []
|
|
||||||
if database is None and len(components) == 3:
|
|
||||||
where_clauses.append("""
|
|
||||||
(
|
|
||||||
q.database_name > :cursor_database
|
|
||||||
OR (
|
|
||||||
q.database_name = :cursor_database
|
|
||||||
AND (
|
|
||||||
{sort_key_sql} > :cursor_sort_key
|
|
||||||
OR (
|
|
||||||
{sort_key_sql} = :cursor_sort_key
|
|
||||||
AND q.name > :cursor_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
""".format(sort_key_sql=sort_key_sql))
|
|
||||||
params["cursor_database"] = components[0]
|
|
||||||
params["cursor_sort_key"] = components[1]
|
|
||||||
params["cursor_name"] = components[2]
|
|
||||||
elif database is not None and len(components) == 2:
|
|
||||||
where_clauses.append("""
|
|
||||||
(
|
|
||||||
{sort_key_sql} > :cursor_sort_key
|
|
||||||
OR (
|
|
||||||
{sort_key_sql} = :cursor_sort_key
|
|
||||||
AND q.name > :cursor_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
""".format(sort_key_sql=sort_key_sql))
|
|
||||||
params["cursor_sort_key"] = components[0]
|
|
||||||
params["cursor_name"] = components[1]
|
|
||||||
|
|
||||||
if q:
|
|
||||||
where_clauses.append("""
|
|
||||||
(
|
|
||||||
q.name LIKE :query_search
|
|
||||||
OR q.title LIKE :query_search
|
|
||||||
OR q.description LIKE :query_search
|
|
||||||
OR q.sql LIKE :query_search
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
params["query_search"] = "%{}%".format(q)
|
|
||||||
if is_write is not None:
|
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
|
||||||
params["query_is_write"] = int(bool(is_write))
|
|
||||||
if is_private is not None:
|
|
||||||
where_clauses.append("q.is_private = :query_is_private")
|
|
||||||
params["query_is_private"] = int(bool(is_private))
|
|
||||||
if is_trusted is not None:
|
|
||||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
|
||||||
params["query_is_trusted"] = int(bool(is_trusted))
|
|
||||||
if source is not None:
|
|
||||||
where_clauses.append("q.source = :query_source")
|
|
||||||
params["query_source"] = source
|
|
||||||
if owner_id is not None:
|
|
||||||
where_clauses.append("q.owner_id = :query_owner_id")
|
|
||||||
params["query_owner_id"] = owner_id
|
|
||||||
|
|
||||||
private_select = ", allowed.is_private AS private" if include_private else ""
|
|
||||||
rows = list(
|
|
||||||
(
|
|
||||||
await datasette.get_internal_database().execute(
|
|
||||||
"""
|
|
||||||
SELECT q.*, {sort_key_sql} AS sort_key{private_select}
|
|
||||||
FROM queries q
|
|
||||||
JOIN (
|
|
||||||
{allowed_sql}
|
|
||||||
) allowed
|
|
||||||
ON allowed.parent = q.database_name
|
|
||||||
AND allowed.child = q.name
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY {order_by}
|
|
||||||
LIMIT :limit
|
|
||||||
""".format(
|
|
||||||
allowed_sql=allowed_sql,
|
|
||||||
private_select=private_select,
|
|
||||||
sort_key_sql=sort_key_sql,
|
|
||||||
where=" AND ".join(where_clauses) or "1 = 1",
|
|
||||||
order_by=order_by,
|
|
||||||
),
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
).rows
|
|
||||||
)
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
if has_more:
|
|
||||||
rows = rows[:limit]
|
|
||||||
|
|
||||||
queries = []
|
|
||||||
for row in rows:
|
|
||||||
query = query_row_to_stored_query(
|
|
||||||
row, private=bool(row["private"]) if include_private else None
|
|
||||||
)
|
|
||||||
assert query is not None
|
|
||||||
queries.append(query)
|
|
||||||
|
|
||||||
next_token = None
|
|
||||||
if has_more and rows:
|
|
||||||
last_row = rows[-1]
|
|
||||||
if database is None:
|
|
||||||
next_token = "{},{},{}".format(
|
|
||||||
tilde_encode(last_row["database_name"]),
|
|
||||||
tilde_encode(last_row["sort_key"]),
|
|
||||||
tilde_encode(last_row["name"]),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
next_token = "{},{}".format(
|
|
||||||
tilde_encode(last_row["sort_key"]),
|
|
||||||
tilde_encode(last_row["name"]),
|
|
||||||
)
|
|
||||||
return StoredQueryPage(
|
|
||||||
queries=queries,
|
|
||||||
next=next_token,
|
|
||||||
has_more=has_more,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"""
|
|
||||||
Index of the documented template contexts for Datasette's core HTML pages.
|
|
||||||
|
|
||||||
This module deliberately contains no documentation strings of its own -
|
|
||||||
the documentation lives next to the code it describes:
|
|
||||||
|
|
||||||
- Every page renders a Context dataclass defined in its view module
|
|
||||||
(DatabaseContext, QueryContext in views/database.py, TableContext in
|
|
||||||
views/table.py, RowContext in views/row.py). Fields added by view code
|
|
||||||
carry ``help`` metadata; fields declared with from_extra() take their
|
|
||||||
documentation from the description on the matching Extra class in
|
|
||||||
views/table_extras.py.
|
|
||||||
- The keys render_template() adds to every page are documented in
|
|
||||||
TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds
|
|
||||||
them.
|
|
||||||
|
|
||||||
The contract tests in tests/test_template_context.py assert that the real
|
|
||||||
rendered context for each page exactly matches what is documented, and
|
|
||||||
docs/template_context_doc.py generates docs/template_context.rst from the
|
|
||||||
same classes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datasette.app import TEMPLATE_BASE_CONTEXT
|
|
||||||
from datasette.views.database import DatabaseContext, QueryContext
|
|
||||||
from datasette.views.row import RowContext
|
|
||||||
from datasette.views.table import TableContext
|
|
||||||
|
|
||||||
PAGES = {
|
|
||||||
"database": DatabaseContext,
|
|
||||||
"query": QueryContext,
|
|
||||||
"table": TableContext,
|
|
||||||
"row": RowContext,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def documented_context_keys(page_name):
|
|
||||||
"Set of every documented key for the named page, including base context keys"
|
|
||||||
return set(TEMPLATE_BASE_CONTEXT) | {
|
|
||||||
f.name for f in PAGES[page_name].documented_fields()
|
|
||||||
}
|
|
||||||
|
|
@ -15,18 +15,10 @@
|
||||||
<div class="hook"></div>
|
<div class="hook"></div>
|
||||||
<ul role="menu">
|
<ul role="menu">
|
||||||
{% for link in action_links %}
|
{% for link in action_links %}
|
||||||
<li role="none">
|
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||||
{% if link.get("type") == "button" %}
|
|
||||||
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
|
|
||||||
{% if link.description %}
|
{% if link.description %}
|
||||||
<span class="dropdown-description">{{ link.description }}</span>
|
<p class="dropdown-description">{{ link.description }}</p>
|
||||||
{% endif %}</button>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
|
||||||
{% if link.description %}
|
|
||||||
<span class="dropdown-description">{{ link.description }}</span>
|
|
||||||
{% endif %}</a>
|
{% endif %}</a>
|
||||||
{% endif %}
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
|
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
|
||||||
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
|
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
|
||||||
<style>
|
<style>
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
resize: both;
|
resize: both;
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
<script>
|
|
||||||
window.datasetteSqlAnalysis = (() => {
|
|
||||||
if (
|
|
||||||
window.datasetteSqlAnalysis &&
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis
|
|
||||||
) {
|
|
||||||
return window.datasetteSqlAnalysis;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCodeCell(row, value, emptyText) {
|
|
||||||
const cell = document.createElement("td");
|
|
||||||
if (value) {
|
|
||||||
const code = document.createElement("code");
|
|
||||||
code.textContent = value;
|
|
||||||
cell.appendChild(code);
|
|
||||||
} else if (emptyText) {
|
|
||||||
appendNotApplicable(cell);
|
|
||||||
}
|
|
||||||
row.appendChild(cell);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendNotApplicable(cell) {
|
|
||||||
const notApplicable = document.createElement("span");
|
|
||||||
notApplicable.className = "execute-write-analysis-na";
|
|
||||||
notApplicable.textContent = "n/a";
|
|
||||||
cell.appendChild(notApplicable);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAnalysis(section, data) {
|
|
||||||
if (!section) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
section.replaceChildren();
|
|
||||||
if (data.has_sql === false) {
|
|
||||||
section.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
section.hidden = false;
|
|
||||||
|
|
||||||
const heading = document.createElement("h2");
|
|
||||||
heading.textContent = "Query operations";
|
|
||||||
section.appendChild(heading);
|
|
||||||
|
|
||||||
if (data.analysis_error) {
|
|
||||||
const error = document.createElement("p");
|
|
||||||
error.className = "message-error";
|
|
||||||
error.textContent = data.analysis_error;
|
|
||||||
section.appendChild(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = data.analysis_rows || [];
|
|
||||||
if (!rows.length) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.textContent =
|
|
||||||
"Analysis will show each affected table and required permission.";
|
|
||||||
section.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.className = "table-wrapper";
|
|
||||||
const table = document.createElement("table");
|
|
||||||
table.className = "execute-write-analysis";
|
|
||||||
const thead = document.createElement("thead");
|
|
||||||
const headerRow = document.createElement("tr");
|
|
||||||
[
|
|
||||||
"Operation",
|
|
||||||
"Database",
|
|
||||||
"Table",
|
|
||||||
"Required permission",
|
|
||||||
"Allowed",
|
|
||||||
].forEach((label) => {
|
|
||||||
const th = document.createElement("th");
|
|
||||||
th.scope = "col";
|
|
||||||
th.textContent = label;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
});
|
|
||||||
thead.appendChild(headerRow);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
rows.forEach((analysisRow) => {
|
|
||||||
const row = document.createElement("tr");
|
|
||||||
appendCodeCell(row, analysisRow.operation);
|
|
||||||
appendCodeCell(row, analysisRow.database);
|
|
||||||
appendCodeCell(row, analysisRow.table);
|
|
||||||
appendCodeCell(row, analysisRow.required_permission, "n/a");
|
|
||||||
|
|
||||||
const allowedCell = document.createElement("td");
|
|
||||||
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
|
|
||||||
const allowed = document.createElement("span");
|
|
||||||
allowed.className = analysisRow.allowed
|
|
||||||
? "execute-write-analysis-allowed"
|
|
||||||
: "execute-write-analysis-denied";
|
|
||||||
allowed.textContent = analysisRow.allowed ? "yes" : "no";
|
|
||||||
allowedCell.appendChild(allowed);
|
|
||||||
} else {
|
|
||||||
appendNotApplicable(allowedCell);
|
|
||||||
}
|
|
||||||
row.appendChild(allowedCell);
|
|
||||||
tbody.appendChild(row);
|
|
||||||
});
|
|
||||||
table.appendChild(tbody);
|
|
||||||
wrapper.appendChild(table);
|
|
||||||
section.appendChild(wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { renderAnalysis };
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<style>
|
|
||||||
.execute-write-analysis {
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0.25rem 0 1rem;
|
|
||||||
min-width: 44rem;
|
|
||||||
}
|
|
||||||
.execute-write-analysis th,
|
|
||||||
.execute-write-analysis td {
|
|
||||||
border-bottom: 1px solid #d7dde5;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.execute-write-analysis th {
|
|
||||||
background-color: #edf6fb;
|
|
||||||
border-top: 1px solid #d7dde5;
|
|
||||||
color: #39445a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.execute-write-analysis tbody tr:nth-child(even) {
|
|
||||||
background-color: rgba(39, 104, 144, 0.05);
|
|
||||||
}
|
|
||||||
.execute-write-analysis code {
|
|
||||||
background: transparent;
|
|
||||||
font-size: 0.9em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.execute-write-analysis-allowed {
|
|
||||||
color: #267a3e;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.execute-write-analysis-denied {
|
|
||||||
color: #b00020;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.execute-write-analysis-na {
|
|
||||||
color: #687386;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -12,9 +12,9 @@
|
||||||
<ul class="tight-bullets">
|
<ul class="tight-bullets">
|
||||||
{% for facet_value in facet_info.results %}
|
{% for facet_value in facet_info.results %}
|
||||||
{% if not facet_value.selected %}
|
{% if not facet_value.selected %}
|
||||||
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span></li>
|
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>{{ facet_value.label or "-" }} · <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span> <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
<li>{{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if facet_info.truncated %}
|
{% if facet_info.truncated %}
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
<style>
|
|
||||||
.query-create-page {
|
|
||||||
max-width: 64rem;
|
|
||||||
}
|
|
||||||
.query-create-form {
|
|
||||||
--query-create-label-width: clamp(7rem, 18vw, 10rem);
|
|
||||||
--query-create-column-gap: 0.8rem;
|
|
||||||
--query-create-control-width: minmax(16rem, 1fr);
|
|
||||||
}
|
|
||||||
.query-create-fields {
|
|
||||||
margin: 0 0 0.85rem;
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
.query-create-field {
|
|
||||||
align-items: start;
|
|
||||||
column-gap: var(--query-create-column-gap);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
|
||||||
margin: 0 0 0.65rem;
|
|
||||||
}
|
|
||||||
.query-create-field label {
|
|
||||||
padding-top: 0.55rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.query-create-field input[type=text],
|
|
||||||
.query-create-field textarea {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
form.sql .query-create-field textarea {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-create-url-control {
|
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
grid-template-columns: max-content minmax(12rem, 1fr);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-create-url-prefix {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-family: var(--font-monospace, monospace);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.query-create-url-control input[type=text] {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.query-create-url-static {
|
|
||||||
color: #39445a;
|
|
||||||
font-family: var(--font-monospace, monospace);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.query-create-field textarea {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
font-family: Helvetica, sans-serif;
|
|
||||||
font-size: 1em;
|
|
||||||
min-height: 5rem;
|
|
||||||
padding: 9px 4px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
form.sql .query-create-sql {
|
|
||||||
column-gap: var(--query-create-column-gap);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
|
||||||
margin: 0.9rem 0 0.75rem;
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
.query-create-sql .cm-editor,
|
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
|
||||||
grid-column: 2;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-create-options {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.8rem 1.4rem;
|
|
||||||
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
|
||||||
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
|
|
||||||
}
|
|
||||||
.query-create-options label {
|
|
||||||
align-items: center;
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.query-create-options input[type=checkbox] {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.query-create-option-note,
|
|
||||||
.query-create-analysis-note {
|
|
||||||
color: #4f5b6d;
|
|
||||||
flex-basis: 100%;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.query-create-option-note {
|
|
||||||
margin: -0.45rem 0 0;
|
|
||||||
}
|
|
||||||
.query-create-analysis-note {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.query-create-analysis {
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
|
||||||
.query-create-submit {
|
|
||||||
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
|
||||||
margin-bottom: 0.9rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.query-create-form {
|
|
||||||
--query-create-label-width: 1fr;
|
|
||||||
--query-create-column-gap: 0;
|
|
||||||
}
|
|
||||||
.query-create-field {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
row-gap: 0.25rem;
|
|
||||||
}
|
|
||||||
.query-create-field label {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
form.sql .query-create-sql {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.query-create-sql .cm-editor,
|
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
.query-create-options,
|
|
||||||
.query-create-submit {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
{% if display_rows %}
|
|
||||||
<div class="table-wrapper"><table class="rows-and-columns">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in display_rows %}
|
|
||||||
<tr>
|
|
||||||
{% for column, td in zip(columns, row) %}
|
|
||||||
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{% elif show_zero_results %}
|
|
||||||
<p class="zero-results">0 results</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
<script>
|
|
||||||
window.datasetteSqlParameters = (() => {
|
|
||||||
if (
|
|
||||||
window.datasetteSqlParameters &&
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh
|
|
||||||
) {
|
|
||||||
return window.datasetteSqlParameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentSql(form) {
|
|
||||||
if (window.editor) {
|
|
||||||
return window.editor.state.doc.toString();
|
|
||||||
}
|
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
|
|
||||||
return sqlInput ? sqlInput.value : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function controlState(control) {
|
|
||||||
return {
|
|
||||||
value: control.value,
|
|
||||||
expanded: control.tagName.toLowerCase() === "textarea",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncParameterState(manager) {
|
|
||||||
manager.parameterState = new Map();
|
|
||||||
manager.section
|
|
||||||
.querySelectorAll("[data-parameter-control]")
|
|
||||||
.forEach((control) => {
|
|
||||||
manager.parameterState.set(
|
|
||||||
control.dataset.parameterName,
|
|
||||||
controlState(control)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createControl(parameter, id, state, namePrefix) {
|
|
||||||
const control = document.createElement(state.expanded ? "textarea" : "input");
|
|
||||||
control.id = id;
|
|
||||||
control.name = `${namePrefix || ""}${parameter}`;
|
|
||||||
control.value = state.value;
|
|
||||||
control.setAttribute("data-parameter-control", "");
|
|
||||||
control.dataset.parameterName = parameter;
|
|
||||||
if (state.expanded) {
|
|
||||||
control.rows = 5;
|
|
||||||
} else {
|
|
||||||
control.type = "text";
|
|
||||||
}
|
|
||||||
return control;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceParameterControl(
|
|
||||||
manager,
|
|
||||||
control,
|
|
||||||
button,
|
|
||||||
expand,
|
|
||||||
value,
|
|
||||||
selectionStart
|
|
||||||
) {
|
|
||||||
const parameter = control.dataset.parameterName;
|
|
||||||
const replacement = createControl(
|
|
||||||
parameter,
|
|
||||||
control.id,
|
|
||||||
{
|
|
||||||
value: value === undefined ? control.value : value,
|
|
||||||
expanded: expand,
|
|
||||||
},
|
|
||||||
manager.namePrefix
|
|
||||||
);
|
|
||||||
button.textContent = expand ? "Collapse" : "Expand";
|
|
||||||
button.setAttribute("aria-expanded", expand ? "true" : "false");
|
|
||||||
control.replaceWith(replacement);
|
|
||||||
replacement.focus();
|
|
||||||
if (selectionStart !== undefined && replacement.setSelectionRange) {
|
|
||||||
replacement.setSelectionRange(selectionStart, selectionStart);
|
|
||||||
}
|
|
||||||
manager.parameterState.set(parameter, controlState(replacement));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderParameters(manager, parameters) {
|
|
||||||
syncParameterState(manager);
|
|
||||||
const previousState = manager.parameterState;
|
|
||||||
const nextState = new Map();
|
|
||||||
manager.section.replaceChildren();
|
|
||||||
if (!parameters.length) {
|
|
||||||
manager.parameterState = nextState;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = document.createElement("h2");
|
|
||||||
heading.textContent = "Parameters";
|
|
||||||
manager.section.appendChild(heading);
|
|
||||||
|
|
||||||
parameters.forEach((parameter, index) => {
|
|
||||||
const id = `qp${index + 1}`;
|
|
||||||
const state = previousState.get(parameter) || {
|
|
||||||
value: "",
|
|
||||||
expanded: false,
|
|
||||||
};
|
|
||||||
if (!manager.allowExpand) {
|
|
||||||
state.expanded = false;
|
|
||||||
}
|
|
||||||
nextState.set(parameter, state);
|
|
||||||
|
|
||||||
const row = document.createElement("p");
|
|
||||||
row.className = "sql-parameter-row";
|
|
||||||
|
|
||||||
const label = document.createElement("label");
|
|
||||||
label.htmlFor = id;
|
|
||||||
label.textContent = parameter;
|
|
||||||
|
|
||||||
const control = createControl(parameter, id, state, manager.namePrefix);
|
|
||||||
|
|
||||||
row.append(label, control);
|
|
||||||
if (manager.allowExpand) {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "sql-parameter-toggle";
|
|
||||||
button.setAttribute("data-parameter-toggle", "");
|
|
||||||
button.setAttribute("aria-controls", id);
|
|
||||||
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
|
|
||||||
button.textContent = state.expanded ? "Collapse" : "Expand";
|
|
||||||
row.append(" ", button);
|
|
||||||
}
|
|
||||||
manager.section.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.parameterState = nextState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindParameterControls(manager) {
|
|
||||||
manager.form.addEventListener("input", (event) => {
|
|
||||||
const control = event.target;
|
|
||||||
if (!control.matches || !control.matches("[data-parameter-control]")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
manager.parameterState.set(
|
|
||||||
control.dataset.parameterName,
|
|
||||||
controlState(control)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!manager.allowExpand) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.form.addEventListener("click", (event) => {
|
|
||||||
const button = event.target.closest
|
|
||||||
? event.target.closest("[data-parameter-toggle]")
|
|
||||||
: null;
|
|
||||||
if (!button || !manager.form.contains(button)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const control = document.getElementById(button.getAttribute("aria-controls"));
|
|
||||||
if (!control) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const expanded = control.tagName.toLowerCase() === "textarea";
|
|
||||||
replaceParameterControl(manager, control, button, !expanded);
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.form.addEventListener("paste", (event) => {
|
|
||||||
const control = event.target;
|
|
||||||
if (
|
|
||||||
!(control instanceof HTMLInputElement) ||
|
|
||||||
!control.matches("[data-parameter-control]")
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
|
|
||||||
if (!/[\r\n]/.test(pasted)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const button = document.querySelector(
|
|
||||||
`[data-parameter-toggle][aria-controls="${control.id}"]`
|
|
||||||
);
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
const selectionStart = control.selectionStart ?? control.value.length;
|
|
||||||
const selectionEnd = control.selectionEnd ?? selectionStart;
|
|
||||||
const value =
|
|
||||||
control.value.slice(0, selectionStart) +
|
|
||||||
pasted +
|
|
||||||
control.value.slice(selectionEnd);
|
|
||||||
replaceParameterControl(
|
|
||||||
manager,
|
|
||||||
control,
|
|
||||||
button,
|
|
||||||
true,
|
|
||||||
value,
|
|
||||||
selectionStart + pasted.length
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEditorChanges(form, callback) {
|
|
||||||
const editorElement = form.querySelector(".cm-content");
|
|
||||||
if (editorElement) {
|
|
||||||
editorElement.addEventListener("input", callback);
|
|
||||||
}
|
|
||||||
if (!window.editor) {
|
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor");
|
|
||||||
if (sqlInput) {
|
|
||||||
sqlInput.addEventListener("input", callback);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.editor.datasetteSqlParameterCallbacks) {
|
|
||||||
const editor = window.editor;
|
|
||||||
const originalDispatch = editor.dispatch.bind(editor);
|
|
||||||
editor.datasetteSqlParameterCallbacks = [];
|
|
||||||
editor.dispatch = (...transactions) => {
|
|
||||||
const before = editor.state.doc.toString();
|
|
||||||
originalDispatch(...transactions);
|
|
||||||
if (editor.state.doc.toString() !== before) {
|
|
||||||
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
window.editor.datasetteSqlParameterCallbacks.push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSqlParameterRefresh(options) {
|
|
||||||
const form =
|
|
||||||
options.form || document.querySelector("form.sql.core[data-parameters-url]");
|
|
||||||
if (!form) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const shouldRenderParameters = options.renderParameters !== false;
|
|
||||||
const section =
|
|
||||||
options.section || form.querySelector("[data-sql-parameters-section]");
|
|
||||||
if (shouldRenderParameters && !section) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const manager = {
|
|
||||||
form,
|
|
||||||
section,
|
|
||||||
allowExpand:
|
|
||||||
options.allowExpand === undefined
|
|
||||||
? section
|
|
||||||
? section.dataset.allowExpand === "1"
|
|
||||||
: false
|
|
||||||
: options.allowExpand,
|
|
||||||
namePrefix: section ? section.dataset.parameterNamePrefix || "" : "",
|
|
||||||
parameterState: new Map(),
|
|
||||||
};
|
|
||||||
if (section) {
|
|
||||||
bindParameterControls(manager);
|
|
||||||
syncParameterState(manager);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = options.url || form.dataset.parametersUrl;
|
|
||||||
let refreshTimer = null;
|
|
||||||
let refreshSequence = 0;
|
|
||||||
|
|
||||||
async function refreshParameters() {
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sequence = ++refreshSequence;
|
|
||||||
try {
|
|
||||||
const requestUrl = new URL(url, window.location.href);
|
|
||||||
requestUrl.searchParams.set("sql", currentSql(form));
|
|
||||||
const response = await fetch(requestUrl, {
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (sequence !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error((data.errors || [response.statusText]).join("; "));
|
|
||||||
}
|
|
||||||
if (shouldRenderParameters) {
|
|
||||||
renderParameters(manager, data.parameters || []);
|
|
||||||
}
|
|
||||||
if (options.onData) {
|
|
||||||
options.onData(data, manager);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (sequence !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.onError) {
|
|
||||||
options.onError(error, manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRefresh() {
|
|
||||||
clearTimeout(refreshTimer);
|
|
||||||
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
|
|
||||||
}
|
|
||||||
|
|
||||||
bindEditorChanges(form, scheduleRefresh);
|
|
||||||
return {
|
|
||||||
currentSql: () => currentSql(form),
|
|
||||||
refreshParameters,
|
|
||||||
renderParameters: (parameters) => renderParameters(manager, parameters),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { setupSqlParameterRefresh };
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<style>
|
|
||||||
form.sql .sql-editor {
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
form.sql .sql-editor textarea#sql-editor {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
form.sql .sql-parameters-section {
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
form.sql .sql-parameter-row {
|
|
||||||
align-items: start;
|
|
||||||
column-gap: 0.6rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
|
|
||||||
margin: 0 0 0.65rem;
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
form.sql .sql-parameter-row label {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
padding-top: 0.55rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
form.sql .sql-parameter-row input[data-parameter-control],
|
|
||||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
font-family: Helvetica, sans-serif;
|
|
||||||
font-size: 1em;
|
|
||||||
min-height: 7rem;
|
|
||||||
padding: 9px 4px;
|
|
||||||
}
|
|
||||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
height: 1.8rem;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
padding: 0.25rem 0.45rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
form.sql .sql-parameter-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
row-gap: 0.25rem;
|
|
||||||
}
|
|
||||||
form.sql .sql-parameter-row label {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
|
||||||
justify-self: start;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %}
|
|
||||||
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameter_name_prefix %} data-parameter-name-prefix="{{ sql_parameter_name_prefix }}"{% endif %}{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
|
|
||||||
{% if parameter_names %}
|
|
||||||
<h2>Parameters</h2>
|
|
||||||
{% for parameter in parameter_names %}
|
|
||||||
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
|
|
||||||
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ sql_parameter_name_prefix }}{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control data-parameter-name="{{ parameter }}">{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in display_rows %}
|
{% for row in display_rows %}
|
||||||
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}>
|
<tr>
|
||||||
{% for cell in row %}
|
{% for cell in row %}
|
||||||
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
|
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}API Explorer{% endblock %}
|
{% block title %}API Explorer{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
</p>
|
</p>
|
||||||
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
|
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
|
||||||
<summary style="cursor: pointer;">GET</summary>
|
<summary style="cursor: pointer;">GET</summary>
|
||||||
<form class="core" method="get" action="{{ urls.path('-/api') }}" id="api-explorer-get" style="margin-top: 0.7em">
|
<form class="core" method="get" id="api-explorer-get" style="margin-top: 0.7em">
|
||||||
<div>
|
<div>
|
||||||
<label for="path">API path:</label>
|
<label for="path">API path:</label>
|
||||||
<input type="text" id="path" name="path" style="width: 60%">
|
<input type="text" id="path" name="path" style="width: 60%">
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
</details>
|
</details>
|
||||||
<details style="border: 2px solid #ccc; padding: 0.5em">
|
<details style="border: 2px solid #ccc; padding: 0.5em">
|
||||||
<summary style="cursor: pointer">POST</summary>
|
<summary style="cursor: pointer">POST</summary>
|
||||||
<form class="core" method="post" action="{{ urls.path('-/api') }}" id="api-explorer-post" style="margin-top: 0.7em">
|
<form class="core" method="post" id="api-explorer-post" style="margin-top: 0.7em">
|
||||||
<div>
|
<div>
|
||||||
<label for="path">API path:</label>
|
<label for="path">API path:</label>
|
||||||
<input type="text" id="path" name="path" style="width: 60%">
|
<input type="text" id="path" name="path" style="width: 60%">
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ static('app.css') }}">
|
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
{% for url in extra_css_urls %}
|
{% for url in extra_css_urls %}
|
||||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||||
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
||||||
{% for url in extra_js_urls %}
|
{% for url in extra_js_urls %}
|
||||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||||
<script src="{{ static('navigation-search.js') }}" defer></script>
|
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||||
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
|
<navigation-search url="/-/jump"></navigation-search>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,6 @@
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
{% include "_codemirror.html" %}
|
{% include "_codemirror.html" %}
|
||||||
{% include "_sql_parameter_styles.html" %}
|
|
||||||
{% if database_page_data.createTable %}
|
|
||||||
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
|
|
||||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||||
|
|
@ -30,13 +25,9 @@
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
{% if allow_execute_sql %}
|
{% if allow_execute_sql %}
|
||||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
|
||||||
<h3>Custom SQL query</h3>
|
<h3>Custom SQL query</h3>
|
||||||
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||||
{% set parameter_names = [] %}
|
|
||||||
{% set parameter_values = {} %}
|
|
||||||
{% set sql_parameters_allow_expand = false %}
|
|
||||||
{% include "_sql_parameters.html" %}
|
|
||||||
<p>
|
<p>
|
||||||
<button id="sql-format" type="button" hidden>Format SQL</button>
|
<button id="sql-format" type="button" hidden>Format SQL</button>
|
||||||
<input type="submit" value="Run SQL">
|
<input type="submit" value="Run SQL">
|
||||||
|
|
@ -62,9 +53,6 @@
|
||||||
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if queries_more %}
|
|
||||||
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if tables %}
|
{% if tables %}
|
||||||
|
|
@ -76,7 +64,7 @@
|
||||||
<div class="db-table">
|
<div class="db-table">
|
||||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||||
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}>{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -99,11 +87,5 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
{% include "_codemirror_foot.html" %}
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% include "_permissions_debug_tabs.html" %}
|
||||||
|
|
||||||
<p style="margin-bottom: 2em;">
|
<p style="margin-bottom: 2em;">
|
||||||
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
|
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
|
||||||
Actions are used by the permission system to control access to different features.
|
Actions are used by the permission system to control access to different features.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for action in data.actions %}
|
{% for action in data %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ action.name }}</strong></td>
|
<td><strong>{{ action.name }}</strong></td>
|
||||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}Allowed Resources{% endblock %}
|
{% block title %}Allowed Resources{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
|
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
||||||
(function() {
|
(function() {
|
||||||
const params = populateFormFromURL();
|
const params = populateFormFromURL();
|
||||||
const action = params.get('action');
|
const action = params.get('action');
|
||||||
const page = params.get('_page');
|
const page = params.get('page');
|
||||||
if (action) {
|
if (action) {
|
||||||
fetchResults(page ? parseInt(page) : 1);
|
fetchResults(page ? parseInt(page) : 1);
|
||||||
}
|
}
|
||||||
|
|
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
if (value && key !== '_size' && key !== '_page') {
|
if (value && key !== 'page_size') {
|
||||||
params.append(key, value);
|
params.append(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = document.getElementById('page_size').value || '50';
|
const pageSize = document.getElementById('page_size').value || '50';
|
||||||
params.append('_page', page.toString());
|
params.append('page', page.toString());
|
||||||
params.append('_size', pageSize);
|
params.append('page_size', pageSize);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Debug autocomplete{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{ super() }}
|
|
||||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>Debug autocomplete</h1>
|
|
||||||
|
|
||||||
<form class="core debug-autocomplete-form" action="{{ urls.path('-/debug/autocomplete') }}" method="get">
|
|
||||||
<p>
|
|
||||||
<label for="debug-autocomplete-database">Database</label>
|
|
||||||
<input id="debug-autocomplete-database" type="text" name="database" value="{{ database_name or "" }}">
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="debug-autocomplete-table">Table</label>
|
|
||||||
<input id="debug-autocomplete-table" type="text" name="table" value="{{ table_name or "" }}">
|
|
||||||
</p>
|
|
||||||
<p><input type="submit" value="Open autocomplete"></p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<p class="message-error">{{ error }}</p>
|
|
||||||
{% elif autocomplete_url %}
|
|
||||||
<h2>{{ database_name }} / {{ table_name }}</h2>
|
|
||||||
{% if label_column %}
|
|
||||||
<p>Label column: <code>{{ label_column }}</code></p>
|
|
||||||
{% else %}
|
|
||||||
<p>No label column detected. Results will use primary key values.</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="debug-autocomplete-demo">
|
|
||||||
<label for="debug-autocomplete-input">Search rows</label>
|
|
||||||
<datasette-autocomplete src="{{ autocomplete_url }}">
|
|
||||||
<input id="debug-autocomplete-input" type="text">
|
|
||||||
</datasette-autocomplete>
|
|
||||||
</div>
|
|
||||||
<h3>Selected row</h3>
|
|
||||||
<pre class="debug-autocomplete-selected" aria-live="polite">No row selected.</pre>
|
|
||||||
<script>
|
|
||||||
document.addEventListener("datasette-autocomplete-select", function (event) {
|
|
||||||
var output = document.querySelector(".debug-autocomplete-selected");
|
|
||||||
if (output) {
|
|
||||||
output.textContent = JSON.stringify(event.detail.row, null, 2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% else %}
|
|
||||||
<h2>Suggested tables</h2>
|
|
||||||
{% if suggestions %}
|
|
||||||
<p>Showing up to five tables with a detected label column.</p>
|
|
||||||
<table class="rows-and-columns">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Database</th>
|
|
||||||
<th>Table</th>
|
|
||||||
<th>Label column</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for suggestion in suggestions %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ suggestion.database }}</td>
|
|
||||||
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
|
|
||||||
<td><code>{{ suggestion.label_column }}</code></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p>No tables with detected label columns found.</p>
|
|
||||||
{% endif %}
|
|
||||||
<p>Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}Permission Check{% endblock %}
|
{% block title %}Permission Check{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}Permission Rules{% endblock %}
|
{% block title %}Permission Rules{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
|
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
|
||||||
(function() {
|
(function() {
|
||||||
const params = populateFormFromURL();
|
const params = populateFormFromURL();
|
||||||
const action = params.get('action');
|
const action = params.get('action');
|
||||||
const page = params.get('_page');
|
const page = params.get('page');
|
||||||
if (action) {
|
if (action) {
|
||||||
fetchResults(page ? parseInt(page) : 1);
|
fetchResults(page ? parseInt(page) : 1);
|
||||||
}
|
}
|
||||||
|
|
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
if (value && key !== '_size' && key !== '_page') {
|
if (value && key !== 'page_size') {
|
||||||
params.append(key, value);
|
params.append(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = document.getElementById('page_size').value || '50';
|
const pageSize = document.getElementById('page_size').value || '50';
|
||||||
params.append('_page', page.toString());
|
params.append('page', page.toString());
|
||||||
params.append('_size', pageSize);
|
params.append('page_size', pageSize);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||||
|
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Write to this database{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{- super() -}}
|
|
||||||
{% include "_codemirror.html" %}
|
|
||||||
<style>
|
|
||||||
.execute-write-template-menu {
|
|
||||||
margin: 0.9rem 0 0.8rem;
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
.execute-write-template-menu summary {
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.execute-write-template-controls {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin: 0.4rem 0 0.7rem;
|
|
||||||
}
|
|
||||||
.execute-write-template-menu .execute-write-template-controls label {
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.execute-write-template-controls select,
|
|
||||||
.execute-write-template-controls button[type=button] {
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
height: 2rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 0.35rem 0.55rem;
|
|
||||||
}
|
|
||||||
.execute-write-template-controls select {
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #777;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
min-width: 13rem;
|
|
||||||
}
|
|
||||||
.execute-write-submit-row {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem 0.75rem;
|
|
||||||
}
|
|
||||||
.execute-write-submit-row [hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
form.sql.core input[data-execute-write-submit]:disabled {
|
|
||||||
background: #d0d7de;
|
|
||||||
border-color: #b6c0cc;
|
|
||||||
color: #5f6975;
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.execute-write form.sql .sql-editor-min-lines .cm-content,
|
|
||||||
.execute-write form.sql .sql-editor-min-lines .cm-gutter {
|
|
||||||
/* Four visible editor lines without adding blank lines to the SQL value. */
|
|
||||||
min-height: calc(5.6em + 8px);
|
|
||||||
}
|
|
||||||
.execute-write-disabled-reason {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% include "_execute_write_analysis_styles.html" %}
|
|
||||||
{% include "_sql_parameter_styles.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Write to this database</h1>
|
|
||||||
|
|
||||||
<p>Execute SQL to insert, update or delete rows in this database.</p>
|
|
||||||
|
|
||||||
{% if execution_message %}
|
|
||||||
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if execute_write_returns_rows %}
|
|
||||||
<h2>Returned rows</h2>
|
|
||||||
{% if execute_write_truncated %}
|
|
||||||
<p class="message-warning">Only the first {{ "{:,}".format(execute_write_display_rows|length) }} returned rows are shown.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% set columns = execute_write_columns %}
|
|
||||||
{% set display_rows = execute_write_display_rows %}
|
|
||||||
{% set show_zero_results = true %}
|
|
||||||
{% include "_query_results.html" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
|
|
||||||
{% if write_create_table_template_sql or write_template_tables %}
|
|
||||||
<div class="execute-write-template-menu">
|
|
||||||
<details>
|
|
||||||
<summary>Start with a template</summary>
|
|
||||||
<p class="execute-write-template-controls">
|
|
||||||
{% if write_create_table_template_sql %}
|
|
||||||
<button type="button" data-sql-template="create" data-template-sql="{{ write_create_table_template_sql }}">Create table</button>
|
|
||||||
{% endif %}
|
|
||||||
{% if write_template_tables %}
|
|
||||||
<label for="execute-write-template-table">{% if write_create_table_template_sql %}or table:{% else %}Table{% endif %}</label>
|
|
||||||
<select id="execute-write-template-table">
|
|
||||||
{% for table_name, table in write_template_tables|dictsort %}
|
|
||||||
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% for operation in write_template_operations %}
|
|
||||||
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
|
||||||
|
|
||||||
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
|
||||||
{% set sql_parameters_allow_expand = true %}
|
|
||||||
{% include "_sql_parameters.html" %}
|
|
||||||
|
|
||||||
<div id="execute-write-analysis-section">
|
|
||||||
<h2>Query operations</h2>
|
|
||||||
{% if analysis_error %}
|
|
||||||
<p class="message-error">{{ analysis_error }}</p>
|
|
||||||
{% elif analysis_rows %}
|
|
||||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Operation</th>
|
|
||||||
<th scope="col">Database</th>
|
|
||||||
<th scope="col">Table</th>
|
|
||||||
<th scope="col">Required permission</th>
|
|
||||||
<th scope="col">Allowed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in analysis_rows %}
|
|
||||||
<tr>
|
|
||||||
<td><code>{{ row.operation }}</code></td>
|
|
||||||
<td><code>{{ row.database }}</code></td>
|
|
||||||
<td><code>{{ row.table }}</code></td>
|
|
||||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
|
|
||||||
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{% else %}
|
|
||||||
<p>Analysis will show each affected table and required permission.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="execute-write-submit-row"{% if save_query_base_url %} data-save-query-base-url="{{ save_query_base_url }}"{% endif %}>
|
|
||||||
<input type="submit" value="Execute" data-execute-write-submit aria-describedby="execute-write-disabled-reason"{% if execute_disabled %} disabled{% endif %}>
|
|
||||||
<span id="execute-write-disabled-reason" class="execute-write-disabled-reason" data-execute-write-disabled-reason aria-live="polite"{% if not execute_disabled_reason %} hidden{% endif %}>{{ execute_disabled_reason or "" }}</span>
|
|
||||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query" data-save-query-link>Save this query</a>{% endif %}
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
{% include "_execute_write_analysis_scripts.html" %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
|
||||||
const form = document.querySelector("form.sql.core");
|
|
||||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
|
||||||
const submitButton = form
|
|
||||||
? form.querySelector("[data-execute-write-submit]")
|
|
||||||
: null;
|
|
||||||
const submitDisabledReason = form
|
|
||||||
? form.querySelector("[data-execute-write-disabled-reason]")
|
|
||||||
: null;
|
|
||||||
const submitRow = form
|
|
||||||
? form.querySelector(".execute-write-submit-row")
|
|
||||||
: null;
|
|
||||||
let saveQueryLink = form
|
|
||||||
? form.querySelector("[data-save-query-link]")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
function updateSubmitState(data) {
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = data.execute_disabled;
|
|
||||||
}
|
|
||||||
if (!submitDisabledReason) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reason = data.execute_disabled_reason || "";
|
|
||||||
submitDisabledReason.textContent = reason;
|
|
||||||
submitDisabledReason.hidden = !reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSaveQueryLink(data) {
|
|
||||||
if (!submitRow || !submitRow.dataset.saveQueryBaseUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sql = window.editor
|
|
||||||
? window.editor.state.doc.toString()
|
|
||||||
: executeWriteSqlInput.value;
|
|
||||||
if (!sql.trim() || !data.ok || data.execute_disabled) {
|
|
||||||
if (saveQueryLink) {
|
|
||||||
saveQueryLink.remove();
|
|
||||||
saveQueryLink = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!saveQueryLink) {
|
|
||||||
saveQueryLink = document.createElement("a");
|
|
||||||
saveQueryLink.className = "save-query";
|
|
||||||
saveQueryLink.setAttribute("data-save-query-link", "");
|
|
||||||
saveQueryLink.textContent = "Save this query";
|
|
||||||
submitRow.appendChild(saveQueryLink);
|
|
||||||
}
|
|
||||||
const url = new URL(
|
|
||||||
submitRow.dataset.saveQueryBaseUrl,
|
|
||||||
window.location.href
|
|
||||||
);
|
|
||||||
url.searchParams.set("sql", sql);
|
|
||||||
saveQueryLink.href = url.pathname + url.search + url.hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
|
||||||
form,
|
|
||||||
url: form.dataset.analyzeUrl,
|
|
||||||
allowExpand: true,
|
|
||||||
onData(data) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
|
||||||
updateSubmitState(data);
|
|
||||||
updateSaveQueryLink(data);
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
|
||||||
analysis_error: error.message,
|
|
||||||
analysis_rows: [],
|
|
||||||
});
|
|
||||||
updateSubmitState({
|
|
||||||
execute_disabled: true,
|
|
||||||
execute_disabled_reason: error.message,
|
|
||||||
});
|
|
||||||
updateSaveQueryLink({ ok: false, execute_disabled: true });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% if write_create_table_template_sql or write_template_tables %}
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const tableSelect = document.querySelector("#execute-write-template-table");
|
|
||||||
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
|
||||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
|
||||||
|
|
||||||
function dataKey(operation) {
|
|
||||||
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedOption() {
|
|
||||||
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateSql(button) {
|
|
||||||
if (button.dataset.templateSql) {
|
|
||||||
return button.dataset.templateSql;
|
|
||||||
}
|
|
||||||
const operation = button.dataset.sqlTemplate;
|
|
||||||
const option = selectedOption();
|
|
||||||
return option ? option.dataset[dataKey(operation)] || "" : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTemplateButtons() {
|
|
||||||
templateButtons.forEach((button) => {
|
|
||||||
button.hidden = !templateSql(button);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSqlUrl(sql) {
|
|
||||||
if (!window.history || !window.history.replaceState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set("sql", sql);
|
|
||||||
window.history.replaceState(null, "", url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEditorSql(sql) {
|
|
||||||
if (window.editor) {
|
|
||||||
window.editor.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: window.editor.state.doc.length,
|
|
||||||
insert: sql,
|
|
||||||
},
|
|
||||||
selection: { anchor: sql.length },
|
|
||||||
});
|
|
||||||
window.editor.focus();
|
|
||||||
if (sqlInput) {
|
|
||||||
sqlInput.value = sql;
|
|
||||||
}
|
|
||||||
} else if (sqlInput) {
|
|
||||||
sqlInput.value = sql;
|
|
||||||
sqlInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
sqlInput.focus();
|
|
||||||
}
|
|
||||||
updateSqlUrl(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
templateButtons.forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const sql = templateSql(button);
|
|
||||||
if (!sql) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEditorSql(sql);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (tableSelect) {
|
|
||||||
tableSelect.addEventListener("change", updateTemplateButtons);
|
|
||||||
}
|
|
||||||
updateTemplateButtons();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Datasette: Pattern Portfolio</title>
|
<title>Datasette: Pattern Portfolio</title>
|
||||||
<link rel="stylesheet" href="{{ static('app.css') }}">
|
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
<header class="hd"><nav>
|
<header class="hd"><nav>
|
||||||
<p class="crumbs">
|
<p class="crumbs">
|
||||||
<a href="{{ base_url }}">home</a>
|
<a href="/">home</a>
|
||||||
</p>
|
</p>
|
||||||
<details class="nav-menu details-menu">
|
<details class="nav-menu details-menu">
|
||||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||||
|
|
@ -22,11 +22,11 @@
|
||||||
</svg></summary>
|
</svg></summary>
|
||||||
<div class="nav-menu-inner">
|
<div class="nav-menu-inner">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ base_url }}-/databases">Databases</a></li>
|
<li><a href="/-/databases">Databases</a></li>
|
||||||
<li><a href="{{ base_url }}-/plugins">Installed plugins</a></li>
|
<li><a href="/-/plugins">Installed plugins</a></li>
|
||||||
<li><a href="{{ base_url }}-/versions">Version info</a></li>
|
<li><a href="/-/versions">Version info</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<form class="nav-menu-logout" action="{{ base_url }}-/logout" method="post">
|
<form class="nav-menu-logout" action="/-/logout" method="post">
|
||||||
<button class="button-as-link">Log out</button>
|
<button class="button-as-link">Log out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -48,9 +48,9 @@
|
||||||
<header class="hd">
|
<header class="hd">
|
||||||
<nav>
|
<nav>
|
||||||
<p class="crumbs">
|
<p class="crumbs">
|
||||||
<a href="{{ base_url }}">home</a> /
|
<a href="/">home</a> /
|
||||||
<a href="{{ base_url }}fixtures">fixtures</a> /
|
<a href="/fixtures">fixtures</a> /
|
||||||
<a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a>
|
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
|
||||||
</p>
|
</p>
|
||||||
<div class="actor">
|
<div class="actor">
|
||||||
<strong>testuser</strong>
|
<strong>testuser</strong>
|
||||||
|
|
@ -80,16 +80,16 @@
|
||||||
<a href="https://github.com/simonw/datasette">
|
<a href="https://github.com/simonw/datasette">
|
||||||
About Datasette</a>
|
About Datasette</a>
|
||||||
</p>
|
</p>
|
||||||
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="{{ base_url }}fixtures">fixtures</a></h2>
|
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="/fixtures">fixtures</a></h2>
|
||||||
<p>
|
<p>
|
||||||
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
|
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
|
||||||
</p>
|
</p>
|
||||||
<p><a href="{{ base_url }}fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="{{ base_url }}fixtures/sortable" title="201 rows">sortable</a>, <a href="{{ base_url }}fixtures/facetable" title="15 rows">facetable</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="{{ base_url }}fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="{{ base_url }}fixtures">...</a></p>
|
<p><a href="/fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="/fixtures/sortable" title="201 rows">sortable</a>, <a href="/fixtures/facetable" title="15 rows">facetable</a>, <a href="/fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="/fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="/fixtures">...</a></p>
|
||||||
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="{{ base_url }}data">data</a></h2>
|
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="/data">data</a></h2>
|
||||||
<p>
|
<p>
|
||||||
6 rows in 2 tables
|
6 rows in 2 tables
|
||||||
</p>
|
</p>
|
||||||
<p><a href="{{ base_url }}data/names" title="6 rows">names</a>, <a href="{{ base_url }}data/foo">foo</a></p>
|
<p><a href="/data/names" title="6 rows">names</a>, <a href="/data/foo">foo</a></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h2 class="pattern-heading">.bd for /database</h2>
|
<h2 class="pattern-heading">.bd for /database</h2>
|
||||||
|
|
@ -134,7 +134,7 @@
|
||||||
<a href="https://github.com/simonw/datasette">
|
<a href="https://github.com/simonw/datasette">
|
||||||
About Datasette</a>
|
About Datasette</a>
|
||||||
</p>
|
</p>
|
||||||
<form class="sql" action="{{ base_url }}fixtures" method="get">
|
<form class="sql" action="/fixtures" method="get">
|
||||||
<h3>Custom SQL query</h3>
|
<h3>Custom SQL query</h3>
|
||||||
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
|
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -143,17 +143,17 @@
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<div class="db-table">
|
<div class="db-table">
|
||||||
<h2><a href="{{ base_url }}fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
|
<h2><a href="/fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
|
||||||
<p><em>content</em></p>
|
<p><em>content</em></p>
|
||||||
<p>0 rows</p>
|
<p>0 rows</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="db-table">
|
<div class="db-table">
|
||||||
<h2><a href="{{ base_url }}fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
|
<h2><a href="/fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
|
||||||
<p><em>pk, content</em></p>
|
<p><em>pk, content</em></p>
|
||||||
<p>0 rows</p>
|
<p>0 rows</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="db-table">
|
<div class="db-table">
|
||||||
<h2><a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a></h2>
|
<h2><a href="/fixtures/attraction_characteristic">attraction_characteristic</a></h2>
|
||||||
<p><em>pk, name</em></p>
|
<p><em>pk, name</em></p>
|
||||||
<p>2 rows</p>
|
<p>2 rows</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,9 +202,9 @@
|
||||||
<h3>3 rows
|
<h3>3 rows
|
||||||
where characteristic_id = 2
|
where characteristic_id = 2
|
||||||
</h3>
|
</h3>
|
||||||
<form class="core filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
<form class="filters" action="/fixtures/roadside_attraction_characteristics" method="get">
|
||||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
||||||
<div class="filter-row filter-controls-row">
|
<div class="filter-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column_1">
|
<select name="_filter_column_1">
|
||||||
<option value="">- remove filter -</option>
|
<option value="">- remove filter -</option>
|
||||||
|
|
@ -238,7 +238,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
|
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row filter-controls-row">
|
<div class="filter-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column">
|
<select name="_filter_column">
|
||||||
<option value="">- column -</option>
|
<option value="">- column -</option>
|
||||||
|
|
@ -272,8 +272,8 @@
|
||||||
</select>
|
</select>
|
||||||
</div><input type="text" name="_filter_value" class="filter-value">
|
</div><input type="text" name="_filter_value" class="filter-value">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row filter-actions-row">
|
<div class="filter-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper small-screen-only">
|
||||||
<select name="_sort" id="sort_by">
|
<select name="_sort" id="sort_by">
|
||||||
<option value="">Sort...</option>
|
<option value="">Sort...</option>
|
||||||
<option value="rowid" selected>Sort by rowid</option>
|
<option value="rowid" selected>Sort by rowid</option>
|
||||||
|
|
@ -281,8 +281,8 @@
|
||||||
<option value="characteristic_id">Sort by characteristic_id</option>
|
<option value="characteristic_id">Sort by characteristic_id</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
||||||
<input type="submit" value="Apply filters">
|
<input type="submit" value="Apply">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -290,16 +290,16 @@
|
||||||
<h3>2 extra where clauses</h3>
|
<h3>2 extra where clauses</h3>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
||||||
<li><code>planet_int=1</code> [<a href="{{ base_url }}fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
|
<li><code>planet_int=1</code> [<a href="/fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
|
||||||
|
|
||||||
<li><code>state='CA'</code> [<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
|
<li><code>state='CA'</code> [<a href="/fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where "characteristic_id" = :p0 order by rowid limit 101" href="{{ base_url }}fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&p0=2">✎ <span class="underlined">View and edit SQL</span></a></p>
|
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where "characteristic_id" = :p0 order by rowid limit 101" href="/fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&p0=2">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||||
|
|
||||||
<p class="export-links">This data as <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">json</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&_labels=on&_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
|
<p class="export-links">This data as <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">json</a>, <a href="/fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&_labels=on&_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
|
||||||
|
|
||||||
<p class="suggested-facets">
|
<p class="suggested-facets">
|
||||||
Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_array=tags#facet-tags">tags</a> (array)
|
Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_array=tags#facet-tags">tags</a> (array)
|
||||||
|
|
@ -311,7 +311,7 @@
|
||||||
<p class="facet-info-name">
|
<p class="facet-info-name">
|
||||||
<strong>tags (array)</strong>
|
<strong>tags (array)</strong>
|
||||||
|
|
||||||
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created" class="cross">✖</a>
|
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created" class="cross">✖</a>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -336,7 +336,7 @@
|
||||||
<p class="facet-info-name">
|
<p class="facet-info-name">
|
||||||
<strong>created</strong>
|
<strong>created</strong>
|
||||||
|
|
||||||
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet_array=tags" class="cross">✖</a>
|
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet_array=tags" class="cross">✖</a>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -361,7 +361,7 @@
|
||||||
<p class="facet-info-name">
|
<p class="facet-info-name">
|
||||||
<strong>city_id</strong>
|
<strong>city_id</strong>
|
||||||
|
|
||||||
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=created&_facet_array=tags" class="cross">✖</a>
|
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=created&_facet_array=tags" class="cross">✖</a>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -387,45 +387,45 @@
|
||||||
Link
|
Link
|
||||||
</th>
|
</th>
|
||||||
<th class="col-rowid" scope="col">
|
<th class="col-rowid" scope="col">
|
||||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort_desc=rowid" rel="nofollow">rowid ▼</a>
|
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort_desc=rowid" rel="nofollow">rowid ▼</a>
|
||||||
</th>
|
</th>
|
||||||
<th class="col-attraction_id" scope="col">
|
<th class="col-attraction_id" scope="col">
|
||||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=attraction_id" rel="nofollow">attraction_id</a>
|
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=attraction_id" rel="nofollow">attraction_id</a>
|
||||||
</th>
|
</th>
|
||||||
<th class="col-characteristic_id" scope="col">
|
<th class="col-characteristic_id" scope="col">
|
||||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=characteristic_id" rel="nofollow">characteristic_id</a>
|
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=characteristic_id" rel="nofollow">characteristic_id</a>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/1">1</a></td>
|
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/1">1</a></td>
|
||||||
<td class="col-rowid">1</td>
|
<td class="col-rowid">1</td>
|
||||||
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/1">The Mystery Spot</a> <em>1</em></td>
|
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/1">The Mystery Spot</a> <em>1</em></td>
|
||||||
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/2">2</a></td>
|
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/2">2</a></td>
|
||||||
<td class="col-rowid">2</td>
|
<td class="col-rowid">2</td>
|
||||||
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/2">Winchester Mystery House</a> <em>2</em></td>
|
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/2">Winchester Mystery House</a> <em>2</em></td>
|
||||||
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/3">3</a></td>
|
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/3">3</a></td>
|
||||||
<td class="col-rowid">3</td>
|
<td class="col-rowid">3</td>
|
||||||
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a> <em>4</em></td>
|
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a> <em>4</em></td>
|
||||||
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div id="export" class="advanced-export">
|
<div id="export" class="advanced-export">
|
||||||
<h3>Advanced export</h3>
|
<h3>Advanced export</h3>
|
||||||
<p>JSON shape:
|
<p>JSON shape:
|
||||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">default</a>,
|
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">default</a>,
|
||||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array">array</a>,
|
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array">array</a>,
|
||||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array&_nl=on">newline-delimited</a>
|
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array&_nl=on">newline-delimited</a>
|
||||||
</p>
|
</p>
|
||||||
<form action="{{ base_url }}fixtures/roadside_attraction_characteristics.csv" method="get">
|
<form action="/fixtures/roadside_attraction_characteristics.csv" method="get">
|
||||||
<p>
|
<p>
|
||||||
CSV options:
|
CSV options:
|
||||||
<label><input type="checkbox" name="_dl"> download file</label>
|
<label><input type="checkbox" name="_dl"> download file</label>
|
||||||
|
|
@ -445,7 +445,7 @@
|
||||||
<h2 class="pattern-heading">.bd for /database/table/row</h2>
|
<h2 class="pattern-heading">.bd for /database/table/row</h2>
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
|
||||||
<p>This data as <a href="{{ base_url }}fixtures/roadside_attractions/2.json">json</a></p>
|
<p>This data as <a href="/fixtures/roadside_attractions/2.json">json</a></p>
|
||||||
<table class="rows-and-columns">
|
<table class="rows-and-columns">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -479,7 +479,7 @@
|
||||||
<h2>Links from other tables</h2>
|
<h2>Links from other tables</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?attraction_id=2">
|
<a href="/fixtures/roadside_attraction_characteristics?attraction_id=2">
|
||||||
1 row</a>
|
1 row</a>
|
||||||
from attraction_id in roadside_attraction_characteristics
|
from attraction_id in roadside_attraction_characteristics
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
</style>
|
</style>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "_codemirror.html" %}
|
{% include "_codemirror.html" %}
|
||||||
{% include "_sql_parameter_styles.html" %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %}
|
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block crumbs %}
|
{% block crumbs %}
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
{{ crumbs.nav(request=request, database=database) }}
|
||||||
|
|
@ -25,19 +24,19 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if stored_query_write and db_is_immutable %}
|
{% if canned_query_write and db_is_immutable %}
|
||||||
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||||
{% set action_links, action_title = query_actions(), "Query actions" %}
|
{% set action_links, action_title = query_actions(), "Query actions" %}
|
||||||
{% include "_action_menu.html" %}
|
{% include "_action_menu.html" %}
|
||||||
|
|
||||||
{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %}
|
{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
<form class="sql core" action="{{ urls.database(database) }}{% if stored_query %}/{{ stored_query }}{% endif %}" method="{% if stored_query_write %}post{% else %}get{% endif %}" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
<form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
|
||||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
|
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
|
||||||
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
||||||
{% endif %}</h3>
|
{% endif %}</h3>
|
||||||
|
|
@ -46,43 +45,56 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not hide_sql %}
|
{% if not hide_sql %}
|
||||||
{% if editable and allow_execute_sql %}
|
{% if editable and allow_execute_sql %}
|
||||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
<p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
||||||
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
>{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not stored_query %}
|
{% if not canned_query %}
|
||||||
<input type="hidden" name="sql"
|
<input type="hidden" name="sql"
|
||||||
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
||||||
>
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set parameter_names = named_parameter_values.keys()|list %}
|
{% if named_parameter_values %}
|
||||||
{% set parameter_values = named_parameter_values %}
|
<h3>Query parameters</h3>
|
||||||
{% set sql_parameters_allow_expand = false %}
|
{% for name, value in named_parameter_values.items() %}
|
||||||
{% include "_sql_parameters.html" %}
|
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
<p>
|
<p>
|
||||||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
||||||
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}>
|
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
|
||||||
{{ show_hide_hidden }}
|
{{ show_hide_hidden }}
|
||||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %}
|
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||||
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
|
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if display_rows %}
|
{% if display_rows %}
|
||||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
|
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
|
||||||
|
<div class="table-wrapper"><table class="rows-and-columns">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in display_rows %}
|
||||||
|
<tr>
|
||||||
|
{% for column, td in zip(columns, row) %}
|
||||||
|
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
{% else %}
|
||||||
|
{% if not canned_query_write and not error %}
|
||||||
|
<p class="zero-results">0 results</p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set show_zero_results = not stored_query_write and not error %}
|
|
||||||
{% include "_query_results.html" %}
|
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
{% include "_codemirror_foot.html" %}
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Create query{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{- super() -}}
|
|
||||||
{% include "_codemirror.html" %}
|
|
||||||
{% include "_execute_write_analysis_styles.html" %}
|
|
||||||
{% include "_query_form_styles.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="query-create-page">
|
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
|
|
||||||
|
|
||||||
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/store" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
|
|
||||||
<div class="query-create-fields">
|
|
||||||
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
|
|
||||||
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
|
|
||||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
|
||||||
|
|
||||||
<p class="query-create-options">
|
|
||||||
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
|
|
||||||
<input type="hidden" name="is_private" value="0">
|
|
||||||
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
|
|
||||||
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
|
|
||||||
</p>
|
|
||||||
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
|
|
||||||
|
|
||||||
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
|
|
||||||
{% if has_sql %}
|
|
||||||
<h2>Query operations</h2>
|
|
||||||
{% if analysis_error %}
|
|
||||||
<p class="message-error">{{ analysis_error }}</p>
|
|
||||||
{% elif analysis_rows %}
|
|
||||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Operation</th>
|
|
||||||
<th scope="col">Database</th>
|
|
||||||
<th scope="col">Table</th>
|
|
||||||
<th scope="col">Required permission</th>
|
|
||||||
<th scope="col">Allowed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in analysis_rows %}
|
|
||||||
<tr>
|
|
||||||
<td><code>{{ row.operation }}</code></td>
|
|
||||||
<td><code>{{ row.database }}</code></td>
|
|
||||||
<td><code>{{ row.table }}</code></td>
|
|
||||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
|
|
||||||
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{% else %}
|
|
||||||
<p>Analysis will show each affected table and required permission.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
{% include "_execute_write_analysis_scripts.html" %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const titleInput = document.querySelector("#query-title");
|
|
||||||
const urlInput = document.querySelector("#query-url-slug");
|
|
||||||
let urlEdited = Boolean(urlInput && urlInput.value);
|
|
||||||
|
|
||||||
function slugify(value) {
|
|
||||||
return value
|
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9_-]+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (titleInput && urlInput) {
|
|
||||||
titleInput.addEventListener("input", () => {
|
|
||||||
if (!urlEdited) {
|
|
||||||
urlInput.value = slugify(titleInput.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
urlInput.addEventListener("input", () => {
|
|
||||||
urlEdited = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const form = document.querySelector("form.sql.core");
|
|
||||||
const analysisSection = document.querySelector("#query-create-analysis-section");
|
|
||||||
const submitButton = form
|
|
||||||
? form.querySelector("[data-query-create-submit]")
|
|
||||||
: null;
|
|
||||||
const analysisNote = form
|
|
||||||
? form.querySelector("[data-query-create-analysis-note]")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
function updateAnalysisNote(data) {
|
|
||||||
if (!analysisNote) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.analysis_error) {
|
|
||||||
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
|
|
||||||
} else if (data.has_sql === false) {
|
|
||||||
analysisNote.textContent = "Enter SQL to analyze this query.";
|
|
||||||
} else if (data.analysis_is_write) {
|
|
||||||
analysisNote.textContent = "This query updates data in the database.";
|
|
||||||
} else {
|
|
||||||
analysisNote.textContent = "This is a read-only query.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
|
||||||
form,
|
|
||||||
url: form.dataset.analyzeUrl,
|
|
||||||
renderParameters: false,
|
|
||||||
onData(data) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = data.save_disabled;
|
|
||||||
}
|
|
||||||
updateAnalysisNote(data);
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
|
||||||
analysis_error: error.message,
|
|
||||||
analysis_rows: [],
|
|
||||||
});
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = true;
|
|
||||||
}
|
|
||||||
updateAnalysisNote({ analysis_error: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Delete query: {{ query.name }}{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{- super() -}}
|
|
||||||
<style>
|
|
||||||
.query-delete-page {
|
|
||||||
max-width: 48rem;
|
|
||||||
}
|
|
||||||
.query-delete-summary {
|
|
||||||
background-color: #f7f7f9;
|
|
||||||
border: 1px solid #d7dde5;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 0.75rem 0 1.25rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
.query-delete-summary dt {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-delete-summary dd {
|
|
||||||
margin: 0 0 0.6rem;
|
|
||||||
}
|
|
||||||
.query-delete-summary dd pre {
|
|
||||||
margin: 0.2rem 0 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.query-delete-actions {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.query-delete-form input[type=submit] {
|
|
||||||
background: linear-gradient(180deg, #d73a31 0%, #b42318 100%);
|
|
||||||
border-color: #b42318;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-delete-form input[type=submit]:hover,
|
|
||||||
.query-delete-form input[type=submit]:focus {
|
|
||||||
background: linear-gradient(180deg, #c3342b 0%, #971c14 100%);
|
|
||||||
border-color: #971c14;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_class %}query-delete db-{{ database|to_css_class }}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="query-delete-page">
|
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Delete query: {{ query.title or query.name }}</h1>
|
|
||||||
|
|
||||||
<p>Are you sure you want to delete this saved query? This cannot be undone.</p>
|
|
||||||
|
|
||||||
<dl class="query-delete-summary">
|
|
||||||
<dt>URL</dt>
|
|
||||||
<dd><a href="{{ query_url }}">{{ query_url }}</a></dd>
|
|
||||||
{% if query.description %}
|
|
||||||
<dt>Description</dt>
|
|
||||||
<dd>{{ query.description }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
<dt>SQL</dt>
|
|
||||||
<dd><pre>{{ query.sql }}</pre></dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<form class="core query-delete-form" action="{{ query_url }}/-/delete" method="post">
|
|
||||||
<p class="query-delete-actions">
|
|
||||||
<input type="submit" value="Delete query">
|
|
||||||
<a href="{{ query_url }}">Cancel</a>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Edit query: {{ name }}{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{- super() -}}
|
|
||||||
{% include "_codemirror.html" %}
|
|
||||||
{% include "_execute_write_analysis_styles.html" %}
|
|
||||||
{% include "_query_form_styles.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_class %}query-edit db-{{ database|to_css_class }}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="query-create-page">
|
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Edit query: {{ title or name }}</h1>
|
|
||||||
|
|
||||||
<form class="sql core query-create-form" action="{{ query_url }}/-/edit" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
|
|
||||||
<div class="query-create-fields">
|
|
||||||
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
|
|
||||||
<p class="query-create-field"><label>URL</label> <span class="query-create-url-static">{{ query_url }}</span></p>
|
|
||||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
|
||||||
|
|
||||||
<p class="query-create-options">
|
|
||||||
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
|
|
||||||
<input type="hidden" name="is_private" value="0">
|
|
||||||
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
|
|
||||||
<span class="query-create-option-note">Queries marked private can only be seen and edited by you, their owner.</span>
|
|
||||||
</p>
|
|
||||||
<p class="query-create-submit"><input type="submit" value="Save changes" data-query-create-submit{% if save_disabled %} disabled{% endif %}> <a href="{{ query_url }}">Cancel</a></p>
|
|
||||||
|
|
||||||
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
|
|
||||||
{% if has_sql %}
|
|
||||||
<h2>Query operations</h2>
|
|
||||||
{% if analysis_error %}
|
|
||||||
<p class="message-error">{{ analysis_error }}</p>
|
|
||||||
{% elif analysis_rows %}
|
|
||||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Operation</th>
|
|
||||||
<th scope="col">Database</th>
|
|
||||||
<th scope="col">Table</th>
|
|
||||||
<th scope="col">Required permission</th>
|
|
||||||
<th scope="col">Allowed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in analysis_rows %}
|
|
||||||
<tr>
|
|
||||||
<td><code>{{ row.operation }}</code></td>
|
|
||||||
<td><code>{{ row.database }}</code></td>
|
|
||||||
<td><code>{{ row.table }}</code></td>
|
|
||||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
|
|
||||||
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{% else %}
|
|
||||||
<p>Analysis will show each affected table and required permission.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
{% include "_execute_write_analysis_scripts.html" %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const form = document.querySelector("form.sql.core");
|
|
||||||
const analysisSection = document.querySelector("#query-create-analysis-section");
|
|
||||||
const submitButton = form
|
|
||||||
? form.querySelector("[data-query-create-submit]")
|
|
||||||
: null;
|
|
||||||
const analysisNote = form
|
|
||||||
? form.querySelector("[data-query-create-analysis-note]")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
function updateAnalysisNote(data) {
|
|
||||||
if (!analysisNote) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.analysis_error) {
|
|
||||||
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
|
|
||||||
} else if (data.has_sql === false) {
|
|
||||||
analysisNote.textContent = "Enter SQL to analyze this query.";
|
|
||||||
} else if (data.analysis_is_write) {
|
|
||||||
analysisNote.textContent = "This query updates data in the database.";
|
|
||||||
} else {
|
|
||||||
analysisNote.textContent = "This is a read-only query.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
|
||||||
form,
|
|
||||||
url: form.dataset.analyzeUrl,
|
|
||||||
renderParameters: false,
|
|
||||||
onData(data) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = data.save_disabled;
|
|
||||||
}
|
|
||||||
updateAnalysisNote(data);
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
|
||||||
analysis_error: error.message,
|
|
||||||
analysis_rows: [],
|
|
||||||
});
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = true;
|
|
||||||
}
|
|
||||||
updateAnalysisNote({ analysis_error: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{- super() -}}
|
|
||||||
<style>
|
|
||||||
.query-list-page {
|
|
||||||
max-width: 64rem;
|
|
||||||
}
|
|
||||||
.query-list-filters {
|
|
||||||
margin: 0.5rem 0 0.75rem;
|
|
||||||
}
|
|
||||||
.query-list-search {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.query-list-search label {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.query-list-search input[type=search] {
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex: 1 1 18rem;
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
.query-list-search button[type=submit] {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
height: 2rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 0.35rem 0.65rem;
|
|
||||||
}
|
|
||||||
.query-list-facets {
|
|
||||||
align-items: flex-start;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem 1.6rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.query-list-facet {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.query-list-facet h2 {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
}
|
|
||||||
.query-list-facet ul {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.query-list-facet-link,
|
|
||||||
.query-list-facet-link:link,
|
|
||||||
.query-list-facet-link:visited,
|
|
||||||
.query-list-facet-link:hover,
|
|
||||||
.query-list-facet-link:focus,
|
|
||||||
.query-list-facet-link:active {
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid #c8d1dc;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: #39445a;
|
|
||||||
display: inline-flex;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
gap: 0.4rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 0.35rem 0.55rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.query-list-facet-link:hover {
|
|
||||||
border-color: #7ca5c8;
|
|
||||||
color: #1f5d85;
|
|
||||||
}
|
|
||||||
.query-list-facet-link-active {
|
|
||||||
background-color: #edf6fb;
|
|
||||||
border-color: #6d9fc0;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-list-facet-disabled {
|
|
||||||
color: #7b8794;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.query-list-facet-count {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.query-list-results {
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0.25rem 0 1rem;
|
|
||||||
min-width: 42rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-list-results th,
|
|
||||||
.query-list-results td {
|
|
||||||
border-bottom: 1px solid #d7dde5;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.query-list-results th {
|
|
||||||
background-color: #edf6fb;
|
|
||||||
border-top: 1px solid #d7dde5;
|
|
||||||
color: #39445a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-list-results tbody tr:nth-child(even) {
|
|
||||||
background-color: rgba(39, 104, 144, 0.05);
|
|
||||||
}
|
|
||||||
.query-list-results a.query-list-title {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-list-description {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin: 0.15rem 0 0;
|
|
||||||
}
|
|
||||||
.query-list-owner {
|
|
||||||
color: #39445a;
|
|
||||||
font-family: var(--font-monospace, monospace);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.query-list-flags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
.query-list-pill {
|
|
||||||
background-color: #eef1f5;
|
|
||||||
border: 1px solid #d7dde5;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: #39445a;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.25rem 0.4rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.query-list-pill-write {
|
|
||||||
background-color: #fff4db;
|
|
||||||
border-color: #e2b64e;
|
|
||||||
}
|
|
||||||
.query-list-pill-public {
|
|
||||||
background-color: #e7f5ec;
|
|
||||||
border-color: #9ecfab;
|
|
||||||
color: #267a3e;
|
|
||||||
}
|
|
||||||
.query-list-pill-private {
|
|
||||||
background-color: #f7edf0;
|
|
||||||
border-color: #dbb8c1;
|
|
||||||
}
|
|
||||||
.query-list-pill-trusted {
|
|
||||||
background-color: #e7f5ec;
|
|
||||||
border-color: #9ecfab;
|
|
||||||
color: #267a3e;
|
|
||||||
}
|
|
||||||
.query-list-empty {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
.query-list-footnotes {
|
|
||||||
border-top: 1px solid #d7dde5;
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
margin: 0.35rem 0 1rem;
|
|
||||||
padding-top: 0.55rem;
|
|
||||||
}
|
|
||||||
.query-list-footnotes p {
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
.query-list-footnotes .query-list-pill {
|
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
.query-list-pagination a {
|
|
||||||
border: 1px solid #007bff;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
}
|
|
||||||
.query-list-pagination-bottom {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.query-list-search input[type=search] {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="query-list-page">
|
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
|
|
||||||
|
|
||||||
{% if queries %}
|
|
||||||
<form class="query-list-filters core" action="{{ query_list_path }}" method="get">
|
|
||||||
<p class="query-list-search">
|
|
||||||
<label for="query-search">Search</label>
|
|
||||||
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
|
|
||||||
{% if filters.is_write %}<input type="hidden" name="is_write" value="{{ filters.is_write }}">{% endif %}
|
|
||||||
{% if filters.is_private %}<input type="hidden" name="is_private" value="{{ filters.is_private }}">{% endif %}
|
|
||||||
{% if filters.source %}<input type="hidden" name="source" value="{{ filters.source }}">{% endif %}
|
|
||||||
{% if filters.owner_id %}<input type="hidden" name="owner_id" value="{{ filters.owner_id }}">{% endif %}
|
|
||||||
<button type="submit">Search</button>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<nav class="query-list-facets" aria-label="Query filters">
|
|
||||||
{% for facet in facets %}
|
|
||||||
<section class="query-list-facet">
|
|
||||||
<h2>{{ facet.title }}</h2>
|
|
||||||
<ul>
|
|
||||||
{% for item in facet["items"] %}
|
|
||||||
<li>{% if item.href %}<a class="query-list-facet-link{% if item.active %} query-list-facet-link-active{% endif %}" href="{{ item.href }}"{% if item.active %} aria-current="true"{% endif %}>{% else %}<span class="query-list-facet-link query-list-facet-disabled">{% endif %}<span>{{ item.label }}</span><span class="query-list-facet-count">{{ item.count }}</span>{% if item.href %}</a>{% else %}</span>{% endif %}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endfor %}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="table-wrapper"><table class="query-list-results">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{% if show_database %}<th scope="col">Database</th>{% endif %}
|
|
||||||
<th scope="col">Query</th>
|
|
||||||
<th scope="col">Owner</th>
|
|
||||||
<th scope="col">Flags</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for query in queries %}
|
|
||||||
<tr>
|
|
||||||
{% if show_database %}
|
|
||||||
<td><a class="query-list-database" href="{{ urls.database(query.database) }}">{{ query.database }}</a></td>
|
|
||||||
{% endif %}
|
|
||||||
<td>
|
|
||||||
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
|
|
||||||
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="query-list-owner">{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}<span class="query-list-empty">-</span>{% endif %}</td>
|
|
||||||
<td>
|
|
||||||
<span class="query-list-flags">
|
|
||||||
{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}
|
|
||||||
{% if query.is_private %}<span class="query-list-pill query-list-pill-private">Private</span>{% endif %}
|
|
||||||
{% if query.is_trusted %}<span class="query-list-pill query-list-pill-trusted">Trusted</span>{% endif %}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{% if show_private_note or show_trusted_note %}
|
|
||||||
<div class="query-list-footnotes">
|
|
||||||
{% if show_private_note %}<p><span class="query-list-pill query-list-pill-private">Private</span>Only the owning actor can view this query.</p>{% endif %}
|
|
||||||
{% if show_trusted_note %}<p><span class="query-list-pill query-list-pill-trusted">Trusted</span>Execution skips the usual SQL and write permission checks after view-query allows access.</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<p>No queries found.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if next_url %}
|
|
||||||
<nav class="query-list-pagination query-list-pagination-bottom" aria-label="Query pagination"><a href="{{ next_url }}">Next page</a></nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -4,13 +4,6 @@
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
{% if row_mutation_ui %}
|
|
||||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
|
||||||
{% if table_page_data.foreignKeys %}
|
|
||||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
|
||||||
{% endif %}
|
|
||||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
|
||||||
{% endif %}
|
|
||||||
<style>
|
<style>
|
||||||
@media only screen and (max-width: 576px) {
|
@media only screen and (max-width: 576px) {
|
||||||
{% for column in columns %}
|
{% for column in columns %}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
{% 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 or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||||
<script src="{{ static('column-chooser.js') }}" defer></script>
|
<script src="{{ urls.static('table.js') }}" defer></script>
|
||||||
{% if table_page_data.foreignKeys %}
|
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
|
||||||
{% endif %}
|
|
||||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
|
||||||
<script src="{{ static('table.js') }}" defer></script>
|
|
||||||
<script src="{{ static('mobile-column-actions.js') }}" defer></script>
|
|
||||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||||
<style>
|
<style>
|
||||||
@media only screen and (max-width: 576px) {
|
@media only screen and (max-width: 576px) {
|
||||||
|
|
@ -48,19 +43,19 @@
|
||||||
|
|
||||||
{% if count or human_description_en %}
|
{% if count or human_description_en %}
|
||||||
<h3>
|
<h3>
|
||||||
{% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows
|
{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows
|
||||||
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||||
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
|
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||||
{% if supports_search %}
|
{% if supports_search %}
|
||||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for column, lookup, value in filters.selections() %}
|
{% for column, lookup, value in filters.selections() %}
|
||||||
<div class="filter-row filter-controls-row">
|
<div class="filter-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column_{{ loop.index }}">
|
<select name="_filter_column_{{ loop.index }}">
|
||||||
<option value="">- remove filter -</option>
|
<option value="">- remove filter -</option>
|
||||||
|
|
@ -77,7 +72,7 @@
|
||||||
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
|
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="filter-row filter-controls-row">
|
<div class="filter-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column">
|
<select name="_filter_column">
|
||||||
<option value="">- column -</option>
|
<option value="">- column -</option>
|
||||||
|
|
@ -93,9 +88,9 @@
|
||||||
</select>
|
</select>
|
||||||
</div><input type="text" name="_filter_value" class="filter-value">
|
</div><input type="text" name="_filter_value" class="filter-value">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row filter-actions-row">
|
<div class="filter-row">
|
||||||
{% if is_sortable %}
|
{% if is_sortable %}
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper small-screen-only">
|
||||||
<select name="_sort" id="sort_by">
|
<select name="_sort" id="sort_by">
|
||||||
<option value="">Sort...</option>
|
<option value="">Sort...</option>
|
||||||
{% for column in display_columns %}
|
{% for column in display_columns %}
|
||||||
|
|
@ -105,12 +100,12 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
|
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for key, value in form_hidden_args %}
|
{% for key, value in form_hidden_args %}
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<input type="submit" value="Apply filters" tabindex="0">
|
<input type="submit" value="Apply">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -163,19 +158,6 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if table_insert_ui %}
|
|
||||||
<div class="table-row-toolbar">
|
|
||||||
<button type="button" class="core table-insert-row" data-table-action="insert-row">
|
|
||||||
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
|
||||||
<path d="M8 12h8"></path>
|
|
||||||
<path d="M12 8v8"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Insert row</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% include custom_table_templates %}
|
{% include custom_table_templates %}
|
||||||
|
|
||||||
{% if next_url %}
|
{% if next_url %}
|
||||||
|
|
|
||||||
|
|
@ -18,21 +18,6 @@ if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
||||||
|
|
||||||
class TokenInvalid(Exception):
|
|
||||||
"""
|
|
||||||
Raised by a TokenHandler when a token it recognizes is invalid -
|
|
||||||
for example a bad signature, malformed payload or expired token.
|
|
||||||
|
|
||||||
Datasette responds to this with an HTTP 401 error. Handlers should
|
|
||||||
return None instead for tokens they do not recognize at all, so that
|
|
||||||
other registered handlers get a chance to verify them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, message="Invalid token"):
|
|
||||||
self.message = message
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class TokenRestrictions:
|
class TokenRestrictions:
|
||||||
"""
|
"""
|
||||||
|
|
@ -123,12 +108,8 @@ class TokenHandler:
|
||||||
|
|
||||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Verify a token and return an actor dict.
|
Verify a token and return an actor dict, or None if this handler
|
||||||
|
does not recognize the token.
|
||||||
Return None if this handler does not recognize the token at all,
|
|
||||||
so other handlers can try it. Raise TokenInvalid if the token is
|
|
||||||
recognized but invalid (bad signature, malformed, expired) - the
|
|
||||||
request will fail with a 401 error.
|
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
@ -166,32 +147,29 @@ class SignedTokenHandler(TokenHandler):
|
||||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||||
prefix = "dstok_"
|
prefix = "dstok_"
|
||||||
|
|
||||||
if not token.startswith(prefix):
|
if not datasette.setting("allow_signed_tokens"):
|
||||||
# Not one of our tokens - leave it for other handlers
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not datasette.setting("allow_signed_tokens"):
|
|
||||||
raise TokenInvalid(
|
|
||||||
"Signed tokens are not enabled for this Datasette instance"
|
|
||||||
)
|
|
||||||
|
|
||||||
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||||
|
|
||||||
|
if not token.startswith(prefix):
|
||||||
|
return None
|
||||||
|
|
||||||
raw = token[len(prefix) :]
|
raw = token[len(prefix) :]
|
||||||
try:
|
try:
|
||||||
decoded = datasette.unsign(raw, namespace="token")
|
decoded = datasette.unsign(raw, namespace="token")
|
||||||
except itsdangerous.BadSignature:
|
except itsdangerous.BadSignature:
|
||||||
raise TokenInvalid("Invalid token signature")
|
return None
|
||||||
|
|
||||||
if "t" not in decoded:
|
if "t" not in decoded:
|
||||||
raise TokenInvalid("Invalid token: no timestamp")
|
return None
|
||||||
created = decoded["t"]
|
created = decoded["t"]
|
||||||
if not isinstance(created, int):
|
if not isinstance(created, int):
|
||||||
raise TokenInvalid("Invalid token: invalid timestamp")
|
return None
|
||||||
|
|
||||||
duration = decoded.get("d")
|
duration = decoded.get("d")
|
||||||
if duration is not None and not isinstance(duration, int):
|
if duration is not None and not isinstance(duration, int):
|
||||||
raise TokenInvalid("Invalid token: invalid duration")
|
return None
|
||||||
|
|
||||||
if (duration is None and max_signed_tokens_ttl) or (
|
if (duration is None and max_signed_tokens_ttl) or (
|
||||||
duration is not None
|
duration is not None
|
||||||
|
|
@ -202,7 +180,7 @@ class SignedTokenHandler(TokenHandler):
|
||||||
|
|
||||||
if duration:
|
if duration:
|
||||||
if time.time() - created > duration:
|
if time.time() - created > duration:
|
||||||
raise TokenInvalid("Token has expired")
|
return None
|
||||||
|
|
||||||
actor = {"id": decoded["a"], "token": "dstok"}
|
actor = {"id": decoded["a"], "token": "dstok"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,7 @@ def get_task_id():
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def trace_child_tasks():
|
def trace_child_tasks():
|
||||||
token = trace_task_id.set(get_task_id())
|
token = trace_task_id.set(get_task_id())
|
||||||
try:
|
|
||||||
yield
|
yield
|
||||||
finally:
|
|
||||||
trace_task_id.reset(token)
|
trace_task_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import binascii
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import click
|
import click
|
||||||
|
|
@ -225,35 +224,17 @@ def compound_keys_after_sql(pks, start_index=0):
|
||||||
return "({})".format("\n or\n".join(or_clauses))
|
return "({})".format("\n or\n".join(or_clauses))
|
||||||
|
|
||||||
|
|
||||||
@documented
|
|
||||||
class CustomJSONEncoder(json.JSONEncoder):
|
class CustomJSONEncoder(json.JSONEncoder):
|
||||||
"""
|
|
||||||
The CustomJSONEncoder class handles serialization for objects commonly used by Datasette,
|
|
||||||
including SQLite cursors and binary blobs. Datasette uses it internally to serve .json endpoints,
|
|
||||||
and plugins that return JSON can use it to match Datasette's own handling.
|
|
||||||
|
|
||||||
Built-in types (text, numbers, lists, etc) are encoded the same as Python's built-in ``json`` module.
|
|
||||||
|
|
||||||
- ``sqlite3.Row`` becomes a tuple
|
|
||||||
- ``sqlite3.Cursor`` becomes a list
|
|
||||||
|
|
||||||
Binary blobs are encoded as an object, with the actual data base64-encoded,
|
|
||||||
like so: ::
|
|
||||||
|
|
||||||
{
|
|
||||||
"$base64": True,
|
|
||||||
"encoded": ...,
|
|
||||||
}
|
|
||||||
|
|
||||||
Example: https://latest.datasette.io/fixtures/binary_data.json
|
|
||||||
"""
|
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, sqlite3.Row):
|
if isinstance(obj, sqlite3.Row):
|
||||||
return tuple(obj)
|
return tuple(obj)
|
||||||
if isinstance(obj, sqlite3.Cursor):
|
if isinstance(obj, sqlite3.Cursor):
|
||||||
return list(obj)
|
return list(obj)
|
||||||
if isinstance(obj, bytes):
|
if isinstance(obj, bytes):
|
||||||
|
# Does it encode to utf8?
|
||||||
|
try:
|
||||||
|
return obj.decode("utf8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
return {
|
return {
|
||||||
"$base64": True,
|
"$base64": True,
|
||||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||||
|
|
@ -261,35 +242,6 @@ class CustomJSONEncoder(json.JSONEncoder):
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
class WriteJsonValueError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def decode_write_json_cell(value):
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return value
|
|
||||||
keys = set(value.keys())
|
|
||||||
if keys == {"$raw"}:
|
|
||||||
return value["$raw"]
|
|
||||||
if keys == {"$base64", "encoded"} and value.get("$base64") is True:
|
|
||||||
encoded = value["encoded"]
|
|
||||||
if not isinstance(encoded, str):
|
|
||||||
raise WriteJsonValueError("$base64 encoded value must be a string")
|
|
||||||
try:
|
|
||||||
return base64.b64decode(encoded, validate=True)
|
|
||||||
except binascii.Error as ex:
|
|
||||||
raise WriteJsonValueError("Invalid $base64 encoded value") from ex
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def decode_write_json_row(row):
|
|
||||||
return {key: decode_write_json_cell(value) for key, value in row.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def decode_write_json_rows(rows):
|
|
||||||
return [decode_write_json_row(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def sqlite_timelimit(conn, ms):
|
def sqlite_timelimit(conn, ms):
|
||||||
deadline = time.perf_counter() + (ms / 1000)
|
deadline = time.perf_counter() + (ms / 1000)
|
||||||
|
|
@ -458,7 +410,8 @@ def escape_css_string(s):
|
||||||
def escape_sqlite(s):
|
def escape_sqlite(s):
|
||||||
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
||||||
return s
|
return s
|
||||||
return '"{}"'.format(s.replace('"', '""'))
|
else:
|
||||||
|
return f"[{s}]"
|
||||||
|
|
||||||
|
|
||||||
def make_dockerfile(
|
def make_dockerfile(
|
||||||
|
|
@ -884,8 +837,7 @@ def path_with_format(
|
||||||
*, request=None, path=None, format=None, extra_qs=None, replace_format=None
|
*, request=None, path=None, format=None, extra_qs=None, replace_format=None
|
||||||
):
|
):
|
||||||
qs = extra_qs or {}
|
qs = extra_qs or {}
|
||||||
if path is None and request:
|
path = request.path if request else path
|
||||||
path = request.path
|
|
||||||
if replace_format and path.endswith(f".{replace_format}"):
|
if replace_format and path.endswith(f".{replace_format}"):
|
||||||
path = path[: -(1 + len(replace_format))]
|
path = path[: -(1 + len(replace_format))]
|
||||||
if "." in path:
|
if "." in path:
|
||||||
|
|
@ -1288,20 +1240,10 @@ class StartupError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Comments and string literals, matched in a single pass so that whichever
|
_single_line_comment_re = re.compile(r"--.*")
|
||||||
# construct starts first "wins" - this ensures a comment marker inside a string
|
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||||
# literal (or a quote inside a comment) does not confuse the parameter scan.
|
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
|
||||||
_comments_and_strings_re = re.compile(
|
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
|
||||||
r"""
|
|
||||||
--[^\n]* # single line comment
|
|
||||||
| /\*.*?(?:\*/|\Z) # multi line comment, possibly to end-of-input
|
|
||||||
| '(?:''|[^'])*' # single quoted string ('' escapes a quote)
|
|
||||||
| "(?:""|[^"])*" # double quoted identifier ("" escapes a quote)
|
|
||||||
| \[(?:[^\]])*\] # square-bracket quoted identifier
|
|
||||||
| `(?:``|[^`])*` # backtick quoted identifier
|
|
||||||
""",
|
|
||||||
re.DOTALL | re.VERBOSE,
|
|
||||||
)
|
|
||||||
_named_param_re = re.compile(r":(\w+)")
|
_named_param_re = re.compile(r":(\w+)")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1312,9 +1254,10 @@ def named_parameters(sql: str) -> List[str]:
|
||||||
|
|
||||||
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
||||||
"""
|
"""
|
||||||
# Strip comments and string literals first so that any ":name" sequences
|
sql = _single_line_comment_re.sub("", sql)
|
||||||
# inside them are not mistaken for named parameters
|
sql = _multi_line_comment_re.sub("", sql)
|
||||||
sql = _comments_and_strings_re.sub("", sql)
|
sql = _single_quote_re.sub("", sql)
|
||||||
|
sql = _double_quote_re.sub("", sql)
|
||||||
# Extract parameters from what is left
|
# Extract parameters from what is left
|
||||||
return _named_param_re.findall(sql)
|
return _named_param_re.findall(sql)
|
||||||
|
|
||||||
|
|
@ -1327,54 +1270,6 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
|
||||||
return named_parameters(sql)
|
return named_parameters(sql)
|
||||||
|
|
||||||
|
|
||||||
def parse_size_limit(value, default, maximum, name="_size"):
|
|
||||||
"""
|
|
||||||
Parse a page-size parameter using the same semantics as the table
|
|
||||||
view's ?_size=: blank means default, "max" means maximum, integers
|
|
||||||
must be 0 or greater and no larger than maximum. Raises ValueError
|
|
||||||
with a message suitable for a 400 response.
|
|
||||||
"""
|
|
||||||
if value in (None, ""):
|
|
||||||
return default
|
|
||||||
if value == "max":
|
|
||||||
return maximum
|
|
||||||
try:
|
|
||||||
size = int(value)
|
|
||||||
if size < 0:
|
|
||||||
raise ValueError
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("{} must be a positive integer".format(name))
|
|
||||||
if size > maximum:
|
|
||||||
raise ValueError("{} must be <= {}".format(name, maximum))
|
|
||||||
return size
|
|
||||||
|
|
||||||
|
|
||||||
UNSTABLE_API_MESSAGE = (
|
|
||||||
"This API is not part of Datasette's stable interface and may change at any time"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def error_body(messages, status):
|
|
||||||
"""
|
|
||||||
The canonical JSON error body used by every Datasette JSON error response:
|
|
||||||
|
|
||||||
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
|
|
||||||
|
|
||||||
"error" is all of the messages joined with "; ", "errors" is the full
|
|
||||||
list, "status" matches the HTTP status code. Callers may add extra
|
|
||||||
context keys to the returned dictionary but must not remove these four.
|
|
||||||
"""
|
|
||||||
if isinstance(messages, str):
|
|
||||||
messages = [messages]
|
|
||||||
messages = [str(message) for message in messages]
|
|
||||||
return {
|
|
||||||
"ok": False,
|
|
||||||
"error": "; ".join(messages),
|
|
||||||
"errors": messages,
|
|
||||||
"status": status,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def add_cors_headers(headers):
|
def add_cors_headers(headers):
|
||||||
headers["Access-Control-Allow-Origin"] = "*"
|
headers["Access-Control-Allow-Origin"] = "*"
|
||||||
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||||
|
|
@ -1648,17 +1543,6 @@ def md5_not_usedforsecurity(s):
|
||||||
_etag_cache = {}
|
_etag_cache = {}
|
||||||
|
|
||||||
|
|
||||||
def sha256_file(filepath, chunk_size=4096):
|
|
||||||
hasher = hashlib.sha256()
|
|
||||||
with open(filepath, "rb") as fp:
|
|
||||||
while True:
|
|
||||||
chunk = fp.read(chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
hasher.update(chunk)
|
|
||||||
return hasher.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
async def calculate_etag(filepath, chunk_size=4096):
|
async def calculate_etag(filepath, chunk_size=4096):
|
||||||
if filepath in _etag_cache:
|
if filepath in _etag_cache:
|
||||||
return _etag_cache[filepath]
|
return _etag_cache[filepath]
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ The core pattern is:
|
||||||
- Across levels, child beats parent beats global
|
- Across levels, child beats parent beats global
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||||
|
|
@ -243,14 +241,6 @@ async def _build_single_action_sql(
|
||||||
"),",
|
"),",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
query_parts.extend(
|
|
||||||
[
|
|
||||||
"anon_rules AS (",
|
|
||||||
" SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0",
|
|
||||||
"),",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Continue with the cascading logic
|
# Continue with the cascading logic
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
|
|
@ -497,153 +487,6 @@ async def build_permission_rules_sql(
|
||||||
return rules_union, all_params, restriction_sqls
|
return rules_union, all_params, restriction_sqls
|
||||||
|
|
||||||
|
|
||||||
async def check_permissions_for_actions(
|
|
||||||
*,
|
|
||||||
datasette: "Datasette",
|
|
||||||
actor: dict | None,
|
|
||||||
actions: list[str],
|
|
||||||
parent: str | None,
|
|
||||||
child: str | None,
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
"""
|
|
||||||
Check several actions for one actor and resource in a single query.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
datasette: The Datasette instance
|
|
||||||
actor: The actor dict (or None)
|
|
||||||
actions: List of action names to check
|
|
||||||
parent: The parent resource identifier (e.g., database name, or None)
|
|
||||||
child: The child resource identifier (e.g., table name, or None)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping each action name to True (allowed) or False (denied)
|
|
||||||
|
|
||||||
Each action contributes its own tagged block of permission rules
|
|
||||||
(gathered from the permission_resources_sql hook, with parameters
|
|
||||||
namespaced per action to avoid collisions) plus an optional
|
|
||||||
restriction allowlist CTE. One internal database query resolves
|
|
||||||
the winning rule per action using the same specificity-then-deny
|
|
||||||
ordering as the rest of the permission system.
|
|
||||||
|
|
||||||
Note: this resolves each action independently - also_requires
|
|
||||||
dependencies are handled by the caller (Datasette.allowed_many).
|
|
||||||
"""
|
|
||||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
|
||||||
|
|
||||||
for action in actions:
|
|
||||||
if not datasette.actions.get(action):
|
|
||||||
raise ValueError(f"Unknown action: {action}")
|
|
||||||
|
|
||||||
# Dedupe while preserving order
|
|
||||||
unique_actions = list(dict.fromkeys(actions))
|
|
||||||
if not unique_actions:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Gather hook results for each action concurrently - hooks within a
|
|
||||||
# single action still run sequentially, preserving existing semantics
|
|
||||||
gathered = await asyncio.gather(
|
|
||||||
*(
|
|
||||||
gather_permission_sql_from_hooks(
|
|
||||||
datasette=datasette, actor=actor, action=action
|
|
||||||
)
|
|
||||||
for action in unique_actions
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(result is SKIP_PERMISSION_CHECKS for result in gathered):
|
|
||||||
return {action: True for action in unique_actions}
|
|
||||||
|
|
||||||
params = {"_check_parent": parent, "_check_child": child}
|
|
||||||
ctes = []
|
|
||||||
result_rows = []
|
|
||||||
verdicts = {}
|
|
||||||
|
|
||||||
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
|
||||||
prefix = f"a{i}_"
|
|
||||||
rule_parts = []
|
|
||||||
restriction_parts = []
|
|
||||||
|
|
||||||
for permission_sql in permission_sqls:
|
|
||||||
sql = permission_sql.sql
|
|
||||||
restriction_sql = permission_sql.restriction_sql
|
|
||||||
# Namespace this block's params so identical names used for
|
|
||||||
# different actions cannot collide
|
|
||||||
for key in permission_sql.params or {}:
|
|
||||||
new_key = prefix + key
|
|
||||||
params[new_key] = permission_sql.params[key]
|
|
||||||
pattern = re.compile(":" + re.escape(key) + r"(?![A-Za-z0-9_])")
|
|
||||||
if sql:
|
|
||||||
sql = pattern.sub(":" + new_key, sql)
|
|
||||||
if restriction_sql:
|
|
||||||
restriction_sql = pattern.sub(":" + new_key, restriction_sql)
|
|
||||||
|
|
||||||
if restriction_sql:
|
|
||||||
restriction_parts.append(restriction_sql)
|
|
||||||
|
|
||||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
|
||||||
if sql is None:
|
|
||||||
continue
|
|
||||||
rule_parts.append(
|
|
||||||
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not rule_parts:
|
|
||||||
# No rules from any plugin - default deny. Restrictions can
|
|
||||||
# only restrict, never grant, so no SQL is needed at all
|
|
||||||
verdicts[action] = False
|
|
||||||
continue
|
|
||||||
ctes.append(f"a{i}_rules AS (\n" + "\nUNION ALL\n".join(rule_parts) + "\n)")
|
|
||||||
|
|
||||||
# Winning rule for this action: most specific depth first, then
|
|
||||||
# deny-beats-allow, then source_plugin as a stable tie-break
|
|
||||||
verdict_sql = f"""COALESCE((
|
|
||||||
SELECT allow FROM (
|
|
||||||
SELECT allow, source_plugin,
|
|
||||||
CASE
|
|
||||||
WHEN child IS NOT NULL THEN 2
|
|
||||||
WHEN parent IS NOT NULL THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END AS depth
|
|
||||||
FROM a{i}_rules
|
|
||||||
WHERE (parent IS NULL OR parent = :_check_parent)
|
|
||||||
AND (child IS NULL OR child = :_check_child)
|
|
||||||
ORDER BY
|
|
||||||
depth DESC,
|
|
||||||
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
|
|
||||||
source_plugin
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
), 0)"""
|
|
||||||
|
|
||||||
if restriction_parts:
|
|
||||||
# Database-level restrictions (parent, NULL) match all children
|
|
||||||
restriction_intersect = "\nINTERSECT\n".join(
|
|
||||||
f"SELECT * FROM ({sql})" for sql in restriction_parts
|
|
||||||
)
|
|
||||||
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
|
||||||
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
|
||||||
SELECT 1 FROM a{i}_restriction r
|
|
||||||
WHERE (r.parent = :_check_parent OR r.parent IS NULL)
|
|
||||||
AND (r.child = :_check_child OR r.child IS NULL)
|
|
||||||
)"""
|
|
||||||
|
|
||||||
result_rows.append(f"({i}, ({verdict_sql}))")
|
|
||||||
|
|
||||||
if result_rows:
|
|
||||||
ctes.append(
|
|
||||||
"results(action_idx, is_allowed) AS (VALUES\n"
|
|
||||||
+ ",\n".join(result_rows)
|
|
||||||
+ "\n)"
|
|
||||||
)
|
|
||||||
query = (
|
|
||||||
"WITH\n" + ",\n".join(ctes) + "\nSELECT action_idx, is_allowed FROM results"
|
|
||||||
)
|
|
||||||
result = await datasette.get_internal_database().execute(query, params)
|
|
||||||
for row in result.rows:
|
|
||||||
verdicts[unique_actions[row[0]]] = bool(row[1])
|
|
||||||
return verdicts
|
|
||||||
|
|
||||||
|
|
||||||
async def check_permission_for_resource(
|
async def check_permission_for_resource(
|
||||||
*,
|
*,
|
||||||
datasette: "Datasette",
|
datasette: "Datasette",
|
||||||
|
|
@ -664,12 +507,77 @@ async def check_permission_for_resource(
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the actor is allowed, False otherwise
|
True if the actor is allowed, False otherwise
|
||||||
|
|
||||||
|
This builds the cascading permission query and checks if the specific
|
||||||
|
resource is in the allowed set.
|
||||||
"""
|
"""
|
||||||
results = await check_permissions_for_actions(
|
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
|
||||||
datasette=datasette,
|
datasette, actor, action
|
||||||
actor=actor,
|
|
||||||
actions=[action],
|
|
||||||
parent=parent,
|
|
||||||
child=child,
|
|
||||||
)
|
)
|
||||||
return results[action]
|
|
||||||
|
# If no rules (empty SQL), default deny
|
||||||
|
if not rules_union:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Add parameters for the resource we're checking
|
||||||
|
all_params["_check_parent"] = parent
|
||||||
|
all_params["_check_child"] = child
|
||||||
|
|
||||||
|
# If there are restriction filters, check if the resource passes them first
|
||||||
|
if restriction_sqls:
|
||||||
|
# Check if resource is in restriction allowlist
|
||||||
|
# Database-level restrictions (parent, NULL) should match all children (parent, *)
|
||||||
|
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||||
|
restriction_check = "\nINTERSECT\n".join(
|
||||||
|
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||||
|
)
|
||||||
|
restriction_query = f"""
|
||||||
|
WITH restriction_list AS (
|
||||||
|
{restriction_check}
|
||||||
|
)
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM restriction_list
|
||||||
|
WHERE (parent = :_check_parent OR parent IS NULL)
|
||||||
|
AND (child = :_check_child OR child IS NULL)
|
||||||
|
) AS in_allowlist
|
||||||
|
"""
|
||||||
|
result = await datasette.get_internal_database().execute(
|
||||||
|
restriction_query, all_params
|
||||||
|
)
|
||||||
|
if result.rows and not result.rows[0][0]:
|
||||||
|
# Resource not in restriction allowlist - deny
|
||||||
|
return False
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
WITH
|
||||||
|
all_rules AS (
|
||||||
|
{rules_union}
|
||||||
|
),
|
||||||
|
matched_rules AS (
|
||||||
|
SELECT ar.*,
|
||||||
|
CASE
|
||||||
|
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||||
|
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
|
||||||
|
ELSE 0 -- root/global
|
||||||
|
END AS depth
|
||||||
|
FROM all_rules ar
|
||||||
|
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
|
||||||
|
AND (ar.child IS NULL OR ar.child = :_check_child)
|
||||||
|
),
|
||||||
|
winner AS (
|
||||||
|
SELECT *
|
||||||
|
FROM matched_rules
|
||||||
|
ORDER BY
|
||||||
|
depth DESC, -- specificity first (higher depth wins)
|
||||||
|
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
|
||||||
|
source_plugin -- stable tie-break
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Execute the query against the internal database
|
||||||
|
result = await datasette.get_internal_database().execute(query, all_params)
|
||||||
|
if result.rows:
|
||||||
|
return bool(result.rows[0][0])
|
||||||
|
return False
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
|
from datasette.utils import MultiParams, calculate_etag
|
||||||
from datasette.utils.multipart import (
|
from datasette.utils.multipart import (
|
||||||
parse_form_data,
|
parse_form_data,
|
||||||
MultipartParseError,
|
MultipartParseError,
|
||||||
|
|
@ -67,25 +67,13 @@ class BadRequest(Base400):
|
||||||
status = 400
|
status = 400
|
||||||
|
|
||||||
|
|
||||||
class PayloadTooLarge(Base400):
|
|
||||||
status = 413
|
|
||||||
|
|
||||||
|
|
||||||
SAMESITE_VALUES = ("strict", "lax", "none")
|
SAMESITE_VALUES = ("strict", "lax", "none")
|
||||||
|
|
||||||
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
|
|
||||||
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
|
|
||||||
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
|
|
||||||
# while these bodies are held in RAM and json.loads() can multiply their
|
|
||||||
# footprint several times over.
|
|
||||||
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
|
||||||
|
|
||||||
|
|
||||||
class Request:
|
class Request:
|
||||||
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
def __init__(self, scope, receive):
|
||||||
self.scope = scope
|
self.scope = scope
|
||||||
self.receive = receive
|
self.receive = receive
|
||||||
self.max_post_body_bytes = max_post_body_bytes
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
||||||
|
|
@ -153,52 +141,20 @@ class Request:
|
||||||
def actor(self):
|
def actor(self):
|
||||||
return self.scope.get("actor", None)
|
return self.scope.get("actor", None)
|
||||||
|
|
||||||
async def post_body(self, max_bytes=None):
|
async def post_body(self):
|
||||||
"""
|
body = b""
|
||||||
Read the request body fully into memory.
|
|
||||||
|
|
||||||
The body is capped at max_bytes - or self.max_post_body_bytes
|
|
||||||
(default 2MB, set from the max_post_body_bytes setting for requests
|
|
||||||
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
|
|
||||||
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
|
|
||||||
oversized bodies are rejected as soon as the limit is passed, without
|
|
||||||
buffering the rest.
|
|
||||||
"""
|
|
||||||
if max_bytes is None:
|
|
||||||
max_bytes = self.max_post_body_bytes
|
|
||||||
too_large = PayloadTooLarge(
|
|
||||||
"Request body exceeded maximum size of {} bytes".format(max_bytes)
|
|
||||||
)
|
|
||||||
if max_bytes:
|
|
||||||
# Reject early if the client declares an oversized body
|
|
||||||
try:
|
|
||||||
if int(self.headers.get("content-length", "")) > max_bytes:
|
|
||||||
raise too_large
|
|
||||||
except ValueError:
|
|
||||||
# Missing or malformed - the streaming check below still applies
|
|
||||||
pass
|
|
||||||
chunks = []
|
|
||||||
received = 0
|
|
||||||
more_body = True
|
more_body = True
|
||||||
while more_body:
|
while more_body:
|
||||||
message = await self.receive()
|
message = await self.receive()
|
||||||
assert message["type"] == "http.request", message
|
assert message["type"] == "http.request", message
|
||||||
chunk = message.get("body", b"")
|
body += message.get("body", b"")
|
||||||
received += len(chunk)
|
|
||||||
if max_bytes and received > max_bytes:
|
|
||||||
raise too_large
|
|
||||||
chunks.append(chunk)
|
|
||||||
more_body = message.get("more_body", False)
|
more_body = message.get("more_body", False)
|
||||||
return b"".join(chunks)
|
return body
|
||||||
|
|
||||||
async def post_vars(self):
|
async def post_vars(self):
|
||||||
body = await self.post_body()
|
body = await self.post_body()
|
||||||
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
|
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
|
||||||
|
|
||||||
async def json(self):
|
|
||||||
body = await self.post_body()
|
|
||||||
return json.loads(body)
|
|
||||||
|
|
||||||
async def form(
|
async def form(
|
||||||
self,
|
self,
|
||||||
files: bool = False,
|
files: bool = False,
|
||||||
|
|
@ -374,11 +330,9 @@ async def asgi_send_html(send, html, status=200, headers=None):
|
||||||
|
|
||||||
|
|
||||||
async def asgi_send_redirect(send, location, status=302):
|
async def asgi_send_redirect(send, location, status=302):
|
||||||
# Prevent open redirect vulnerability: collapse leading slashes and
|
# Prevent open redirect vulnerability: strip multiple leading slashes
|
||||||
# backslashes down to a single slash. //example.com is a protocol-relative
|
# //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)
|
||||||
# URL, and browsers normalise backslashes to slashes so /\example.com would
|
location = re.sub(r"^/+", "/", location)
|
||||||
# be treated as //example.com - https://github.com/simonw/datasette/issues/2680
|
|
||||||
location = re.sub(r"^[/\\]+", "/", location)
|
|
||||||
await asgi_send(
|
await asgi_send(
|
||||||
send,
|
send,
|
||||||
"",
|
"",
|
||||||
|
|
@ -437,9 +391,6 @@ async def asgi_send_file(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
HASHED_STATIC_CACHE_CONTROL = "max-age=31536000, immutable, public"
|
|
||||||
|
|
||||||
|
|
||||||
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
root_path = Path(root_path)
|
root_path = Path(root_path)
|
||||||
static_headers = {}
|
static_headers = {}
|
||||||
|
|
@ -466,17 +417,11 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
# Calculate ETag for filepath
|
# Calculate ETag for filepath
|
||||||
hash_value = request.args.get("_hash")
|
|
||||||
if (
|
|
||||||
hash_value
|
|
||||||
and hash_value == sha256_file(full_path, chunk_size=chunk_size)[:12]
|
|
||||||
):
|
|
||||||
headers["Cache-Control"] = HASHED_STATIC_CACHE_CONTROL
|
|
||||||
etag = await calculate_etag(full_path, chunk_size=chunk_size)
|
etag = await calculate_etag(full_path, chunk_size=chunk_size)
|
||||||
headers["ETag"] = etag
|
headers["ETag"] = etag
|
||||||
if_none_match = request.headers.get("if-none-match")
|
if_none_match = request.headers.get("if-none-match")
|
||||||
if if_none_match and if_none_match == etag:
|
if if_none_match and if_none_match == etag:
|
||||||
return await asgi_send(send, "", 304, headers=headers)
|
return await asgi_send(send, "", 304)
|
||||||
await asgi_send_file(
|
await asgi_send_file(
|
||||||
send, full_path, chunk_size=chunk_size, headers=headers
|
send, full_path, chunk_size=chunk_size, headers=headers
|
||||||
)
|
)
|
||||||
|
|
@ -575,18 +520,6 @@ class Response:
|
||||||
content_type="application/json; charset=utf-8",
|
content_type="application/json; charset=utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def error(cls, messages, status=400, headers=None):
|
|
||||||
"""
|
|
||||||
A JSON error response using Datasette's standard error format.
|
|
||||||
|
|
||||||
messages can be a single string or a list of strings. For errors
|
|
||||||
that should content-negotiate between JSON and HTML, raise
|
|
||||||
Forbidden, NotFound, BadRequest or DatasetteError instead and let
|
|
||||||
Datasette's error handling hooks build the response.
|
|
||||||
"""
|
|
||||||
return cls.json(error_body(messages, status), status=status, headers=headers)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def redirect(cls, path, status=302, headers=None):
|
def redirect(cls, path, status=302, headers=None):
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,9 @@
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from sqlite_utils import Database as SQLiteUtilsDatabase
|
|
||||||
from sqlite_utils import Migrations
|
|
||||||
|
|
||||||
from datasette.utils import table_column_details
|
from datasette.utils import table_column_details
|
||||||
|
|
||||||
INTERNAL_DB_SCHEMA_TABLES = {
|
|
||||||
"catalog_databases",
|
|
||||||
"catalog_tables",
|
|
||||||
"catalog_views",
|
|
||||||
"catalog_columns",
|
|
||||||
"catalog_indexes",
|
|
||||||
"catalog_foreign_keys",
|
|
||||||
"metadata_instance",
|
|
||||||
"metadata_databases",
|
|
||||||
"metadata_resources",
|
|
||||||
"metadata_columns",
|
|
||||||
"column_types",
|
|
||||||
"queries",
|
|
||||||
}
|
|
||||||
|
|
||||||
INTERNAL_DB_SCHEMA_INDEXES = {
|
async def init_internal_db(db):
|
||||||
"queries_owner_idx",
|
create_tables_sql = textwrap.dedent("""
|
||||||
}
|
|
||||||
|
|
||||||
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
|
|
||||||
CREATE TABLE IF NOT EXISTS catalog_databases (
|
CREATE TABLE IF NOT EXISTS catalog_databases (
|
||||||
database_name TEXT PRIMARY KEY,
|
database_name TEXT PRIMARY KEY,
|
||||||
path TEXT,
|
path TEXT,
|
||||||
|
|
@ -88,7 +67,13 @@ INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
|
||||||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
||||||
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
||||||
);
|
);
|
||||||
|
""").strip()
|
||||||
|
await db.execute_write_script(create_tables_sql)
|
||||||
|
await initialize_metadata_tables(db)
|
||||||
|
|
||||||
|
|
||||||
|
async def initialize_metadata_tables(db):
|
||||||
|
await db.execute_write_script(textwrap.dedent("""
|
||||||
CREATE TABLE IF NOT EXISTS metadata_instance (
|
CREATE TABLE IF NOT EXISTS metadata_instance (
|
||||||
key text,
|
key text,
|
||||||
value text,
|
value text,
|
||||||
|
|
@ -127,57 +112,7 @@ INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
|
||||||
config TEXT,
|
config TEXT,
|
||||||
PRIMARY KEY (database_name, resource_name, column_name)
|
PRIMARY KEY (database_name, resource_name, column_name)
|
||||||
);
|
);
|
||||||
|
"""))
|
||||||
CREATE TABLE IF NOT EXISTS queries (
|
|
||||||
database_name TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sql TEXT NOT NULL,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
description_html TEXT,
|
|
||||||
options TEXT NOT NULL DEFAULT '{}',
|
|
||||||
parameters TEXT NOT NULL DEFAULT '[]',
|
|
||||||
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
|
|
||||||
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
|
|
||||||
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
|
|
||||||
source TEXT NOT NULL DEFAULT 'user',
|
|
||||||
owner_id TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (database_name, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS queries_owner_idx
|
|
||||||
ON queries(owner_id);
|
|
||||||
""").strip()
|
|
||||||
|
|
||||||
|
|
||||||
internal_migrations = Migrations("datasette_internal")
|
|
||||||
|
|
||||||
|
|
||||||
def _internal_schema_exists(db):
|
|
||||||
table_names = set(db.table_names())
|
|
||||||
if not INTERNAL_DB_SCHEMA_TABLES.issubset(table_names):
|
|
||||||
return False
|
|
||||||
index_names = {
|
|
||||||
row[0]
|
|
||||||
for row in db.execute("select name from sqlite_master where type = 'index'")
|
|
||||||
}
|
|
||||||
return INTERNAL_DB_SCHEMA_INDEXES.issubset(index_names)
|
|
||||||
|
|
||||||
|
|
||||||
@internal_migrations(name="0001_initial")
|
|
||||||
def initial_internal_schema(db):
|
|
||||||
if _internal_schema_exists(db):
|
|
||||||
return
|
|
||||||
db.executescript(INTERNAL_DB_SCHEMA_SQL)
|
|
||||||
|
|
||||||
|
|
||||||
async def init_internal_db(db):
|
|
||||||
def apply_migrations(conn):
|
|
||||||
internal_migrations.apply(SQLiteUtilsDatabase(conn, execute_plugins=False))
|
|
||||||
|
|
||||||
await db.execute_write_fn(apply_migrations, transaction=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def populate_schema_tables(internal_db, db):
|
async def populate_schema_tables(internal_db, db):
|
||||||
|
|
|
||||||
|
|
@ -1,553 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from datasette.utils.sqlite import SQLiteTableType, sqlite3, sqlite_table_type
|
|
||||||
|
|
||||||
SQLOperation = Literal[
|
|
||||||
"read",
|
|
||||||
"insert",
|
|
||||||
"update",
|
|
||||||
"delete",
|
|
||||||
"select",
|
|
||||||
"function",
|
|
||||||
"create",
|
|
||||||
"alter",
|
|
||||||
"drop",
|
|
||||||
"begin",
|
|
||||||
"commit",
|
|
||||||
"rollback",
|
|
||||||
"savepoint",
|
|
||||||
"attach",
|
|
||||||
"detach",
|
|
||||||
"pragma",
|
|
||||||
"analyze",
|
|
||||||
"reindex",
|
|
||||||
"vacuum",
|
|
||||||
"unknown",
|
|
||||||
]
|
|
||||||
SQLTargetType = Literal[
|
|
||||||
"table",
|
|
||||||
"index",
|
|
||||||
"view",
|
|
||||||
"trigger",
|
|
||||||
"virtual-table",
|
|
||||||
"schema",
|
|
||||||
"statement",
|
|
||||||
"transaction",
|
|
||||||
"database",
|
|
||||||
"pragma",
|
|
||||||
"function",
|
|
||||||
"unknown",
|
|
||||||
]
|
|
||||||
SQLTableOperation = Literal["read", "insert", "update", "delete"]
|
|
||||||
SQLSchemaOperation = Literal["create", "drop"]
|
|
||||||
SQLSchemaTargetType = Literal["index", "table", "trigger", "view", "virtual-table"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Operation:
|
|
||||||
operation: SQLOperation
|
|
||||||
target_type: SQLTargetType
|
|
||||||
database: str | None
|
|
||||||
table: str | None
|
|
||||||
sqlite_schema: str | None
|
|
||||||
table_kind: SQLiteTableType | None = None
|
|
||||||
target: str | None = None
|
|
||||||
columns: tuple[str, ...] = ()
|
|
||||||
source: str | None = None
|
|
||||||
internal: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SQLAnalysis:
|
|
||||||
operations: tuple[Operation, ...]
|
|
||||||
|
|
||||||
|
|
||||||
# Hashable dict key for grouping repeated authorizer callbacks while collecting columns.
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class OperationKey:
|
|
||||||
operation: SQLOperation
|
|
||||||
target_type: SQLTargetType
|
|
||||||
database: str | None
|
|
||||||
table: str | None
|
|
||||||
sqlite_schema: str | None
|
|
||||||
target: str | None
|
|
||||||
source: str | None
|
|
||||||
internal: bool
|
|
||||||
|
|
||||||
|
|
||||||
_ACTION_TO_OPERATION: dict[int, SQLTableOperation] = {
|
|
||||||
sqlite3.SQLITE_READ: "read",
|
|
||||||
sqlite3.SQLITE_INSERT: "insert",
|
|
||||||
sqlite3.SQLITE_UPDATE: "update",
|
|
||||||
sqlite3.SQLITE_DELETE: "delete",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Values are (operation, target_type) pairs used to construct Operation objects.
|
|
||||||
_CREATE_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
|
|
||||||
sqlite3.SQLITE_CREATE_INDEX: ("create", "index"),
|
|
||||||
sqlite3.SQLITE_CREATE_TABLE: ("create", "table"),
|
|
||||||
sqlite3.SQLITE_CREATE_TRIGGER: ("create", "trigger"),
|
|
||||||
sqlite3.SQLITE_CREATE_VIEW: ("create", "view"),
|
|
||||||
}
|
|
||||||
_DROP_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
|
|
||||||
sqlite3.SQLITE_DROP_INDEX: ("drop", "index"),
|
|
||||||
sqlite3.SQLITE_DROP_TABLE: ("drop", "table"),
|
|
||||||
sqlite3.SQLITE_DROP_TRIGGER: ("drop", "trigger"),
|
|
||||||
sqlite3.SQLITE_DROP_VIEW: ("drop", "view"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _add_schema_action(
|
|
||||||
action_name: str,
|
|
||||||
operation: SQLSchemaOperation,
|
|
||||||
target_type: SQLSchemaTargetType,
|
|
||||||
) -> None:
|
|
||||||
action_value = getattr(sqlite3, action_name, None)
|
|
||||||
if action_value is not None:
|
|
||||||
actions = _CREATE_ACTIONS if operation == "create" else _DROP_ACTIONS
|
|
||||||
actions[action_value] = (operation, target_type)
|
|
||||||
|
|
||||||
|
|
||||||
_TEMP_SCHEMA_ACTIONS: tuple[
|
|
||||||
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
|
|
||||||
] = (
|
|
||||||
("SQLITE_CREATE_TEMP_INDEX", "create", "index"),
|
|
||||||
("SQLITE_CREATE_TEMP_TABLE", "create", "table"),
|
|
||||||
("SQLITE_CREATE_TEMP_TRIGGER", "create", "trigger"),
|
|
||||||
("SQLITE_CREATE_TEMP_VIEW", "create", "view"),
|
|
||||||
("SQLITE_DROP_TEMP_INDEX", "drop", "index"),
|
|
||||||
("SQLITE_DROP_TEMP_TABLE", "drop", "table"),
|
|
||||||
("SQLITE_DROP_TEMP_TRIGGER", "drop", "trigger"),
|
|
||||||
("SQLITE_DROP_TEMP_VIEW", "drop", "view"),
|
|
||||||
)
|
|
||||||
for schema_action in _TEMP_SCHEMA_ACTIONS:
|
|
||||||
_add_schema_action(*schema_action)
|
|
||||||
|
|
||||||
_VTABLE_SCHEMA_ACTIONS: tuple[
|
|
||||||
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
|
|
||||||
] = (
|
|
||||||
("SQLITE_CREATE_VTABLE", "create", "virtual-table"),
|
|
||||||
("SQLITE_DROP_VTABLE", "drop", "virtual-table"),
|
|
||||||
)
|
|
||||||
for schema_action in _VTABLE_SCHEMA_ACTIONS:
|
|
||||||
_add_schema_action(*schema_action)
|
|
||||||
|
|
||||||
_SQLITE_SCHEMA_TABLES = {
|
|
||||||
"sqlite_master",
|
|
||||||
"sqlite_schema",
|
|
||||||
"sqlite_temp_master",
|
|
||||||
"sqlite_temp_schema",
|
|
||||||
}
|
|
||||||
_SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
|
|
||||||
"length",
|
|
||||||
"like",
|
|
||||||
"printf",
|
|
||||||
"sqlite_drop_column",
|
|
||||||
"sqlite_rename_column",
|
|
||||||
"sqlite_rename_quotefix",
|
|
||||||
"sqlite_rename_table",
|
|
||||||
"sqlite_rename_test",
|
|
||||||
"substr",
|
|
||||||
}
|
|
||||||
_AUTHORIZER_ACTION_NAMES = {
|
|
||||||
getattr(sqlite3, name): name
|
|
||||||
for name in (
|
|
||||||
"SQLITE_CREATE_INDEX",
|
|
||||||
"SQLITE_CREATE_TABLE",
|
|
||||||
"SQLITE_CREATE_TEMP_INDEX",
|
|
||||||
"SQLITE_CREATE_TEMP_TABLE",
|
|
||||||
"SQLITE_CREATE_TEMP_TRIGGER",
|
|
||||||
"SQLITE_CREATE_TEMP_VIEW",
|
|
||||||
"SQLITE_CREATE_TRIGGER",
|
|
||||||
"SQLITE_CREATE_VIEW",
|
|
||||||
"SQLITE_DELETE",
|
|
||||||
"SQLITE_DROP_INDEX",
|
|
||||||
"SQLITE_DROP_TABLE",
|
|
||||||
"SQLITE_DROP_TEMP_INDEX",
|
|
||||||
"SQLITE_DROP_TEMP_TABLE",
|
|
||||||
"SQLITE_DROP_TEMP_TRIGGER",
|
|
||||||
"SQLITE_DROP_TEMP_VIEW",
|
|
||||||
"SQLITE_DROP_TRIGGER",
|
|
||||||
"SQLITE_DROP_VIEW",
|
|
||||||
"SQLITE_INSERT",
|
|
||||||
"SQLITE_PRAGMA",
|
|
||||||
"SQLITE_READ",
|
|
||||||
"SQLITE_SELECT",
|
|
||||||
"SQLITE_TRANSACTION",
|
|
||||||
"SQLITE_UPDATE",
|
|
||||||
"SQLITE_ATTACH",
|
|
||||||
"SQLITE_DETACH",
|
|
||||||
"SQLITE_ALTER_TABLE",
|
|
||||||
"SQLITE_REINDEX",
|
|
||||||
"SQLITE_ANALYZE",
|
|
||||||
"SQLITE_CREATE_VTABLE",
|
|
||||||
"SQLITE_DROP_VTABLE",
|
|
||||||
"SQLITE_FUNCTION",
|
|
||||||
"SQLITE_SAVEPOINT",
|
|
||||||
"SQLITE_RECURSIVE",
|
|
||||||
)
|
|
||||||
if hasattr(sqlite3, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _allow_authorizer_action(*args):
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_sql_tables(
|
|
||||||
conn,
|
|
||||||
sql: str,
|
|
||||||
params=None,
|
|
||||||
*,
|
|
||||||
database_name: str | None = None,
|
|
||||||
schema_to_database: dict[str, str] | None = None,
|
|
||||||
) -> SQLAnalysis:
|
|
||||||
"""
|
|
||||||
Return operations performed by a SQL statement according to SQLite's authorizer.
|
|
||||||
|
|
||||||
This function is synchronous and connection-based. It temporarily installs a
|
|
||||||
SQLite authorizer, prepares ``EXPLAIN <sql>``, and returns the operation
|
|
||||||
callbacks observed while SQLite compiles the statement.
|
|
||||||
"""
|
|
||||||
operations: dict[OperationKey, set[str]] = {}
|
|
||||||
|
|
||||||
def database_for_schema(sqlite_schema):
|
|
||||||
if schema_to_database and sqlite_schema in schema_to_database:
|
|
||||||
return schema_to_database[sqlite_schema]
|
|
||||||
if sqlite_schema == "main" and database_name is not None:
|
|
||||||
return database_name
|
|
||||||
return sqlite_schema
|
|
||||||
|
|
||||||
def record(
|
|
||||||
operation: SQLOperation,
|
|
||||||
target_type: SQLTargetType,
|
|
||||||
*,
|
|
||||||
database: str | None,
|
|
||||||
table: str | None,
|
|
||||||
sqlite_schema: str | None,
|
|
||||||
target: str | None,
|
|
||||||
source: str | None,
|
|
||||||
column: str | None = None,
|
|
||||||
internal: bool = False,
|
|
||||||
):
|
|
||||||
key = OperationKey(
|
|
||||||
operation=operation,
|
|
||||||
target_type=target_type,
|
|
||||||
database=database,
|
|
||||||
table=table,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=target,
|
|
||||||
source=source,
|
|
||||||
internal=internal,
|
|
||||||
)
|
|
||||||
columns = operations.setdefault(key, set())
|
|
||||||
if column is not None:
|
|
||||||
columns.add(column)
|
|
||||||
|
|
||||||
def authorizer(action, arg1, arg2, sqlite_schema, source):
|
|
||||||
operation = _ACTION_TO_OPERATION.get(action)
|
|
||||||
if operation is not None and arg1 is not None:
|
|
||||||
target_type = "schema" if arg1 in _SQLITE_SCHEMA_TABLES else "table"
|
|
||||||
column = (
|
|
||||||
arg2 if operation in ("read", "update") and arg2 is not None else None
|
|
||||||
)
|
|
||||||
record(
|
|
||||||
operation,
|
|
||||||
target_type,
|
|
||||||
database=database_for_schema(sqlite_schema),
|
|
||||||
table=arg1 if target_type == "table" else None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
column=column,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
create_operation = _CREATE_ACTIONS.get(action)
|
|
||||||
if create_operation is not None and arg1 is not None:
|
|
||||||
operation, target_type = create_operation
|
|
||||||
related_table = arg2 if target_type in {"index", "trigger"} else arg1
|
|
||||||
record(
|
|
||||||
operation,
|
|
||||||
target_type,
|
|
||||||
database=database_for_schema(sqlite_schema),
|
|
||||||
table=related_table,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
drop_operation = _DROP_ACTIONS.get(action)
|
|
||||||
if drop_operation is not None and arg1 is not None:
|
|
||||||
operation, target_type = drop_operation
|
|
||||||
related_table = arg2 if target_type in {"index", "trigger"} else arg1
|
|
||||||
record(
|
|
||||||
operation,
|
|
||||||
target_type,
|
|
||||||
database=database_for_schema(sqlite_schema),
|
|
||||||
table=related_table,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_ALTER_TABLE and arg2 is not None:
|
|
||||||
record(
|
|
||||||
"alter",
|
|
||||||
"table",
|
|
||||||
database=database_for_schema(arg1),
|
|
||||||
table=arg2,
|
|
||||||
sqlite_schema=arg1,
|
|
||||||
target=arg2,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_TRANSACTION and arg1 is not None:
|
|
||||||
record(
|
|
||||||
arg1.lower(),
|
|
||||||
"transaction",
|
|
||||||
database=None,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=None,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_ATTACH and arg1 is not None:
|
|
||||||
record(
|
|
||||||
"attach",
|
|
||||||
"database",
|
|
||||||
database=None,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=None,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_DETACH and arg1 is not None:
|
|
||||||
record(
|
|
||||||
"detach",
|
|
||||||
"database",
|
|
||||||
database=None,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=None,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_PRAGMA and arg1 is not None:
|
|
||||||
record(
|
|
||||||
"pragma",
|
|
||||||
"pragma",
|
|
||||||
database=None,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_ANALYZE:
|
|
||||||
record(
|
|
||||||
"analyze",
|
|
||||||
"database" if arg1 is None else "table",
|
|
||||||
database=database_for_schema(sqlite_schema),
|
|
||||||
table=arg1,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_REINDEX and arg1 is not None:
|
|
||||||
record(
|
|
||||||
"reindex",
|
|
||||||
"index",
|
|
||||||
database=database_for_schema(sqlite_schema),
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_SELECT:
|
|
||||||
record(
|
|
||||||
"select",
|
|
||||||
"statement",
|
|
||||||
database=None,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=None,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_RECURSIVE:
|
|
||||||
# Recursive CTE bookkeeping; table reads are reported separately.
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_FUNCTION and arg2 is not None:
|
|
||||||
record(
|
|
||||||
"function",
|
|
||||||
"function",
|
|
||||||
database=None,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=arg2,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_SAVEPOINT and arg1 is not None:
|
|
||||||
record(
|
|
||||||
"savepoint",
|
|
||||||
"transaction",
|
|
||||||
database=None,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
|
|
||||||
record(
|
|
||||||
"unknown",
|
|
||||||
"unknown",
|
|
||||||
database=database_for_schema(sqlite_schema),
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=action_name,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
return sqlite3.SQLITE_OK
|
|
||||||
|
|
||||||
table_kind_cache: dict[tuple[str | None, str], SQLiteTableType | None] = {}
|
|
||||||
|
|
||||||
conn.set_authorizer(authorizer)
|
|
||||||
try:
|
|
||||||
explain_rows = conn.execute(
|
|
||||||
"EXPLAIN " + sql, params if params is not None else {}
|
|
||||||
).fetchall()
|
|
||||||
# Passing None before these lookups leaves a failing callback installed
|
|
||||||
# on Python 3.10, so use a permissive callback until they are complete.
|
|
||||||
conn.set_authorizer(_allow_authorizer_action)
|
|
||||||
|
|
||||||
if not operations:
|
|
||||||
vacuum_row = next((row for row in explain_rows if row[1] == "Vacuum"), None)
|
|
||||||
if vacuum_row is not None:
|
|
||||||
schema_by_index = {
|
|
||||||
row[0]: row[1] for row in conn.execute("PRAGMA database_list")
|
|
||||||
}
|
|
||||||
sqlite_schema = schema_by_index.get(vacuum_row[2])
|
|
||||||
database = database_for_schema(sqlite_schema)
|
|
||||||
record(
|
|
||||||
"vacuum",
|
|
||||||
"database",
|
|
||||||
database=database,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=sqlite_schema,
|
|
||||||
target=database,
|
|
||||||
source=None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
record(
|
|
||||||
"unknown",
|
|
||||||
"statement",
|
|
||||||
database=database_name,
|
|
||||||
table=None,
|
|
||||||
sqlite_schema=None,
|
|
||||||
target=None,
|
|
||||||
source=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
for key in operations:
|
|
||||||
if (
|
|
||||||
key.target_type == "table"
|
|
||||||
and key.operation in {"read", "insert", "update", "delete"}
|
|
||||||
and key.table is not None
|
|
||||||
):
|
|
||||||
cache_key = (key.sqlite_schema, key.table)
|
|
||||||
if cache_key not in table_kind_cache:
|
|
||||||
table_kind_cache[cache_key] = sqlite_table_type(
|
|
||||||
conn, key.table, schema=key.sqlite_schema
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn.set_authorizer(None)
|
|
||||||
|
|
||||||
has_schema_operation = any(
|
|
||||||
key.target_type in {"table", "index", "view", "trigger", "virtual-table"}
|
|
||||||
and key.operation in {"create", "alter", "drop"}
|
|
||||||
for key in operations
|
|
||||||
)
|
|
||||||
dropped_tables_and_views = {
|
|
||||||
(key.database, key.table)
|
|
||||||
for key in operations
|
|
||||||
if key.operation == "drop" and key.target_type in {"table", "view"}
|
|
||||||
}
|
|
||||||
|
|
||||||
def key_is_drop_table_delete(key: OperationKey) -> bool:
|
|
||||||
return (
|
|
||||||
key.operation == "delete"
|
|
||||||
and key.target_type == "table"
|
|
||||||
and (key.database, key.table) in dropped_tables_and_views
|
|
||||||
)
|
|
||||||
|
|
||||||
has_user_table_access_in_schema_operation = any(
|
|
||||||
key.operation in {"read", "insert", "update", "delete"}
|
|
||||||
and key.target_type == "table"
|
|
||||||
and not key.internal
|
|
||||||
and not key_is_drop_table_delete(key)
|
|
||||||
for key in operations
|
|
||||||
)
|
|
||||||
|
|
||||||
def operation_is_internal(key: OperationKey) -> bool:
|
|
||||||
if key.internal or (has_schema_operation and key.target_type == "schema"):
|
|
||||||
return True
|
|
||||||
if has_schema_operation and key.operation == "reindex":
|
|
||||||
return True
|
|
||||||
if (
|
|
||||||
has_schema_operation
|
|
||||||
and not has_user_table_access_in_schema_operation
|
|
||||||
and key.operation == "function"
|
|
||||||
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
if key_is_drop_table_delete(key):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
|
|
||||||
if (
|
|
||||||
key.target_type != "table"
|
|
||||||
or key.operation not in {"read", "insert", "update", "delete"}
|
|
||||||
or key.table is None
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return table_kind_cache[(key.sqlite_schema, key.table)]
|
|
||||||
|
|
||||||
return SQLAnalysis(
|
|
||||||
operations=tuple(
|
|
||||||
Operation(
|
|
||||||
operation=key.operation,
|
|
||||||
target_type=key.target_type,
|
|
||||||
database=key.database,
|
|
||||||
table=key.table,
|
|
||||||
sqlite_schema=key.sqlite_schema,
|
|
||||||
table_kind=table_kind_for(key),
|
|
||||||
target=key.target,
|
|
||||||
columns=tuple(sorted(columns)),
|
|
||||||
source=key.source,
|
|
||||||
internal=operation_is_internal(key),
|
|
||||||
)
|
|
||||||
for key, columns in operations.items()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import re
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
using_pysqlite3 = False
|
using_pysqlite3 = False
|
||||||
try:
|
try:
|
||||||
import pysqlite3 as sqlite3
|
import pysqlite3 as sqlite3
|
||||||
|
|
@ -13,19 +10,6 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
|
||||||
sqlite3.enable_callback_tracebacks(True)
|
sqlite3.enable_callback_tracebacks(True)
|
||||||
|
|
||||||
_cached_sqlite_version = None
|
_cached_sqlite_version = None
|
||||||
_cached_supports_returning = None
|
|
||||||
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
|
|
||||||
_VIRTUAL_TABLE_MODULE_RE = re.compile(
|
|
||||||
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
|
|
||||||
"fts3": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
|
||||||
"fts4": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
|
||||||
"fts5": ("_data", "_idx", "_docsize", "_content", "_config"),
|
|
||||||
"rtree": ("_node", "_parent", "_rowid"),
|
|
||||||
"rtree_i32": ("_node", "_parent", "_rowid"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_version():
|
def sqlite_version():
|
||||||
|
|
@ -52,146 +36,5 @@ def supports_table_xinfo():
|
||||||
return sqlite_version() >= (3, 26, 0)
|
return sqlite_version() >= (3, 26, 0)
|
||||||
|
|
||||||
|
|
||||||
def supports_table_list():
|
|
||||||
return sqlite_version() >= (3, 37, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def supports_generated_columns():
|
def supports_generated_columns():
|
||||||
return sqlite_version() >= (3, 31, 0)
|
return sqlite_version() >= (3, 31, 0)
|
||||||
|
|
||||||
|
|
||||||
def supports_returning():
|
|
||||||
global _cached_supports_returning
|
|
||||||
if _cached_supports_returning is None:
|
|
||||||
conn = sqlite3.connect(":memory:")
|
|
||||||
try:
|
|
||||||
conn.execute("create table t (id integer primary key)")
|
|
||||||
conn.execute("insert into t default values returning id").fetchone()
|
|
||||||
_cached_supports_returning = True
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
_cached_supports_returning = False
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return _cached_supports_returning
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_table_type(
|
|
||||||
conn,
|
|
||||||
table: str,
|
|
||||||
*,
|
|
||||||
schema: str | None = "main",
|
|
||||||
) -> SQLiteTableType | None:
|
|
||||||
if supports_table_list():
|
|
||||||
try:
|
|
||||||
query = "select type from pragma_table_list where name = ?"
|
|
||||||
params: tuple[str, ...] = (table,)
|
|
||||||
if schema is not None:
|
|
||||||
query += " and schema = ?"
|
|
||||||
params = (table, schema)
|
|
||||||
row = conn.execute(query, params).fetchone()
|
|
||||||
if row is not None and row[0] in {"table", "view", "virtual", "shadow"}:
|
|
||||||
return row[0]
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
pass
|
|
||||||
return _sqlite_table_type_from_schema(conn, table, schema=schema)
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
|
|
||||||
schema_table = _sqlite_schema_table(schema)
|
|
||||||
try:
|
|
||||||
rows = conn.execute(
|
|
||||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
|
||||||
).fetchall()
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return []
|
|
||||||
hidden_tables = []
|
|
||||||
content_fts_tables = []
|
|
||||||
for name, sql in rows:
|
|
||||||
if (
|
|
||||||
name in {"sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"}
|
|
||||||
or name.startswith("_")
|
|
||||||
or sqlite_table_type(conn, name, schema=schema) == "shadow"
|
|
||||||
):
|
|
||||||
hidden_tables.append(name)
|
|
||||||
elif _is_fts_content_virtual_table(sql):
|
|
||||||
content_fts_tables.append(name)
|
|
||||||
return sorted(hidden_tables) + content_fts_tables
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_table_type_from_schema(
|
|
||||||
conn,
|
|
||||||
table: str,
|
|
||||||
*,
|
|
||||||
schema: str | None = "main",
|
|
||||||
) -> SQLiteTableType | None:
|
|
||||||
schema_table = _sqlite_schema_table(schema)
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"select type, sql from {} where name = ?".format(schema_table),
|
|
||||||
(table,),
|
|
||||||
).fetchone()
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return None
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
object_type, sql = row
|
|
||||||
if object_type == "view":
|
|
||||||
return "view"
|
|
||||||
if object_type != "table":
|
|
||||||
return None
|
|
||||||
if _virtual_table_module(sql) is not None:
|
|
||||||
return "virtual"
|
|
||||||
if _is_known_shadow_table(conn, table, schema=schema):
|
|
||||||
return "shadow"
|
|
||||||
return "table"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_known_shadow_table(
|
|
||||||
conn,
|
|
||||||
table: str,
|
|
||||||
*,
|
|
||||||
schema: str | None = "main",
|
|
||||||
) -> bool:
|
|
||||||
schema_table = _sqlite_schema_table(schema)
|
|
||||||
try:
|
|
||||||
rows = conn.execute(
|
|
||||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
|
||||||
).fetchall()
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return False
|
|
||||||
for virtual_table, sql in rows:
|
|
||||||
module = _virtual_table_module(sql)
|
|
||||||
if module is None:
|
|
||||||
continue
|
|
||||||
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
|
|
||||||
if table == virtual_table + suffix:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_schema_table(schema: str | None) -> str:
|
|
||||||
if schema is None or schema == "main":
|
|
||||||
return "sqlite_master"
|
|
||||||
if schema == "temp":
|
|
||||||
return "sqlite_temp_master"
|
|
||||||
return "{}.sqlite_master".format(_quote_identifier(schema))
|
|
||||||
|
|
||||||
|
|
||||||
def _quote_identifier(value: str) -> str:
|
|
||||||
return '"{}"'.format(value.replace('"', '""'))
|
|
||||||
|
|
||||||
|
|
||||||
def _virtual_table_module(sql: str | None) -> str | None:
|
|
||||||
if not sql:
|
|
||||||
return None
|
|
||||||
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
|
|
||||||
if match is None:
|
|
||||||
return None
|
|
||||||
return match.group(1).strip("\"'[]`").lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fts_content_virtual_table(sql: str | None) -> bool:
|
|
||||||
return (
|
|
||||||
_virtual_table_module(sql) in {"fts3", "fts4", "fts5"}
|
|
||||||
and "content=" in sql.lower()
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
__version__ = "1.0a36"
|
__version__ = "1.0a30"
|
||||||
__version_info__ = tuple(__version__.split("."))
|
__version_info__ = tuple(__version__.split("."))
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,2 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
import dataclasses
|
|
||||||
import types
|
|
||||||
import typing
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ContextField:
|
|
||||||
name: str
|
|
||||||
type_name: str
|
|
||||||
help: str
|
|
||||||
from_extra: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def _type_name(type_):
|
|
||||||
if type_ is type(None):
|
|
||||||
return "None"
|
|
||||||
origin = typing.get_origin(type_)
|
|
||||||
args = typing.get_args(type_)
|
|
||||||
if origin in (typing.Union, types.UnionType):
|
|
||||||
return " | ".join(_type_name(arg) for arg in args)
|
|
||||||
if origin is not None:
|
|
||||||
name = getattr(origin, "__name__", str(origin).removeprefix("typing."))
|
|
||||||
return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args))
|
|
||||||
return getattr(type_, "__name__", str(type_).removeprefix("typing."))
|
|
||||||
|
|
||||||
|
|
||||||
def from_extra():
|
|
||||||
"""
|
|
||||||
Declare a Context dataclass field whose value comes from a registered
|
|
||||||
Extra of the same name - its documentation is the Extra description,
|
|
||||||
so the doc string lives next to the resolve() code rather than being
|
|
||||||
duplicated on the dataclass.
|
|
||||||
"""
|
|
||||||
return dataclasses.field(metadata={"from_extra": True})
|
|
||||||
|
|
||||||
|
|
||||||
class Context:
|
class Context:
|
||||||
"Base class for all documented contexts"
|
"Base class for all documented contexts"
|
||||||
|
|
||||||
# Set on subclasses whose from_extra() fields should be resolved
|
|
||||||
# against the extras registry for this scope
|
|
||||||
extras_scope = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def documented_fields(cls):
|
|
||||||
"List of ContextField describing the documented fields of this context"
|
|
||||||
documented = []
|
|
||||||
for f in dataclasses.fields(cls):
|
|
||||||
if f.name.startswith("_"):
|
|
||||||
continue
|
|
||||||
is_from_extra = bool(f.metadata.get("from_extra"))
|
|
||||||
if is_from_extra:
|
|
||||||
help_text = cls._extra_description(f.name)
|
|
||||||
else:
|
|
||||||
help_text = f.metadata.get("help", "")
|
|
||||||
documented.append(
|
|
||||||
ContextField(
|
|
||||||
name=f.name,
|
|
||||||
type_name=_type_name(f.type),
|
|
||||||
help=help_text,
|
|
||||||
from_extra=is_from_extra,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return documented
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extra_description(cls, name):
|
|
||||||
# Imported lazily - table_extras is not needed just to define
|
|
||||||
# Context subclasses
|
|
||||||
from datasette.views.table_extras import table_extra_registry
|
|
||||||
|
|
||||||
try:
|
|
||||||
extra_class = table_extra_registry.classes_by_name[name]
|
|
||||||
except KeyError:
|
|
||||||
raise KeyError(
|
|
||||||
"{}.{} is declared with from_extra() but there is no "
|
|
||||||
"registered extra of that name".format(cls.__name__, name)
|
|
||||||
)
|
|
||||||
if cls.extras_scope is not None and not extra_class.available_for(
|
|
||||||
cls.extras_scope
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"{}.{} is declared with from_extra() but the {} extra is "
|
|
||||||
"not available for scope {}".format(
|
|
||||||
cls.__name__, name, name, cls.extras_scope
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return extra_class.description or ""
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,31 @@
|
||||||
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
import sys
|
||||||
|
import textwrap
|
||||||
|
import time
|
||||||
|
import urllib
|
||||||
|
from markupsafe import escape
|
||||||
|
|
||||||
|
|
||||||
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.utils.asgi import Request
|
from datasette.utils.asgi import Request
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
|
await_me_maybe,
|
||||||
EscapeHtmlWriter,
|
EscapeHtmlWriter,
|
||||||
InvalidSql,
|
InvalidSql,
|
||||||
LimitedWriter,
|
LimitedWriter,
|
||||||
|
call_with_supported_arguments,
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
|
path_with_added_args,
|
||||||
|
path_with_removed_args,
|
||||||
path_with_format,
|
path_with_format,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import (
|
from datasette.utils.asgi import (
|
||||||
AsgiStream,
|
AsgiStream,
|
||||||
|
NotFound,
|
||||||
Response,
|
Response,
|
||||||
BadRequest,
|
BadRequest,
|
||||||
)
|
)
|
||||||
|
|
@ -28,15 +40,12 @@ class DatasetteError(Exception):
|
||||||
status=500,
|
status=500,
|
||||||
template=None,
|
template=None,
|
||||||
message_is_html=False,
|
message_is_html=False,
|
||||||
plain_message=None,
|
|
||||||
):
|
):
|
||||||
self.message = message
|
self.message = message
|
||||||
self.title = title
|
self.title = title
|
||||||
self.error_dict = error_dict or {}
|
self.error_dict = error_dict or {}
|
||||||
self.status = status
|
self.status = status
|
||||||
self.message_is_html = message_is_html
|
self.message_is_html = message_is_html
|
||||||
# Plain text used for JSON error responses when message is HTML
|
|
||||||
self.plain_message = plain_message
|
|
||||||
|
|
||||||
|
|
||||||
class View:
|
class View:
|
||||||
|
|
@ -52,7 +61,9 @@ class View:
|
||||||
request.path.endswith(".json")
|
request.path.endswith(".json")
|
||||||
or request.headers.get("content-type") == "application/json"
|
or request.headers.get("content-type") == "application/json"
|
||||||
):
|
):
|
||||||
response = Response.error("Method not allowed", 405)
|
response = Response.json(
|
||||||
|
{"ok": False, "error": "Method not allowed"}, status=405
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
response = Response.text("Method not allowed", status=405)
|
response = Response.text("Method not allowed", status=405)
|
||||||
return response
|
return response
|
||||||
|
|
@ -91,7 +102,9 @@ class BaseView:
|
||||||
request.path.endswith(".json")
|
request.path.endswith(".json")
|
||||||
or request.headers.get("content-type") == "application/json"
|
or request.headers.get("content-type") == "application/json"
|
||||||
):
|
):
|
||||||
response = Response.error("Method not allowed", 405)
|
response = Response.json(
|
||||||
|
{"ok": False, "error": "Method not allowed"}, status=405
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
response = Response.text("Method not allowed", status=405)
|
response = Response.text("Method not allowed", status=405)
|
||||||
return response
|
return response
|
||||||
|
|
@ -140,13 +153,7 @@ class BaseView:
|
||||||
if self.has_json_alternate:
|
if self.has_json_alternate:
|
||||||
alternate_url_json = self.ds.absolute_url(
|
alternate_url_json = self.ds.absolute_url(
|
||||||
request,
|
request,
|
||||||
self.ds.urls.path(
|
self.ds.urls.path(path_with_format(request=request, format="json")),
|
||||||
path_with_format(
|
|
||||||
request=request,
|
|
||||||
path=request.scope.get("route_path"),
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
template_context["alternate_url_json"] = alternate_url_json
|
template_context["alternate_url_json"] = alternate_url_json
|
||||||
headers.update(
|
headers.update(
|
||||||
|
|
@ -179,6 +186,223 @@ class BaseView:
|
||||||
return view
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
class DataView(BaseView):
|
||||||
|
name = ""
|
||||||
|
|
||||||
|
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
||||||
|
if request.query_string and "?" not in path and forward_querystring:
|
||||||
|
path = f"{path}?{request.query_string}"
|
||||||
|
if remove_args:
|
||||||
|
path = path_with_removed_args(request, remove_args, path=path)
|
||||||
|
r = Response.redirect(path)
|
||||||
|
r.headers["Link"] = f"<{path}>; rel=preload"
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(r.headers)
|
||||||
|
return r
|
||||||
|
|
||||||
|
async def data(self, request):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def as_csv(self, request, database):
|
||||||
|
return await stream_csv(self.ds, self.data, request, database)
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
db = await self.ds.resolve_database(request)
|
||||||
|
database = db.name
|
||||||
|
database_route = db.route
|
||||||
|
|
||||||
|
_format = request.url_vars["format"]
|
||||||
|
data_kwargs = {}
|
||||||
|
|
||||||
|
if _format == "csv":
|
||||||
|
return await self.as_csv(request, database_route)
|
||||||
|
|
||||||
|
if _format is None:
|
||||||
|
# HTML views default to expanding all foreign key labels
|
||||||
|
data_kwargs["default_labels"] = True
|
||||||
|
|
||||||
|
extra_template_data = {}
|
||||||
|
start = time.perf_counter()
|
||||||
|
status_code = None
|
||||||
|
templates = []
|
||||||
|
try:
|
||||||
|
response_or_template_contexts = await self.data(request, **data_kwargs)
|
||||||
|
if isinstance(response_or_template_contexts, Response):
|
||||||
|
return response_or_template_contexts
|
||||||
|
# If it has four items, it includes an HTTP status code
|
||||||
|
if len(response_or_template_contexts) == 4:
|
||||||
|
(
|
||||||
|
data,
|
||||||
|
extra_template_data,
|
||||||
|
templates,
|
||||||
|
status_code,
|
||||||
|
) = response_or_template_contexts
|
||||||
|
else:
|
||||||
|
data, extra_template_data, templates = response_or_template_contexts
|
||||||
|
except QueryInterrupted as ex:
|
||||||
|
raise DatasetteError(
|
||||||
|
textwrap.dedent("""
|
||||||
|
<p>SQL query took too long. The time limit is controlled by the
|
||||||
|
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||||
|
configuration option.</p>
|
||||||
|
<textarea style="width: 90%">{}</textarea>
|
||||||
|
<script>
|
||||||
|
let ta = document.querySelector("textarea");
|
||||||
|
ta.style.height = ta.scrollHeight + "px";
|
||||||
|
</script>
|
||||||
|
""".format(escape(ex.sql))).strip(),
|
||||||
|
title="SQL Interrupted",
|
||||||
|
status=400,
|
||||||
|
message_is_html=True,
|
||||||
|
)
|
||||||
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||||
|
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
raise DatasetteError(str(e))
|
||||||
|
|
||||||
|
except DatasetteError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
end = time.perf_counter()
|
||||||
|
data["query_ms"] = (end - start) * 1000
|
||||||
|
|
||||||
|
# Special case for .jsono extension - redirect to _shape=objects
|
||||||
|
if _format == "jsono":
|
||||||
|
return self.redirect(
|
||||||
|
request,
|
||||||
|
path_with_added_args(
|
||||||
|
request,
|
||||||
|
{"_shape": "objects"},
|
||||||
|
path=request.path.rsplit(".jsono", 1)[0] + ".json",
|
||||||
|
),
|
||||||
|
forward_querystring=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _format in self.ds.renderers.keys():
|
||||||
|
# Dispatch request to the correct output format renderer
|
||||||
|
# (CSV is not handled here due to streaming)
|
||||||
|
result = call_with_supported_arguments(
|
||||||
|
self.ds.renderers[_format][0],
|
||||||
|
datasette=self.ds,
|
||||||
|
columns=data.get("columns") or [],
|
||||||
|
rows=data.get("rows") or [],
|
||||||
|
sql=data.get("query", {}).get("sql", None),
|
||||||
|
query_name=data.get("query_name"),
|
||||||
|
database=database,
|
||||||
|
table=data.get("table"),
|
||||||
|
request=request,
|
||||||
|
view_name=self.name,
|
||||||
|
truncated=False, # TODO: support this
|
||||||
|
error=data.get("error"),
|
||||||
|
# These will be deprecated in Datasette 1.0:
|
||||||
|
args=request.args,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
result = await result
|
||||||
|
if result is None:
|
||||||
|
raise NotFound("No data")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
r = Response(
|
||||||
|
body=result.get("body"),
|
||||||
|
status=result.get("status_code", status_code or 200),
|
||||||
|
content_type=result.get("content_type", "text/plain"),
|
||||||
|
headers=result.get("headers"),
|
||||||
|
)
|
||||||
|
elif isinstance(result, Response):
|
||||||
|
r = result
|
||||||
|
if status_code is not None:
|
||||||
|
# Over-ride the status code
|
||||||
|
r.status = status_code
|
||||||
|
else:
|
||||||
|
assert False, f"{result} should be dict or Response"
|
||||||
|
else:
|
||||||
|
extras = {}
|
||||||
|
if callable(extra_template_data):
|
||||||
|
extras = extra_template_data()
|
||||||
|
if asyncio.iscoroutine(extras):
|
||||||
|
extras = await extras
|
||||||
|
else:
|
||||||
|
extras = extra_template_data
|
||||||
|
url_labels_extra = {}
|
||||||
|
if data.get("expandable_columns"):
|
||||||
|
url_labels_extra = {"_labels": "on"}
|
||||||
|
|
||||||
|
renderers = {}
|
||||||
|
for key, (_, can_render) in self.ds.renderers.items():
|
||||||
|
it_can_render = call_with_supported_arguments(
|
||||||
|
can_render,
|
||||||
|
datasette=self.ds,
|
||||||
|
columns=data.get("columns") or [],
|
||||||
|
rows=data.get("rows") or [],
|
||||||
|
sql=data.get("query", {}).get("sql", None),
|
||||||
|
query_name=data.get("query_name"),
|
||||||
|
database=database,
|
||||||
|
table=data.get("table"),
|
||||||
|
request=request,
|
||||||
|
view_name=self.name,
|
||||||
|
)
|
||||||
|
it_can_render = await await_me_maybe(it_can_render)
|
||||||
|
if it_can_render:
|
||||||
|
renderers[key] = self.ds.urls.path(
|
||||||
|
path_with_format(
|
||||||
|
request=request, format=key, extra_qs={**url_labels_extra}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
url_csv_args = {"_size": "max", **url_labels_extra}
|
||||||
|
url_csv = self.ds.urls.path(
|
||||||
|
path_with_format(request=request, format="csv", extra_qs=url_csv_args)
|
||||||
|
)
|
||||||
|
url_csv_path = url_csv.split("?")[0]
|
||||||
|
context = {
|
||||||
|
**data,
|
||||||
|
**extras,
|
||||||
|
**{
|
||||||
|
"renderers": renderers,
|
||||||
|
"url_csv": url_csv,
|
||||||
|
"url_csv_path": url_csv_path,
|
||||||
|
"url_csv_hidden_args": [
|
||||||
|
(key, value)
|
||||||
|
for key, value in urllib.parse.parse_qsl(request.query_string)
|
||||||
|
if key not in ("_labels", "_facet", "_size")
|
||||||
|
]
|
||||||
|
+ [("_size", "max")],
|
||||||
|
"settings": self.ds.settings_dict(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if "metadata" not in context:
|
||||||
|
context["metadata"] = await self.ds.get_instance_metadata()
|
||||||
|
r = await self.render(templates, request=request, context=context)
|
||||||
|
if status_code is not None:
|
||||||
|
r.status = status_code
|
||||||
|
|
||||||
|
ttl = request.args.get("_ttl", None)
|
||||||
|
if ttl is None or not ttl.isdigit():
|
||||||
|
ttl = self.ds.setting("default_cache_ttl")
|
||||||
|
|
||||||
|
return self.set_response_headers(r, ttl)
|
||||||
|
|
||||||
|
def set_response_headers(self, response, ttl):
|
||||||
|
# Set far-future cache expiry
|
||||||
|
if self.ds.cache_headers and response.status == 200:
|
||||||
|
ttl = int(ttl)
|
||||||
|
if ttl == 0:
|
||||||
|
ttl_header = "no-cache"
|
||||||
|
else:
|
||||||
|
ttl_header = f"max-age={ttl}"
|
||||||
|
response.headers["Cache-Control"] = ttl_header
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(response.headers)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _error(messages, status=400):
|
||||||
|
return Response.json({"ok": False, "errors": messages}, status=status)
|
||||||
|
|
||||||
|
|
||||||
async def stream_csv(datasette, fetch_data, request, database):
|
async def stream_csv(datasette, fetch_data, request, database):
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
stream = request.args.get("_stream")
|
stream = request.args.get("_stream")
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,505 +0,0 @@
|
||||||
import re
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from datasette.resources import DatabaseResource
|
|
||||||
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
|
|
||||||
from datasette.utils.asgi import Response
|
|
||||||
|
|
||||||
from .base import BaseView
|
|
||||||
from .database import display_rows as display_query_rows
|
|
||||||
from .query_helpers import (
|
|
||||||
QueryValidationError,
|
|
||||||
SQL_PARAMETER_FORM_PREFIX,
|
|
||||||
_analysis_is_write,
|
|
||||||
_analysis_rows,
|
|
||||||
_analysis_rows_with_permissions,
|
|
||||||
_block_framing,
|
|
||||||
_coerce_execute_write_payload,
|
|
||||||
_derived_query_parameters,
|
|
||||||
_execute_write_analysis_data,
|
|
||||||
_execute_write_disabled_reason,
|
|
||||||
_inserted_row_url,
|
|
||||||
_json_or_form_payload,
|
|
||||||
_prepare_execute_write,
|
|
||||||
_table_columns,
|
|
||||||
_wants_json,
|
|
||||||
)
|
|
||||||
|
|
||||||
WRITE_TEMPLATE_LABELS = {
|
|
||||||
"insert": "Insert row",
|
|
||||||
"update": "Update rows",
|
|
||||||
"delete": "Delete rows",
|
|
||||||
}
|
|
||||||
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
|
|
||||||
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
|
|
||||||
(
|
|
||||||
"create table new_table (",
|
|
||||||
" id integer primary key,",
|
|
||||||
" name text",
|
|
||||||
" -- created text default (datetime('now'))",
|
|
||||||
")",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parameter_names(columns):
|
|
||||||
seen = set()
|
|
||||||
names = {}
|
|
||||||
for column in columns:
|
|
||||||
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
|
||||||
base = base.strip("_") or "value"
|
|
||||||
if base[0].isdigit():
|
|
||||||
base = "p_{}".format(base)
|
|
||||||
name = base
|
|
||||||
index = 2
|
|
||||||
while name in seen:
|
|
||||||
name = "{}_{}".format(base, index)
|
|
||||||
index += 1
|
|
||||||
seen.add(name)
|
|
||||||
names[column] = name
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def _quote_identifier(identifier):
|
|
||||||
return '"{}"'.format(identifier.replace('"', '""'))
|
|
||||||
|
|
||||||
|
|
||||||
def _preferred_where_column(table, columns):
|
|
||||||
lower_table_id = "{}_id".format(table.lower())
|
|
||||||
return (
|
|
||||||
next((column for column in columns if column.lower() == "id"), None)
|
|
||||||
or next(
|
|
||||||
(column for column in columns if column.lower() == lower_table_id), None
|
|
||||||
)
|
|
||||||
or columns[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_incrementing_primary_key(columns):
|
|
||||||
primary_keys = [column for column in columns if column.is_pk]
|
|
||||||
if len(primary_keys) != 1:
|
|
||||||
return None
|
|
||||||
primary_key = primary_keys[0]
|
|
||||||
if primary_key.type and primary_key.type.lower() == "integer":
|
|
||||||
return primary_key.name
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_template_sql(table, columns):
|
|
||||||
column_names = [column.name for column in columns]
|
|
||||||
auto_pk = _auto_incrementing_primary_key(columns)
|
|
||||||
insert_columns = [column for column in column_names if column != auto_pk]
|
|
||||||
if not insert_columns:
|
|
||||||
return "insert into {}\ndefault values".format(_quote_identifier(table))
|
|
||||||
names = _parameter_names(insert_columns)
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"insert into {} (".format(_quote_identifier(table)),
|
|
||||||
",\n".join(
|
|
||||||
" {}".format(_quote_identifier(column)) for column in insert_columns
|
|
||||||
),
|
|
||||||
")",
|
|
||||||
"values (",
|
|
||||||
",\n".join(" :{}".format(names[column]) for column in insert_columns),
|
|
||||||
")",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_template_sql(table, columns):
|
|
||||||
column_names = [column.name for column in columns]
|
|
||||||
names = _parameter_names(column_names)
|
|
||||||
where_column = _preferred_where_column(table, column_names)
|
|
||||||
set_columns = [column for column in column_names if column != where_column]
|
|
||||||
if not set_columns:
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"update {}".format(_quote_identifier(table)),
|
|
||||||
"set {} = :new_{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
"where {} = :{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"update {}".format(_quote_identifier(table)),
|
|
||||||
"set "
|
|
||||||
+ ",\n".join(
|
|
||||||
"{}{} = :{}".format(
|
|
||||||
" " if index else "",
|
|
||||||
_quote_identifier(column),
|
|
||||||
names[column],
|
|
||||||
)
|
|
||||||
for index, column in enumerate(set_columns)
|
|
||||||
),
|
|
||||||
"where {} = :{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_template_sql(table, columns):
|
|
||||||
column_names = [column.name for column in columns]
|
|
||||||
names = _parameter_names(column_names)
|
|
||||||
where_column = _preferred_where_column(table, column_names)
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"delete from {}".format(_quote_identifier(table)),
|
|
||||||
"where {} = :{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _template_sqls_for_table(table, columns):
|
|
||||||
return {
|
|
||||||
"insert": _insert_template_sql(table, columns),
|
|
||||||
"update": _update_template_sql(table, columns),
|
|
||||||
"delete": _delete_template_sql(table, columns),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _template_sql_allowed(datasette, db, sql, actor):
|
|
||||||
params = {parameter: "" for parameter in _derived_query_parameters(sql)}
|
|
||||||
try:
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return False
|
|
||||||
if not _analysis_is_write(analysis):
|
|
||||||
return False
|
|
||||||
analysis_rows = await _analysis_rows_with_permissions(datasette, analysis, actor)
|
|
||||||
return _execute_write_disabled_reason(sql, None, analysis_rows) is None
|
|
||||||
|
|
||||||
|
|
||||||
async def _write_template_tables(
|
|
||||||
datasette, db, table_columns, hidden_table_names, actor
|
|
||||||
):
|
|
||||||
write_template_tables = {}
|
|
||||||
for table in table_columns:
|
|
||||||
if table in hidden_table_names or not table_columns[table]:
|
|
||||||
continue
|
|
||||||
column_details = [
|
|
||||||
column
|
|
||||||
for column in await db.table_column_details(table)
|
|
||||||
if not column.hidden
|
|
||||||
]
|
|
||||||
if not column_details:
|
|
||||||
continue
|
|
||||||
templates = {}
|
|
||||||
for operation, sql in _template_sqls_for_table(table, column_details).items():
|
|
||||||
if await _template_sql_allowed(datasette, db, sql, actor):
|
|
||||||
templates[operation] = sql
|
|
||||||
if templates:
|
|
||||||
write_template_tables[table] = {
|
|
||||||
"templates": templates,
|
|
||||||
}
|
|
||||||
return write_template_tables
|
|
||||||
|
|
||||||
|
|
||||||
def _write_template_operations(write_template_tables):
|
|
||||||
operations = []
|
|
||||||
for operation in WRITE_TEMPLATE_OPERATIONS:
|
|
||||||
if any(
|
|
||||||
operation in table["templates"] for table in write_template_tables.values()
|
|
||||||
):
|
|
||||||
operations.append(
|
|
||||||
{
|
|
||||||
"name": operation,
|
|
||||||
"label": WRITE_TEMPLATE_LABELS[operation],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return operations
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_table_template_sql(datasette, db, actor):
|
|
||||||
if await datasette.allowed(
|
|
||||||
action="create-table",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
return CREATE_TABLE_TEMPLATE_SQL
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _analysis_changes_schema(analysis):
|
|
||||||
return any(
|
|
||||||
operation.operation in {"create", "alter", "drop"}
|
|
||||||
for operation in analysis.operations
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExecuteWriteView(BaseView):
|
|
||||||
name = "execute-write"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def _render_form(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
*,
|
|
||||||
sql="",
|
|
||||||
parameter_values=None,
|
|
||||||
analysis=None,
|
|
||||||
analysis_error=None,
|
|
||||||
execution_message=None,
|
|
||||||
execution_links=None,
|
|
||||||
execution_ok=None,
|
|
||||||
execute_write_returns_rows=False,
|
|
||||||
execute_write_columns=None,
|
|
||||||
execute_write_display_rows=None,
|
|
||||||
execute_write_truncated=False,
|
|
||||||
status=200,
|
|
||||||
):
|
|
||||||
parameter_values = parameter_values or {}
|
|
||||||
execution_links = execution_links or []
|
|
||||||
execute_write_columns = execute_write_columns or []
|
|
||||||
execute_write_display_rows = execute_write_display_rows or []
|
|
||||||
parameter_names = []
|
|
||||||
analysis_rows = []
|
|
||||||
table_columns = await _table_columns(self.ds, db.name)
|
|
||||||
hidden_table_names = set(await db.hidden_table_names())
|
|
||||||
write_template_tables = await _write_template_tables(
|
|
||||||
self.ds, db, table_columns, hidden_table_names, request.actor
|
|
||||||
)
|
|
||||||
write_template_operations = _write_template_operations(write_template_tables)
|
|
||||||
write_create_table_template_sql = await _create_table_template_sql(
|
|
||||||
self.ds, db, request.actor
|
|
||||||
)
|
|
||||||
if sql and analysis_error is None:
|
|
||||||
try:
|
|
||||||
parameter_names = _derived_query_parameters(sql)
|
|
||||||
if analysis is None:
|
|
||||||
params = {parameter: "" for parameter in parameter_names}
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
if _analysis_is_write(analysis):
|
|
||||||
analysis_rows = await _analysis_rows_with_permissions(
|
|
||||||
self.ds, analysis, request.actor
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
analysis_error = (
|
|
||||||
"Use /-/query for read-only SQL; "
|
|
||||||
"this endpoint only executes writes"
|
|
||||||
)
|
|
||||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
|
||||||
analysis_error = getattr(ex, "message", str(ex))
|
|
||||||
|
|
||||||
allow_save_query = await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
) and await self.ds.allowed(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
save_query_base_url = None
|
|
||||||
save_query_url = None
|
|
||||||
execute_disabled_reason = _execute_write_disabled_reason(
|
|
||||||
sql, analysis_error, analysis_rows
|
|
||||||
)
|
|
||||||
if allow_save_query:
|
|
||||||
save_query_base_url = self.ds.urls.database(db.name) + "/-/queries/store"
|
|
||||||
if not execute_disabled_reason:
|
|
||||||
save_query_url = save_query_base_url + "?" + urlencode({"sql": sql})
|
|
||||||
|
|
||||||
response = await self.render(
|
|
||||||
["execute_write.html"],
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
"database": db.name,
|
|
||||||
"database_color": db.color,
|
|
||||||
"sql": sql,
|
|
||||||
"parameter_names": parameter_names,
|
|
||||||
"parameter_values": parameter_values,
|
|
||||||
"analysis_error": analysis_error,
|
|
||||||
"analysis_rows": analysis_rows,
|
|
||||||
"execution_message": execution_message,
|
|
||||||
"execution_links": execution_links,
|
|
||||||
"execution_ok": execution_ok,
|
|
||||||
"execute_write_returns_rows": execute_write_returns_rows,
|
|
||||||
"execute_write_columns": execute_write_columns,
|
|
||||||
"execute_write_display_rows": execute_write_display_rows,
|
|
||||||
"execute_write_truncated": execute_write_truncated,
|
|
||||||
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
|
|
||||||
"execute_disabled": bool(execute_disabled_reason),
|
|
||||||
"execute_disabled_reason": execute_disabled_reason,
|
|
||||||
"table_columns": table_columns,
|
|
||||||
"write_template_tables": write_template_tables,
|
|
||||||
"write_template_operations": write_template_operations,
|
|
||||||
"write_create_table_template_sql": write_create_table_template_sql,
|
|
||||||
"save_query_url": save_query_url,
|
|
||||||
"save_query_base_url": save_query_base_url,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.status = status
|
|
||||||
return _block_framing(response)
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="execute-write-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
if not db.is_mutable:
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(
|
|
||||||
["Cannot execute write SQL because this database is immutable."],
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=request.args.get("sql") or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-write-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(["Permission denied: need execute-write-sql"], 403)
|
|
||||||
)
|
|
||||||
if not db.is_mutable:
|
|
||||||
return _block_framing(Response.error(["Database is immutable"], 403))
|
|
||||||
|
|
||||||
data = {}
|
|
||||||
is_json = request.headers.get("content-type", "").startswith("application/json")
|
|
||||||
sql = ""
|
|
||||||
provided_params = {}
|
|
||||||
try:
|
|
||||||
data, is_json = await _json_or_form_payload(request)
|
|
||||||
sql, provided_params = _coerce_execute_write_payload(data, is_json)
|
|
||||||
parameter_names, params, analysis = await _prepare_execute_write(
|
|
||||||
self.ds, db, sql, provided_params, request.actor
|
|
||||||
)
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
if _wants_json(request, is_json, data):
|
|
||||||
return _block_framing(Response.error([ex.message], ex.status))
|
|
||||||
if ex.flash:
|
|
||||||
self.ds.add_message(request, ex.message, self.ds.ERROR)
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=sql or "",
|
|
||||||
parameter_values=provided_params,
|
|
||||||
analysis_error=None if ex.flash else ex.message,
|
|
||||||
execution_message=None if ex.flash else ex.message,
|
|
||||||
execution_ok=False,
|
|
||||||
status=ex.status,
|
|
||||||
)
|
|
||||||
|
|
||||||
wants_json = _wants_json(request, is_json, data)
|
|
||||||
try:
|
|
||||||
execute_write_kwargs = {"request": request}
|
|
||||||
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
message = str(ex)
|
|
||||||
if wants_json:
|
|
||||||
return _block_framing(Response.error([message], 400))
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=sql,
|
|
||||||
parameter_values=params,
|
|
||||||
analysis=analysis,
|
|
||||||
execution_message=message,
|
|
||||||
execution_ok=False,
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if _analysis_changes_schema(analysis):
|
|
||||||
await self.ds.refresh_schemas(force=True)
|
|
||||||
|
|
||||||
if cursor.rowcount == -1:
|
|
||||||
message = "Query executed"
|
|
||||||
else:
|
|
||||||
message = "Query executed, {} row{} affected".format(
|
|
||||||
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
|
||||||
)
|
|
||||||
if wants_json:
|
|
||||||
data = {
|
|
||||||
"ok": True,
|
|
||||||
"message": message,
|
|
||||||
"rowcount": cursor.rowcount,
|
|
||||||
"rows": [],
|
|
||||||
"truncated": False,
|
|
||||||
"analysis": _analysis_rows(analysis),
|
|
||||||
}
|
|
||||||
if cursor.description is not None:
|
|
||||||
data["rows"] = [dict(row) for row in cursor.fetchall()]
|
|
||||||
data["truncated"] = cursor.truncated
|
|
||||||
return _block_framing(Response.json(data))
|
|
||||||
|
|
||||||
inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor)
|
|
||||||
execution_links = (
|
|
||||||
[{"href": inserted_row_url, "label": "View row"}]
|
|
||||||
if inserted_row_url
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
execute_write_returns_rows = cursor.description is not None
|
|
||||||
execute_write_columns = []
|
|
||||||
execute_write_display_rows = []
|
|
||||||
if execute_write_returns_rows:
|
|
||||||
execute_write_columns = [
|
|
||||||
description[0] for description in cursor.description
|
|
||||||
]
|
|
||||||
execute_write_display_rows = await display_query_rows(
|
|
||||||
self.ds,
|
|
||||||
db.name,
|
|
||||||
request,
|
|
||||||
cursor.fetchall(),
|
|
||||||
execute_write_columns,
|
|
||||||
)
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=sql,
|
|
||||||
parameter_values={name: params.get(name, "") for name in parameter_names},
|
|
||||||
analysis=analysis,
|
|
||||||
execution_message=message,
|
|
||||||
execution_links=execution_links,
|
|
||||||
execution_ok=True,
|
|
||||||
execute_write_returns_rows=execute_write_returns_rows,
|
|
||||||
execute_write_columns=execute_write_columns,
|
|
||||||
execute_write_display_rows=execute_write_display_rows,
|
|
||||||
execute_write_truncated=cursor.truncated,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExecuteWriteAnalyzeView(BaseView):
|
|
||||||
name = "execute-write-analyze"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-write-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(["Permission denied: need execute-write-sql"], 403)
|
|
||||||
)
|
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
|
||||||
if invalid_keys:
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(
|
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sql = request.args.get("sql") or ""
|
|
||||||
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
|
||||||
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
|
||||||
return _block_framing(Response.json(analysis))
|
|
||||||
|
|
@ -6,7 +6,6 @@ from datasette.utils import (
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
make_slot_function,
|
make_slot_function,
|
||||||
CustomJSONEncoder,
|
CustomJSONEncoder,
|
||||||
UNSTABLE_API_MESSAGE,
|
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
from datasette.version import __version__
|
from datasette.version import __version__
|
||||||
|
|
@ -152,9 +151,7 @@ class IndexView(BaseView):
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"databases": {db["name"]: db for db in databases},
|
||||||
"unstable": UNSTABLE_API_MESSAGE,
|
|
||||||
"databases": databases,
|
|
||||||
"metadata": await self.ds.get_instance_metadata(),
|
"metadata": await self.ds.get_instance_metadata(),
|
||||||
},
|
},
|
||||||
cls=CustomJSONEncoder,
|
cls=CustomJSONEncoder,
|
||||||
|
|
|
||||||
|
|
@ -1,636 +0,0 @@
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
from datasette.resources import DatabaseResource
|
|
||||||
from datasette.stored_queries import (
|
|
||||||
StoredQuery,
|
|
||||||
)
|
|
||||||
from datasette.write_sql import (
|
|
||||||
IgnoreWriteSqlOperation,
|
|
||||||
QueryWriteRejected,
|
|
||||||
RequireWriteSqlPermissions,
|
|
||||||
decision_for_write_sql_operation,
|
|
||||||
operation_is_write,
|
|
||||||
)
|
|
||||||
from datasette.utils import (
|
|
||||||
parse_size_limit,
|
|
||||||
named_parameters as derive_named_parameters,
|
|
||||||
escape_sqlite,
|
|
||||||
path_from_row_pks,
|
|
||||||
sqlite3,
|
|
||||||
validate_sql_select,
|
|
||||||
InvalidSql,
|
|
||||||
)
|
|
||||||
from datasette.utils.asgi import Forbidden
|
|
||||||
from datasette.utils.sql_analysis import Operation, SQLAnalysis
|
|
||||||
|
|
||||||
_query_name_re = re.compile(r"^[^/\.\n]+$")
|
|
||||||
|
|
||||||
_query_fields = {
|
|
||||||
"sql",
|
|
||||||
"title",
|
|
||||||
"description",
|
|
||||||
"hide_sql",
|
|
||||||
"fragment",
|
|
||||||
"parameters",
|
|
||||||
"is_private",
|
|
||||||
"on_success_message",
|
|
||||||
"on_success_redirect",
|
|
||||||
"on_error_message",
|
|
||||||
"on_error_redirect",
|
|
||||||
}
|
|
||||||
|
|
||||||
_query_create_fields = _query_fields | {"name", "mode", "csrftoken"}
|
|
||||||
_query_update_fields = _query_fields
|
|
||||||
_query_write_fields = {
|
|
||||||
"on_success_message",
|
|
||||||
"on_success_redirect",
|
|
||||||
"on_error_message",
|
|
||||||
"on_error_redirect",
|
|
||||||
}
|
|
||||||
|
|
||||||
SQL_PARAMETER_FORM_PREFIX = "_sql_param_"
|
|
||||||
|
|
||||||
|
|
||||||
class QueryValidationError(Exception):
|
|
||||||
def __init__(self, message, status=400, *, flash=False):
|
|
||||||
self.message = message
|
|
||||||
self.status = status
|
|
||||||
self.flash = flash
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_id(actor):
|
|
||||||
if isinstance(actor, dict):
|
|
||||||
return actor.get("id")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(value):
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if value is None:
|
|
||||||
return False
|
|
||||||
if isinstance(value, int):
|
|
||||||
return bool(value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value.lower() in {"1", "true", "t", "yes", "on"}
|
|
||||||
return bool(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_optional_bool(value, name):
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, int):
|
|
||||||
return bool(value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
lowered = value.lower()
|
|
||||||
if lowered in {"1", "true", "t", "yes", "on"}:
|
|
||||||
return True
|
|
||||||
if lowered in {"0", "false", "f", "no", "off"}:
|
|
||||||
return False
|
|
||||||
raise QueryValidationError("{} must be 0 or 1".format(name))
|
|
||||||
|
|
||||||
|
|
||||||
def _query_list_limit(value, default, maximum):
|
|
||||||
try:
|
|
||||||
return parse_size_limit(value, default, maximum)
|
|
||||||
except ValueError as ex:
|
|
||||||
raise QueryValidationError(str(ex)) from ex
|
|
||||||
|
|
||||||
|
|
||||||
def _derived_query_parameters(sql):
|
|
||||||
parameters = []
|
|
||||||
seen = set()
|
|
||||||
for parameter in derive_named_parameters(sql):
|
|
||||||
if parameter.startswith("_"):
|
|
||||||
raise QueryValidationError("Magic parameters are not allowed")
|
|
||||||
if parameter not in seen:
|
|
||||||
parameters.append(parameter)
|
|
||||||
seen.add(parameter)
|
|
||||||
return parameters
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_query_parameters(value, derived):
|
|
||||||
if value is None:
|
|
||||||
return derived
|
|
||||||
if isinstance(value, str):
|
|
||||||
parameters = [
|
|
||||||
parameter.strip()
|
|
||||||
for parameter in re.split(r"[\s,]+", value)
|
|
||||||
if parameter.strip()
|
|
||||||
]
|
|
||||||
elif isinstance(value, list):
|
|
||||||
parameters = value
|
|
||||||
else:
|
|
||||||
raise QueryValidationError("parameters must be a list of strings")
|
|
||||||
if not all(isinstance(parameter, str) for parameter in parameters):
|
|
||||||
raise QueryValidationError("parameters must be a list of strings")
|
|
||||||
if any(parameter.startswith("_") for parameter in parameters):
|
|
||||||
raise QueryValidationError("Magic parameters are not allowed")
|
|
||||||
if set(parameters) != set(derived):
|
|
||||||
raise QueryValidationError("parameters must match SQL named parameters")
|
|
||||||
return parameters
|
|
||||||
|
|
||||||
|
|
||||||
def _analysis_is_write(analysis: SQLAnalysis) -> bool:
|
|
||||||
return any(operation_is_write(operation) for operation in analysis.operations)
|
|
||||||
|
|
||||||
|
|
||||||
def _block_framing(response):
|
|
||||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
|
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _wants_json(request, is_json, data):
|
|
||||||
return (
|
|
||||||
is_json
|
|
||||||
or request.headers.get("accept") == "application/json"
|
|
||||||
or (isinstance(data, dict) and data.get("_json"))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _query_create_form_error_message(message):
|
|
||||||
return {
|
|
||||||
"Query name is required": "URL is required",
|
|
||||||
"Invalid query name": "Invalid URL",
|
|
||||||
"Query name conflicts with a table or view": (
|
|
||||||
"URL conflicts with an existing table or view"
|
|
||||||
),
|
|
||||||
"Query already exists": "A query already exists at that URL",
|
|
||||||
}.get(message, message)
|
|
||||||
|
|
||||||
|
|
||||||
async def _json_or_form_payload(request):
|
|
||||||
content_type = request.headers.get("content-type", "")
|
|
||||||
if content_type.startswith("application/json"):
|
|
||||||
body = await request.post_body()
|
|
||||||
try:
|
|
||||||
return json.loads(body or b"{}"), True
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise QueryValidationError("Invalid JSON: {}".format(e))
|
|
||||||
return await request.post_vars(), False
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_query_name(db, name, *, existing=False):
|
|
||||||
if not name or not isinstance(name, str):
|
|
||||||
raise QueryValidationError("Query name is required")
|
|
||||||
if not _query_name_re.match(name):
|
|
||||||
raise QueryValidationError("Invalid query name")
|
|
||||||
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
|
|
||||||
raise QueryValidationError("Query name conflicts with a table or view")
|
|
||||||
|
|
||||||
|
|
||||||
async def _analyze_user_query(datasette, db, sql, *, actor):
|
|
||||||
if not sql or not isinstance(sql, str):
|
|
||||||
raise QueryValidationError("SQL is required")
|
|
||||||
derived = _derived_query_parameters(sql)
|
|
||||||
params = {parameter: "" for parameter in derived}
|
|
||||||
try:
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
|
||||||
|
|
||||||
is_write = _analysis_is_write(analysis)
|
|
||||||
if is_write:
|
|
||||||
try:
|
|
||||||
await datasette.ensure_query_write_permissions(
|
|
||||||
db.name, sql, actor=actor, analysis=analysis
|
|
||||||
)
|
|
||||||
except QueryWriteRejected as ex:
|
|
||||||
raise QueryValidationError(ex.message, status=403, flash=True) from ex
|
|
||||||
except Forbidden as ex:
|
|
||||||
raise QueryValidationError(str(ex), status=403) from ex
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
validate_sql_select(sql)
|
|
||||||
except InvalidSql as ex:
|
|
||||||
raise QueryValidationError(str(ex)) from ex
|
|
||||||
return is_write, derived, analysis
|
|
||||||
|
|
||||||
|
|
||||||
def _display_operations(analysis: SQLAnalysis) -> list[Operation]:
|
|
||||||
operations = []
|
|
||||||
for operation in analysis.operations:
|
|
||||||
if isinstance(
|
|
||||||
decision_for_write_sql_operation(operation), IgnoreWriteSqlOperation
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
operations.append(operation)
|
|
||||||
return operations
|
|
||||||
|
|
||||||
|
|
||||||
def _analysis_rows(analysis: SQLAnalysis) -> list[dict[str, object]]:
|
|
||||||
rows = []
|
|
||||||
for operation in _display_operations(analysis):
|
|
||||||
decision = decision_for_write_sql_operation(operation)
|
|
||||||
required_permission = (
|
|
||||||
", ".join(permission.action for permission in decision.permissions)
|
|
||||||
if isinstance(decision, RequireWriteSqlPermissions)
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"operation": operation.operation,
|
|
||||||
"database": operation.database,
|
|
||||||
"table": operation.table or operation.target,
|
|
||||||
"required_permission": required_permission,
|
|
||||||
"source": operation.source,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
async def _analysis_rows_with_permissions(
|
|
||||||
datasette, analysis: SQLAnalysis, actor
|
|
||||||
) -> list[dict[str, object]]:
|
|
||||||
rows = _analysis_rows(analysis)
|
|
||||||
is_write = _analysis_is_write(analysis)
|
|
||||||
for row, operation in zip(rows, _display_operations(analysis)):
|
|
||||||
decision = decision_for_write_sql_operation(operation)
|
|
||||||
if isinstance(decision, RequireWriteSqlPermissions):
|
|
||||||
row["allowed"] = True
|
|
||||||
for permission in decision.permissions:
|
|
||||||
if not await datasette.allowed(
|
|
||||||
action=permission.action,
|
|
||||||
resource=permission.resource,
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
row["allowed"] = False
|
|
||||||
break
|
|
||||||
elif is_write:
|
|
||||||
row["allowed"] = False
|
|
||||||
else:
|
|
||||||
row["allowed"] = None
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _execute_write_disabled_reason(sql, analysis_error, analysis_rows):
|
|
||||||
if not (sql and sql.strip()):
|
|
||||||
return "Enter writable SQL before executing."
|
|
||||||
if analysis_error:
|
|
||||||
return analysis_error
|
|
||||||
if any(row.get("allowed") is False for row in analysis_rows):
|
|
||||||
return "You do not have permission for every operation listed above."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_execute_write_payload(data, is_json):
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise QueryValidationError("JSON must be a dictionary")
|
|
||||||
if is_json:
|
|
||||||
invalid_keys = set(data) - {"sql", "params"}
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
|
||||||
)
|
|
||||||
params = data.get("params") or {}
|
|
||||||
else:
|
|
||||||
params = {}
|
|
||||||
for key, value in data.items():
|
|
||||||
if key in {"sql", "csrftoken", "_json"}:
|
|
||||||
continue
|
|
||||||
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
|
|
||||||
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
|
|
||||||
params[key] = value
|
|
||||||
if not isinstance(params, dict):
|
|
||||||
raise QueryValidationError("params must be a dictionary")
|
|
||||||
return data.get("sql"), params
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_execute_write(datasette, db, sql, params, actor):
|
|
||||||
if not sql or not isinstance(sql, str):
|
|
||||||
raise QueryValidationError("SQL is required")
|
|
||||||
parameter_names = _derived_query_parameters(sql)
|
|
||||||
extra_params = set(params) - set(parameter_names)
|
|
||||||
if extra_params:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Unknown parameters: {}".format(", ".join(sorted(extra_params)))
|
|
||||||
)
|
|
||||||
params = {name: params.get(name, "") for name in parameter_names}
|
|
||||||
try:
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
|
||||||
if not _analysis_is_write(analysis):
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Use /-/query for read-only SQL; this endpoint only executes writes"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await datasette.ensure_query_write_permissions(
|
|
||||||
db.name, sql, actor=actor, analysis=analysis
|
|
||||||
)
|
|
||||||
except QueryWriteRejected as ex:
|
|
||||||
raise QueryValidationError(ex.message, status=403, flash=True) from ex
|
|
||||||
except Forbidden as ex:
|
|
||||||
raise QueryValidationError(str(ex), status=403) from ex
|
|
||||||
return parameter_names, params, analysis
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_stored_query_execution_permissions(
|
|
||||||
datasette, db, query: StoredQuery, actor
|
|
||||||
):
|
|
||||||
if query.is_trusted:
|
|
||||||
return
|
|
||||||
if query.is_write:
|
|
||||||
await datasette.ensure_permission(
|
|
||||||
action="execute-write-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor)
|
|
||||||
else:
|
|
||||||
await datasette.ensure_permission(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_write_analysis_data(datasette, db, sql, actor):
|
|
||||||
parameter_names = []
|
|
||||||
analysis_rows = []
|
|
||||||
analysis_error = None
|
|
||||||
if sql:
|
|
||||||
try:
|
|
||||||
parameter_names = _derived_query_parameters(sql)
|
|
||||||
params = {parameter: "" for parameter in parameter_names}
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
if _analysis_is_write(analysis):
|
|
||||||
analysis_rows = await _analysis_rows_with_permissions(
|
|
||||||
datasette, analysis, actor
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
analysis_error = (
|
|
||||||
"Use /-/query for read-only SQL; "
|
|
||||||
"this endpoint only executes writes"
|
|
||||||
)
|
|
||||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
|
||||||
analysis_error = getattr(ex, "message", str(ex))
|
|
||||||
execute_disabled_reason = _execute_write_disabled_reason(
|
|
||||||
sql, analysis_error, analysis_rows
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": analysis_error is None,
|
|
||||||
"parameters": parameter_names,
|
|
||||||
"analysis_error": analysis_error,
|
|
||||||
"analysis_rows": analysis_rows,
|
|
||||||
"execute_disabled": bool(execute_disabled_reason),
|
|
||||||
"execute_disabled_reason": execute_disabled_reason,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_create_analysis_data(datasette, db, sql, actor):
|
|
||||||
has_sql = bool(sql and sql.strip())
|
|
||||||
parameter_names = []
|
|
||||||
analysis_rows = []
|
|
||||||
analysis_error = None
|
|
||||||
analysis: SQLAnalysis | None = None
|
|
||||||
if has_sql:
|
|
||||||
try:
|
|
||||||
parameter_names = _derived_query_parameters(sql)
|
|
||||||
params = {parameter: "" for parameter in parameter_names}
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
analysis_rows = await _analysis_rows_with_permissions(
|
|
||||||
datasette, analysis, actor
|
|
||||||
)
|
|
||||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
|
||||||
analysis_error = getattr(ex, "message", str(ex))
|
|
||||||
return {
|
|
||||||
"ok": analysis_error is None,
|
|
||||||
"parameters": parameter_names,
|
|
||||||
"analysis_error": analysis_error,
|
|
||||||
"analysis_rows": analysis_rows,
|
|
||||||
"has_sql": has_sql,
|
|
||||||
"analysis_is_write": _analysis_is_write(analysis) if analysis else False,
|
|
||||||
"save_disabled": bool(
|
|
||||||
(not has_sql)
|
|
||||||
or analysis_error
|
|
||||||
or any(row["allowed"] is False for row in analysis_rows)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_create_form_context(
|
|
||||||
datasette,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
*,
|
|
||||||
sql="",
|
|
||||||
name="",
|
|
||||||
title="",
|
|
||||||
description="",
|
|
||||||
is_private=True,
|
|
||||||
):
|
|
||||||
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
|
|
||||||
return {
|
|
||||||
"database": db.name,
|
|
||||||
"database_color": db.color,
|
|
||||||
"sql": sql,
|
|
||||||
"name": name,
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"is_private": is_private,
|
|
||||||
**analysis_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_edit_form_context(
|
|
||||||
datasette,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
existing: StoredQuery,
|
|
||||||
*,
|
|
||||||
sql=None,
|
|
||||||
title=None,
|
|
||||||
description=None,
|
|
||||||
is_private=None,
|
|
||||||
):
|
|
||||||
sql = existing.sql if sql is None else sql
|
|
||||||
title = existing.title if title is None else title
|
|
||||||
description = existing.description if description is None else description
|
|
||||||
is_private = existing.is_private if is_private is None else is_private
|
|
||||||
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
|
|
||||||
return {
|
|
||||||
"database": db.name,
|
|
||||||
"database_color": db.color,
|
|
||||||
"name": existing.name,
|
|
||||||
"sql": sql,
|
|
||||||
"title": title or "",
|
|
||||||
"description": description or "",
|
|
||||||
"is_private": is_private,
|
|
||||||
"query_url": datasette.urls.table(db.name, existing.name),
|
|
||||||
**analysis_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _inserted_row_url(datasette, db, analysis, cursor):
|
|
||||||
if cursor.rowcount != 1:
|
|
||||||
return None
|
|
||||||
lastrowid = getattr(cursor, "lastrowid", None)
|
|
||||||
if lastrowid is None:
|
|
||||||
return None
|
|
||||||
direct_inserts = [
|
|
||||||
operation
|
|
||||||
for operation in analysis.operations
|
|
||||||
if operation.operation == "insert"
|
|
||||||
and operation.target_type == "table"
|
|
||||||
and not operation.internal
|
|
||||||
and operation.source is None
|
|
||||||
and operation.database == db.name
|
|
||||||
]
|
|
||||||
if len(direct_inserts) != 1:
|
|
||||||
return None
|
|
||||||
table = direct_inserts[0].table
|
|
||||||
if table is None:
|
|
||||||
return None
|
|
||||||
pks = await db.primary_keys(table)
|
|
||||||
use_rowid = not pks
|
|
||||||
select = (
|
|
||||||
"rowid"
|
|
||||||
if use_rowid
|
|
||||||
else ", ".join(escape_sqlite(primary_key) for primary_key in pks)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
result = await db.execute(
|
|
||||||
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
|
|
||||||
[lastrowid],
|
|
||||||
)
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return None
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
row_path = path_from_row_pks(row, pks, use_rowid)
|
|
||||||
return datasette.urls.row(db.name, table, row_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_query_data_types(data):
|
|
||||||
typed = dict(data)
|
|
||||||
for key in ("hide_sql", "is_private"):
|
|
||||||
if key in typed:
|
|
||||||
typed[key] = _as_bool(typed[key])
|
|
||||||
return typed
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_query_create(datasette, request, db, data):
|
|
||||||
invalid_keys = set(data) - _query_create_fields
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
|
||||||
)
|
|
||||||
|
|
||||||
data = _apply_query_data_types(data)
|
|
||||||
name = data.get("name")
|
|
||||||
await _check_query_name(db, name)
|
|
||||||
if await datasette.get_query(db.name, name) is not None:
|
|
||||||
raise QueryValidationError("Query already exists")
|
|
||||||
|
|
||||||
is_write, derived, analysis = await _analyze_user_query(
|
|
||||||
datasette,
|
|
||||||
db,
|
|
||||||
data.get("sql"),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
if not is_write and any(data.get(field) for field in _query_write_fields):
|
|
||||||
raise QueryValidationError("Writable query fields require writable SQL")
|
|
||||||
|
|
||||||
parameters = _coerce_query_parameters(
|
|
||||||
data.get("parameters"),
|
|
||||||
derived,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"sql": data["sql"],
|
|
||||||
"title": data.get("title"),
|
|
||||||
"description": data.get("description"),
|
|
||||||
"hide_sql": _as_bool(data.get("hide_sql")),
|
|
||||||
"fragment": data.get("fragment"),
|
|
||||||
"parameters": parameters,
|
|
||||||
"is_write": is_write,
|
|
||||||
"is_private": _as_bool(data.get("is_private", True)),
|
|
||||||
"is_trusted": False,
|
|
||||||
"source": "user",
|
|
||||||
"owner_id": _actor_id(request.actor),
|
|
||||||
"on_success_message": data.get("on_success_message"),
|
|
||||||
"on_success_redirect": data.get("on_success_redirect"),
|
|
||||||
"on_error_message": data.get("on_error_message"),
|
|
||||||
"on_error_redirect": data.get("on_error_redirect"),
|
|
||||||
"analysis": analysis,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update):
|
|
||||||
invalid_keys = set(update) - _query_update_fields
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
|
||||||
)
|
|
||||||
|
|
||||||
update = _apply_query_data_types(update)
|
|
||||||
sql = update.get("sql", existing.sql)
|
|
||||||
query_is_write = existing.is_write
|
|
||||||
derived = _derived_query_parameters(sql)
|
|
||||||
parameters = None
|
|
||||||
|
|
||||||
if "sql" in update:
|
|
||||||
query_is_write, derived, _ = await _analyze_user_query(
|
|
||||||
datasette,
|
|
||||||
db,
|
|
||||||
sql,
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "parameters" in update:
|
|
||||||
parameters = _coerce_query_parameters(
|
|
||||||
update.get("parameters"),
|
|
||||||
derived,
|
|
||||||
)
|
|
||||||
elif "sql" in update:
|
|
||||||
parameters = derived
|
|
||||||
|
|
||||||
if not query_is_write and any(update.get(field) for field in _query_write_fields):
|
|
||||||
raise QueryValidationError("Writable query fields require writable SQL")
|
|
||||||
|
|
||||||
field_values = {
|
|
||||||
"sql": sql,
|
|
||||||
"title": update.get("title"),
|
|
||||||
"description": update.get("description"),
|
|
||||||
"hide_sql": update.get("hide_sql"),
|
|
||||||
"fragment": update.get("fragment"),
|
|
||||||
"parameters": parameters,
|
|
||||||
"is_write": query_is_write,
|
|
||||||
"is_private": update.get("is_private"),
|
|
||||||
"on_success_message": update.get("on_success_message"),
|
|
||||||
"on_success_redirect": update.get("on_success_redirect"),
|
|
||||||
"on_error_message": update.get("on_error_message"),
|
|
||||||
"on_error_redirect": update.get("on_error_redirect"),
|
|
||||||
}
|
|
||||||
update_kwargs = {}
|
|
||||||
for field_name, value in field_values.items():
|
|
||||||
if field_name in update:
|
|
||||||
update_kwargs[field_name] = value
|
|
||||||
if parameters is not None:
|
|
||||||
update_kwargs["parameters"] = parameters
|
|
||||||
if "sql" in update:
|
|
||||||
update_kwargs["is_write"] = query_is_write
|
|
||||||
return update_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
async def _table_columns(datasette, database_name):
|
|
||||||
internal_db = datasette.get_internal_database()
|
|
||||||
result = await internal_db.execute(
|
|
||||||
"select table_name, name from catalog_columns where database_name = ?",
|
|
||||||
[database_name],
|
|
||||||
)
|
|
||||||
table_columns = {}
|
|
||||||
for row in result.rows:
|
|
||||||
table_columns.setdefault(row["table_name"], []).append(row["name"])
|
|
||||||
# Add views
|
|
||||||
db = datasette.get_database(database_name)
|
|
||||||
for view_name in await db.view_names():
|
|
||||||
table_columns[view_name] = []
|
|
||||||
return table_columns
|
|
||||||
|
|
@ -1,398 +1,25 @@
|
||||||
import asyncio
|
from datasette.utils.asgi import NotFound, Forbidden, Response
|
||||||
import json
|
|
||||||
import textwrap
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import markupsafe
|
|
||||||
import sqlite_utils
|
|
||||||
|
|
||||||
from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
|
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
||||||
from datasette.resources import TableResource
|
from datasette.resources import TableResource
|
||||||
from .base import BaseView, DatasetteError, stream_csv
|
from .base import DataView, BaseView, _error
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
add_cors_headers,
|
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
call_with_supported_arguments,
|
|
||||||
CustomJSONEncoder,
|
|
||||||
CustomRow,
|
CustomRow,
|
||||||
decode_write_json_row,
|
|
||||||
InvalidSql,
|
|
||||||
make_slot_function,
|
make_slot_function,
|
||||||
path_from_row_pks,
|
|
||||||
path_with_format,
|
|
||||||
path_with_removed_args,
|
|
||||||
to_css_class,
|
to_css_class,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
sqlite3,
|
|
||||||
WriteJsonValueError,
|
|
||||||
)
|
)
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
from datasette.extras import extra_names_from_request, ExtraScope
|
import json
|
||||||
from . import Context, from_extra
|
import markupsafe
|
||||||
from .table import (
|
import sqlite_utils
|
||||||
display_columns_and_rows,
|
from .table import display_columns_and_rows, _get_extras
|
||||||
_table_page_data,
|
|
||||||
row_label_from_label_column,
|
|
||||||
)
|
|
||||||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class RowView(DataView):
|
||||||
class RowContext(Context):
|
|
||||||
"The page showing an individual row, e.g. /fixtures/facetable/1."
|
|
||||||
|
|
||||||
documented_template = "row.html"
|
|
||||||
extras_scope = ExtraScope.ROW
|
|
||||||
|
|
||||||
# Fields resolved by registered extras - their documentation comes
|
|
||||||
# from the description on each Extra class in table_extras.py
|
|
||||||
columns: list = from_extra()
|
|
||||||
database: str = from_extra()
|
|
||||||
database_color: str = from_extra()
|
|
||||||
foreign_key_tables: list = from_extra()
|
|
||||||
metadata: dict = from_extra()
|
|
||||||
primary_keys: list = from_extra()
|
|
||||||
private: bool = from_extra()
|
|
||||||
table: str = from_extra()
|
|
||||||
|
|
||||||
# Fields added by the view code
|
|
||||||
ok: bool = field(
|
|
||||||
metadata={"help": "True if the data for this page was retrieved without errors"}
|
|
||||||
)
|
|
||||||
rows: list = field(
|
|
||||||
metadata={
|
|
||||||
"help": "A single-item list containing this row as a dictionary mapping column name to raw value."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
primary_key_values: list = field(
|
|
||||||
metadata={"help": "Values of the primary keys for this row, from the URL"}
|
|
||||||
)
|
|
||||||
query_ms: float = field(
|
|
||||||
metadata={
|
|
||||||
"help": "Time taken by the SQL queries for this page, in milliseconds"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
display_columns: list = field(
|
|
||||||
metadata={
|
|
||||||
"help": "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
display_rows: list = field(
|
|
||||||
metadata={
|
|
||||||
"help": "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
custom_table_templates: list = field(
|
|
||||||
metadata={
|
|
||||||
"help": "Custom template names that were considered for displaying this row's table, in lookup order."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
row_actions: list = field(
|
|
||||||
metadata={
|
|
||||||
"help": 'Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
row_mutation_ui: bool = field(
|
|
||||||
metadata={"help": "True if the row edit/delete JavaScript UI should be enabled"}
|
|
||||||
)
|
|
||||||
table_page_data: dict = field(
|
|
||||||
metadata={
|
|
||||||
"help": "JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
top_row: callable = field(
|
|
||||||
metadata={
|
|
||||||
"help": "Async callable that renders the ``top_row`` plugin slot for this row and returns HTML."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
renderers: dict = field(
|
|
||||||
metadata={
|
|
||||||
"help": "Dictionary mapping output format names such as ``json`` to URLs for this row in that format."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
|
|
||||||
url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
|
|
||||||
url_csv_hidden_args: list = field(
|
|
||||||
metadata={
|
|
||||||
"help": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
settings: dict = field(
|
|
||||||
metadata={
|
|
||||||
"help": "Dictionary of Datasette's current settings, keyed by setting name."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
select_templates: list = field(
|
|
||||||
metadata={
|
|
||||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
alternate_url_json: str = field(
|
|
||||||
metadata={"help": "URL for the JSON version of this page"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RowView(BaseView):
|
|
||||||
name = "row"
|
name = "row"
|
||||||
|
|
||||||
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
|
||||||
if request.query_string and "?" not in path and forward_querystring:
|
|
||||||
path = f"{path}?{request.query_string}"
|
|
||||||
if remove_args:
|
|
||||||
path = path_with_removed_args(request, remove_args, path=path)
|
|
||||||
response = Response.redirect(path)
|
|
||||||
response.headers["Link"] = f"<{path}>; rel=preload"
|
|
||||||
if self.ds.cors:
|
|
||||||
add_cors_headers(response.headers)
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def as_csv(self, request, database):
|
|
||||||
return await stream_csv(self.ds, self.data, request, database)
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
database = db.name
|
|
||||||
database_route = db.route
|
|
||||||
format_ = request.url_vars.get("format") or "html"
|
|
||||||
data_kwargs = {}
|
|
||||||
|
|
||||||
if format_ == "csv":
|
|
||||||
return await self.as_csv(request, database_route)
|
|
||||||
|
|
||||||
if format_ == "html":
|
|
||||||
# HTML views default to expanding all foreign key labels
|
|
||||||
data_kwargs["default_labels"] = True
|
|
||||||
|
|
||||||
extra_template_data = {}
|
|
||||||
start = time.perf_counter()
|
|
||||||
status_code = None
|
|
||||||
templates = ()
|
|
||||||
try:
|
|
||||||
response_or_template_contexts = await self.data(request, **data_kwargs)
|
|
||||||
if isinstance(response_or_template_contexts, Response):
|
|
||||||
return response_or_template_contexts
|
|
||||||
# If it has four items, it includes an HTTP status code
|
|
||||||
if len(response_or_template_contexts) == 4:
|
|
||||||
(
|
|
||||||
data,
|
|
||||||
extra_template_data,
|
|
||||||
templates,
|
|
||||||
status_code,
|
|
||||||
) = response_or_template_contexts
|
|
||||||
else:
|
|
||||||
data, extra_template_data, templates = response_or_template_contexts
|
|
||||||
except QueryInterrupted as ex:
|
|
||||||
raise DatasetteError(
|
|
||||||
textwrap.dedent("""
|
|
||||||
<p>SQL query took too long. The time limit is controlled by the
|
|
||||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
|
||||||
configuration option.</p>
|
|
||||||
<textarea style="width: 90%">{}</textarea>
|
|
||||||
<script>
|
|
||||||
let ta = document.querySelector("textarea");
|
|
||||||
ta.style.height = ta.scrollHeight + "px";
|
|
||||||
</script>
|
|
||||||
""".format(markupsafe.escape(ex.sql))).strip(),
|
|
||||||
title="SQL Interrupted",
|
|
||||||
status=400,
|
|
||||||
message_is_html=True,
|
|
||||||
plain_message=(
|
|
||||||
"SQL query took too long. The time limit is"
|
|
||||||
" controlled by the sql_time_limit_ms setting."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
|
||||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
|
||||||
except sqlite3.OperationalError as e:
|
|
||||||
raise DatasetteError(str(e))
|
|
||||||
except DatasetteError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
end = time.perf_counter()
|
|
||||||
data["query_ms"] = (end - start) * 1000
|
|
||||||
|
|
||||||
if format_ in self.ds.renderers.keys():
|
|
||||||
# Dispatch request to the correct output format renderer
|
|
||||||
# (CSV is not handled here due to streaming)
|
|
||||||
result = call_with_supported_arguments(
|
|
||||||
self.ds.renderers[format_][0],
|
|
||||||
datasette=self.ds,
|
|
||||||
columns=data.get("columns") or [],
|
|
||||||
rows=data.get("rows") or [],
|
|
||||||
sql=data.get("query", {}).get("sql", None),
|
|
||||||
query_name=data.get("query_name"),
|
|
||||||
database=database,
|
|
||||||
table=data.get("table"),
|
|
||||||
request=request,
|
|
||||||
view_name=self.name,
|
|
||||||
truncated=False, # TODO: support this
|
|
||||||
error=data.get("error"),
|
|
||||||
# These will be deprecated in Datasette 1.0:
|
|
||||||
args=request.args,
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
result = await result
|
|
||||||
if result is None:
|
|
||||||
raise NotFound("No data")
|
|
||||||
if isinstance(result, dict):
|
|
||||||
response = Response(
|
|
||||||
body=result.get("body"),
|
|
||||||
status=result.get("status_code", status_code or 200),
|
|
||||||
content_type=result.get("content_type", "text/plain"),
|
|
||||||
headers=result.get("headers"),
|
|
||||||
)
|
|
||||||
elif isinstance(result, Response):
|
|
||||||
response = result
|
|
||||||
if status_code is not None:
|
|
||||||
# Over-ride the status code
|
|
||||||
response.status = status_code
|
|
||||||
else:
|
|
||||||
assert False, f"{result} should be dict or Response"
|
|
||||||
elif format_ == "html":
|
|
||||||
response = await self.html(request, data, extra_template_data, templates)
|
|
||||||
if status_code is not None:
|
|
||||||
response.status = status_code
|
|
||||||
else:
|
|
||||||
raise NotFound("Invalid format: {}".format(format_))
|
|
||||||
|
|
||||||
ttl = request.args.get("_ttl", None)
|
|
||||||
if ttl is None or not ttl.isdigit():
|
|
||||||
ttl = self.ds.setting("default_cache_ttl")
|
|
||||||
|
|
||||||
return self.set_response_headers(response, ttl)
|
|
||||||
|
|
||||||
async def html(self, request, data, extra_template_data, templates):
|
|
||||||
extras = {}
|
|
||||||
if callable(extra_template_data):
|
|
||||||
extras = extra_template_data()
|
|
||||||
if asyncio.iscoroutine(extras):
|
|
||||||
extras = await extras
|
|
||||||
else:
|
|
||||||
extras = extra_template_data
|
|
||||||
|
|
||||||
url_labels_extra = {}
|
|
||||||
if data.get("expandable_columns"):
|
|
||||||
url_labels_extra = {"_labels": "on"}
|
|
||||||
|
|
||||||
renderers = {}
|
|
||||||
for key, (_, can_render) in self.ds.renderers.items():
|
|
||||||
it_can_render = call_with_supported_arguments(
|
|
||||||
can_render,
|
|
||||||
datasette=self.ds,
|
|
||||||
columns=data.get("columns") or [],
|
|
||||||
rows=data.get("rows") or [],
|
|
||||||
sql=data.get("query", {}).get("sql", None),
|
|
||||||
query_name=data.get("query_name"),
|
|
||||||
database=data.get("database"),
|
|
||||||
table=data.get("table"),
|
|
||||||
request=request,
|
|
||||||
view_name=self.name,
|
|
||||||
)
|
|
||||||
it_can_render = await await_me_maybe(it_can_render)
|
|
||||||
if it_can_render:
|
|
||||||
renderers[key] = self.ds.urls.path(
|
|
||||||
path_with_format(
|
|
||||||
request=request,
|
|
||||||
path=request.scope.get("route_path"),
|
|
||||||
format=key,
|
|
||||||
extra_qs={**url_labels_extra},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
url_csv_args = {"_size": "max", **url_labels_extra}
|
|
||||||
url_csv = self.ds.urls.path(
|
|
||||||
path_with_format(
|
|
||||||
request=request,
|
|
||||||
path=request.scope.get("route_path"),
|
|
||||||
format="csv",
|
|
||||||
extra_qs=url_csv_args,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
url_csv_path = url_csv.split("?")[0]
|
|
||||||
context = {**data, **extras}
|
|
||||||
if "metadata" not in context:
|
|
||||||
context["metadata"] = await self.ds.get_instance_metadata()
|
|
||||||
|
|
||||||
environment = self.ds.get_jinja_environment(request)
|
|
||||||
template = environment.select_template(templates)
|
|
||||||
alternate_url_json = self.ds.absolute_url(
|
|
||||||
request,
|
|
||||||
self.ds.urls.path(
|
|
||||||
path_with_format(
|
|
||||||
request=request,
|
|
||||||
path=request.scope.get("route_path"),
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return Response.html(
|
|
||||||
await self.ds.render_template(
|
|
||||||
template,
|
|
||||||
RowContext(
|
|
||||||
columns=context["columns"],
|
|
||||||
database=context["database"],
|
|
||||||
database_color=context["database_color"],
|
|
||||||
foreign_key_tables=context["foreign_key_tables"],
|
|
||||||
metadata=context["metadata"],
|
|
||||||
primary_keys=context["primary_keys"],
|
|
||||||
private=context["private"],
|
|
||||||
table=context["table"],
|
|
||||||
ok=context["ok"],
|
|
||||||
rows=context["rows"],
|
|
||||||
primary_key_values=context["primary_key_values"],
|
|
||||||
query_ms=context["query_ms"],
|
|
||||||
display_columns=context["display_columns"],
|
|
||||||
display_rows=context["display_rows"],
|
|
||||||
custom_table_templates=context["custom_table_templates"],
|
|
||||||
row_actions=context["row_actions"],
|
|
||||||
row_mutation_ui=context["row_mutation_ui"],
|
|
||||||
table_page_data=context["table_page_data"],
|
|
||||||
top_row=context["top_row"],
|
|
||||||
renderers=renderers,
|
|
||||||
url_csv=url_csv,
|
|
||||||
url_csv_path=url_csv_path,
|
|
||||||
url_csv_hidden_args=[
|
|
||||||
(key, value)
|
|
||||||
for key, value in urllib.parse.parse_qsl(request.query_string)
|
|
||||||
if key not in ("_labels", "_facet", "_size")
|
|
||||||
]
|
|
||||||
+ [("_size", "max")],
|
|
||||||
settings=self.ds.settings_dict(),
|
|
||||||
select_templates=[
|
|
||||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
|
||||||
for template_name in templates
|
|
||||||
],
|
|
||||||
alternate_url_json=alternate_url_json,
|
|
||||||
),
|
|
||||||
request=request,
|
|
||||||
view_name=self.name,
|
|
||||||
),
|
|
||||||
headers={
|
|
||||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
|
||||||
alternate_url_json
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_response_headers(self, response, ttl):
|
|
||||||
# Set far-future cache expiry
|
|
||||||
if self.ds.cache_headers and response.status == 200:
|
|
||||||
ttl = int(ttl)
|
|
||||||
if ttl == 0:
|
|
||||||
ttl_header = "no-cache"
|
|
||||||
else:
|
|
||||||
ttl_header = f"max-age={ttl}"
|
|
||||||
response.headers["Cache-Control"] = ttl_header
|
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
|
||||||
if self.ds.cors:
|
|
||||||
add_cors_headers(response.headers)
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def data(self, request, default_labels=False):
|
async def data(self, request, default_labels=False):
|
||||||
resolved = await self.ds.resolve_row(request)
|
resolved = await self.ds.resolve_row(request)
|
||||||
db = resolved.db
|
db = resolved.db
|
||||||
|
|
@ -420,7 +47,6 @@ class RowView(BaseView):
|
||||||
pks = resolved.pks
|
pks = resolved.pks
|
||||||
|
|
||||||
async def template_data():
|
async def template_data():
|
||||||
is_table = await db.table_exists(table)
|
|
||||||
# Reorder columns so primary keys come first
|
# Reorder columns so primary keys come first
|
||||||
pk_set = set(pks)
|
pk_set = set(pks)
|
||||||
pk_cols = [d for d in results.description if d[0] in pk_set]
|
pk_cols = [d for d in results.description if d[0] in pk_set]
|
||||||
|
|
@ -489,60 +115,7 @@ class RowView(BaseView):
|
||||||
"<strong>{}</strong>".format(cell["value"])
|
"<strong>{}</strong>".format(cell["value"])
|
||||||
)
|
)
|
||||||
|
|
||||||
label_column = await db.label_column_for_table(table) if is_table else None
|
|
||||||
row_path = path_from_row_pks(rows[0], pks, False)
|
|
||||||
pk_path = path_from_row_pks(rows[0], pks, False, False)
|
|
||||||
row_label = row_label_from_label_column(expanded_rows[0], label_column)
|
|
||||||
for display_row in display_rows:
|
|
||||||
display_row.pk_path = pk_path
|
|
||||||
display_row.row_path = row_path
|
|
||||||
display_row.row_label = row_label
|
|
||||||
|
|
||||||
row_action_label = pk_path
|
|
||||||
if row_label and row_label != pk_path:
|
|
||||||
row_action_label = "{} {}".format(pk_path, row_label)
|
|
||||||
|
|
||||||
row_action_permissions = {}
|
|
||||||
if is_table and db.is_mutable:
|
|
||||||
row_action_permissions = await self.ds.allowed_many(
|
|
||||||
actions=["update-row", "delete-row"],
|
|
||||||
resource=TableResource(database=database, table=table),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
row_actions = []
|
row_actions = []
|
||||||
if row_action_permissions.get("update-row"):
|
|
||||||
attrs = {
|
|
||||||
"aria-label": "Edit row {}".format(row_action_label),
|
|
||||||
"data-row": row_path,
|
|
||||||
"data-row-action": "edit",
|
|
||||||
}
|
|
||||||
if row_label:
|
|
||||||
attrs["data-row-label"] = row_label
|
|
||||||
row_actions.append(
|
|
||||||
{
|
|
||||||
"type": "button",
|
|
||||||
"label": "Edit row",
|
|
||||||
"description": "Open a dialog to edit this row.",
|
|
||||||
"attrs": attrs,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if row_action_permissions.get("delete-row"):
|
|
||||||
attrs = {
|
|
||||||
"aria-label": "Delete row {}".format(row_action_label),
|
|
||||||
"data-row": row_path,
|
|
||||||
"data-row-action": "delete",
|
|
||||||
}
|
|
||||||
if row_label:
|
|
||||||
attrs["data-row-label"] = row_label
|
|
||||||
row_actions.append(
|
|
||||||
{
|
|
||||||
"type": "button",
|
|
||||||
"label": "Delete row",
|
|
||||||
"description": "Open a confirmation dialog to delete this row.",
|
|
||||||
"attrs": attrs,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for hook in pm.hook.row_actions(
|
for hook in pm.hook.row_actions(
|
||||||
datasette=self.ds,
|
datasette=self.ds,
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
|
|
@ -569,17 +142,6 @@ class RowView(BaseView):
|
||||||
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
|
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
|
||||||
"_table.html",
|
"_table.html",
|
||||||
],
|
],
|
||||||
"row_mutation_ui": any(row_action_permissions.values()),
|
|
||||||
"table_page_data": await _table_page_data(
|
|
||||||
datasette=self.ds,
|
|
||||||
request=request,
|
|
||||||
db=db,
|
|
||||||
database_name=database,
|
|
||||||
table_name=table,
|
|
||||||
is_view=not is_table,
|
|
||||||
table_insert_ui=None,
|
|
||||||
table_alter_ui=None,
|
|
||||||
),
|
|
||||||
"row_actions": row_actions,
|
"row_actions": row_actions,
|
||||||
"top_row": make_slot_function(
|
"top_row": make_slot_function(
|
||||||
"top_row",
|
"top_row",
|
||||||
|
|
@ -602,30 +164,60 @@ class RowView(BaseView):
|
||||||
"primary_key_values": pk_values,
|
"primary_key_values": pk_values,
|
||||||
}
|
}
|
||||||
|
|
||||||
extras = extra_names_from_request(request)
|
# Handle _extra parameter (new style)
|
||||||
if request.url_vars.get("format"):
|
extras = _get_extras(request)
|
||||||
# Data formats reject unknown extras; HTML ignores them
|
|
||||||
table_extra_registry.validate_requested(extras, ExtraScope.ROW)
|
# Also support legacy _extras parameter for backward compatibility
|
||||||
|
if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
|
||||||
|
extras.add("foreign_key_tables")
|
||||||
|
|
||||||
# Process extras
|
# Process extras
|
||||||
row_extra_context = RowExtraContext(
|
if "foreign_key_tables" in extras:
|
||||||
|
data["foreign_key_tables"] = await self.foreign_key_tables(
|
||||||
|
database, table, pk_values
|
||||||
|
)
|
||||||
|
|
||||||
|
if "render_cell" in extras:
|
||||||
|
# Call render_cell plugin hook for each cell
|
||||||
|
ct_map = await self.ds.get_column_types(database, table)
|
||||||
|
rendered_rows = []
|
||||||
|
for row in rows:
|
||||||
|
rendered_row = {}
|
||||||
|
for value, column in zip(row, columns):
|
||||||
|
ct = ct_map.get(column)
|
||||||
|
plugin_display_value = None
|
||||||
|
# Try column type render_cell first
|
||||||
|
if ct:
|
||||||
|
candidate = await ct.render_cell(
|
||||||
|
value=value,
|
||||||
|
column=column,
|
||||||
|
table=table,
|
||||||
|
database=database,
|
||||||
datasette=self.ds,
|
datasette=self.ds,
|
||||||
request=request,
|
request=request,
|
||||||
db=db,
|
|
||||||
database_name=database,
|
|
||||||
table_name=table,
|
|
||||||
private=private,
|
|
||||||
rows=rows,
|
|
||||||
columns=columns,
|
|
||||||
pks=pks,
|
|
||||||
pk_values=pk_values,
|
|
||||||
sql=resolved.sql,
|
|
||||||
params=resolved.params,
|
|
||||||
extras=extras,
|
|
||||||
extra_registry=table_extra_registry,
|
|
||||||
foreign_key_tables=self.foreign_key_tables,
|
|
||||||
)
|
)
|
||||||
data.update(await resolve_row_extras(extras, row_extra_context))
|
if candidate is not None:
|
||||||
|
plugin_display_value = candidate
|
||||||
|
if plugin_display_value is None:
|
||||||
|
for candidate in pm.hook.render_cell(
|
||||||
|
row=row,
|
||||||
|
value=value,
|
||||||
|
column=column,
|
||||||
|
table=table,
|
||||||
|
pks=resolved.pks,
|
||||||
|
database=database,
|
||||||
|
datasette=self.ds,
|
||||||
|
request=request,
|
||||||
|
column_type=ct,
|
||||||
|
):
|
||||||
|
candidate = await await_me_maybe(candidate)
|
||||||
|
if candidate is not None:
|
||||||
|
plugin_display_value = candidate
|
||||||
|
break
|
||||||
|
if plugin_display_value:
|
||||||
|
rendered_row[column] = str(plugin_display_value)
|
||||||
|
rendered_rows.append(rendered_row)
|
||||||
|
data["render_cell"] = rendered_rows
|
||||||
|
|
||||||
return (
|
return (
|
||||||
data,
|
data,
|
||||||
|
|
@ -688,40 +280,17 @@ class RowError(Exception):
|
||||||
self.error = error
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
ROW_FLASH_LABEL_MAX_LENGTH = 80
|
|
||||||
|
|
||||||
|
|
||||||
def _truncated_row_flash_label(label):
|
|
||||||
label = " ".join(str(label).split())
|
|
||||||
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
|
|
||||||
return label
|
|
||||||
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
|
|
||||||
|
|
||||||
|
|
||||||
async def _row_flash_message(db, action, resolved, row=None):
|
|
||||||
pk_label = ", ".join(resolved.pk_values)
|
|
||||||
label_column = await db.label_column_for_table(resolved.table)
|
|
||||||
label = row_label_from_label_column(row or resolved.row, label_column)
|
|
||||||
if label:
|
|
||||||
label = _truncated_row_flash_label(label)
|
|
||||||
if label and label != pk_label:
|
|
||||||
return "{} row {} ({})".format(action, pk_label, label)
|
|
||||||
return "{} row {}".format(action, pk_label)
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_row_and_check_permission(datasette, request, permission):
|
async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||||
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
|
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await datasette.resolve_row(request)
|
resolved = await datasette.resolve_row(request)
|
||||||
except DatabaseNotFound as e:
|
except DatabaseNotFound as e:
|
||||||
return False, Response.error(
|
return False, _error(["Database not found: {}".format(e.database_name)], 404)
|
||||||
["Database not found: {}".format(e.database_name)], 404
|
|
||||||
)
|
|
||||||
except TableNotFound as e:
|
except TableNotFound as e:
|
||||||
return False, Response.error(["Table not found: {}".format(e.table)], 404)
|
return False, _error(["Table not found: {}".format(e.table)], 404)
|
||||||
except RowNotFound as e:
|
except RowNotFound as e:
|
||||||
return False, Response.error(["Record not found: {}".format(e.pk_values)], 404)
|
return False, _error(["Record not found: {}".format(e.pk_values)], 404)
|
||||||
|
|
||||||
# Ensure user has permission to delete this row
|
# Ensure user has permission to delete this row
|
||||||
if not await datasette.allowed(
|
if not await datasette.allowed(
|
||||||
|
|
@ -729,7 +298,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return False, Response.error(["Permission denied"], 403)
|
return False, _error(["Permission denied"], 403)
|
||||||
|
|
||||||
return True, resolved
|
return True, resolved
|
||||||
|
|
||||||
|
|
@ -754,7 +323,7 @@ class RowDeleteView(BaseView):
|
||||||
try:
|
try:
|
||||||
await resolved.db.execute_write_fn(delete_row, request=request)
|
await resolved.db.execute_write_fn(delete_row, request=request)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response.error([str(e)], 400)
|
return _error([str(e)], 500)
|
||||||
|
|
||||||
await self.ds.track_event(
|
await self.ds.track_event(
|
||||||
DeleteRowEvent(
|
DeleteRowEvent(
|
||||||
|
|
@ -765,15 +334,6 @@ class RowDeleteView(BaseView):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.args.get("_redirect_to_table"):
|
|
||||||
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
|
|
||||||
self.ds.add_message(
|
|
||||||
request,
|
|
||||||
await _row_flash_message(resolved.db, "Deleted", resolved),
|
|
||||||
self.ds.INFO,
|
|
||||||
)
|
|
||||||
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
|
|
||||||
|
|
||||||
return Response.json({"ok": True}, status=200)
|
return Response.json({"ok": True}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -790,27 +350,22 @@ class RowUpdateView(BaseView):
|
||||||
if not ok:
|
if not ok:
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
body = await request.post_body()
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = json.loads(body)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return Response.error(["Invalid JSON: {}".format(e)])
|
return _error(["Invalid JSON: {}".format(e)])
|
||||||
except PayloadTooLarge as e:
|
|
||||||
return Response.error([str(e)], 413)
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return Response.error(["JSON must be a dictionary"])
|
return _error(["JSON must be a dictionary"])
|
||||||
if "update" not in data or not isinstance(data["update"], dict):
|
if "update" not in data or not isinstance(data["update"], dict):
|
||||||
return Response.error(["JSON must contain an update dictionary"])
|
return _error(["JSON must contain an update dictionary"])
|
||||||
|
|
||||||
invalid_keys = set(data.keys()) - {"update", "return", "alter"}
|
invalid_keys = set(data.keys()) - {"update", "return", "alter"}
|
||||||
if invalid_keys:
|
if invalid_keys:
|
||||||
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
||||||
|
|
||||||
update = data["update"]
|
update = data["update"]
|
||||||
try:
|
|
||||||
update = decode_write_json_row(update)
|
|
||||||
except WriteJsonValueError as e:
|
|
||||||
return Response.error([str(e)], 400)
|
|
||||||
|
|
||||||
# Validate column types
|
# Validate column types
|
||||||
from datasette.views.table import _validate_column_types
|
from datasette.views.table import _validate_column_types
|
||||||
|
|
@ -819,7 +374,7 @@ class RowUpdateView(BaseView):
|
||||||
self.ds, resolved.db.name, resolved.table, [update]
|
self.ds, resolved.db.name, resolved.table, [update]
|
||||||
)
|
)
|
||||||
if ct_errors:
|
if ct_errors:
|
||||||
return Response.error(ct_errors, 400)
|
return _error(ct_errors, 400)
|
||||||
|
|
||||||
alter = data.get("alter")
|
alter = data.get("alter")
|
||||||
if alter and not await self.ds.allowed(
|
if alter and not await self.ds.allowed(
|
||||||
|
|
@ -827,7 +382,7 @@ class RowUpdateView(BaseView):
|
||||||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return Response.error(["Permission denied for alter-table"], 403)
|
return _error(["Permission denied for alter-table"], 403)
|
||||||
|
|
||||||
def update_row(conn):
|
def update_row(conn):
|
||||||
sqlite_utils.Database(conn)[resolved.table].update(
|
sqlite_utils.Database(conn)[resolved.table].update(
|
||||||
|
|
@ -837,16 +392,14 @@ class RowUpdateView(BaseView):
|
||||||
try:
|
try:
|
||||||
await resolved.db.execute_write_fn(update_row, request=request)
|
await resolved.db.execute_write_fn(update_row, request=request)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response.error([str(e)], 400)
|
return _error([str(e)], 400)
|
||||||
|
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
returned_row = None
|
|
||||||
if data.get("return"):
|
if data.get("return"):
|
||||||
results = await resolved.db.execute(
|
results = await resolved.db.execute(
|
||||||
resolved.sql, resolved.params, truncate=True
|
resolved.sql, resolved.params, truncate=True
|
||||||
)
|
)
|
||||||
returned_row = results.dicts()[0]
|
result["row"] = results.dicts()[0]
|
||||||
result["rows"] = [returned_row]
|
|
||||||
|
|
||||||
await self.ds.track_event(
|
await self.ds.track_event(
|
||||||
UpdateRowEvent(
|
UpdateRowEvent(
|
||||||
|
|
@ -857,19 +410,4 @@ class RowUpdateView(BaseView):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.args.get("_message"):
|
return Response.json(result, status=200)
|
||||||
message_row = returned_row
|
|
||||||
if message_row is None:
|
|
||||||
results = await resolved.db.execute(
|
|
||||||
resolved.sql, resolved.params, truncate=True
|
|
||||||
)
|
|
||||||
message_row = results.first()
|
|
||||||
self.ds.add_message(
|
|
||||||
request,
|
|
||||||
await _row_flash_message(
|
|
||||||
resolved.db, "Updated", resolved, row=message_row
|
|
||||||
),
|
|
||||||
self.ds.INFO,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response.json(result, status=200, default=CustomJSONEncoder().default)
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,9 @@ from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
from datasette.resources import DatabaseResource, TableResource
|
||||||
from datasette.utils.asgi import Response, Forbidden
|
from datasette.utils.asgi import Response, Forbidden
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
UNSTABLE_API_MESSAGE,
|
|
||||||
actor_matches_allow,
|
actor_matches_allow,
|
||||||
parse_size_limit,
|
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
error_body,
|
|
||||||
tilde_encode,
|
tilde_encode,
|
||||||
tilde_decode,
|
tilde_decode,
|
||||||
)
|
)
|
||||||
|
|
@ -55,9 +52,9 @@ class JsonDataView(BaseView):
|
||||||
if self.permission:
|
if self.permission:
|
||||||
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
|
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
|
||||||
if self.needs_request:
|
if self.needs_request:
|
||||||
data = await await_me_maybe(self.data_callback(request))
|
data = self.data_callback(request)
|
||||||
else:
|
else:
|
||||||
data = await await_me_maybe(self.data_callback())
|
data = self.data_callback()
|
||||||
|
|
||||||
# Return JSON or HTML depending on format parameter
|
# Return JSON or HTML depending on format parameter
|
||||||
as_format = request.url_vars.get("format")
|
as_format = request.url_vars.get("format")
|
||||||
|
|
@ -65,14 +62,12 @@ class JsonDataView(BaseView):
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
if isinstance(data, dict):
|
|
||||||
data = {"ok": True, **data}
|
|
||||||
return Response.json(data, headers=headers)
|
return Response.json(data, headers=headers)
|
||||||
else:
|
else:
|
||||||
context = {
|
context = {
|
||||||
"filename": self.filename,
|
"filename": self.filename,
|
||||||
"data": data,
|
"data": data,
|
||||||
"data_json": json.dumps(data, indent=2, default=repr),
|
"data_json": json.dumps(data, indent=4, default=repr),
|
||||||
}
|
}
|
||||||
# Add has_debug_permission if this view requires permissions-debug
|
# Add has_debug_permission if this view requires permissions-debug
|
||||||
if self.permission == "permissions-debug":
|
if self.permission == "permissions-debug":
|
||||||
|
|
@ -96,110 +91,6 @@ class PatternPortfolioView(View):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AutocompleteDebugView(BaseView):
|
|
||||||
name = "autocomplete_debug"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def _suggested_tables(self, request):
|
|
||||||
scanned = 0
|
|
||||||
reached_scan_limit = False
|
|
||||||
suggestions = []
|
|
||||||
for database_name, db in self.ds.databases.items():
|
|
||||||
if scanned >= 100 or len(suggestions) >= 5:
|
|
||||||
break
|
|
||||||
remaining = 100 - scanned
|
|
||||||
results = await db.execute(
|
|
||||||
"select name from sqlite_master where type = 'table' order by name limit ?",
|
|
||||||
[remaining],
|
|
||||||
)
|
|
||||||
for row in results.rows:
|
|
||||||
table_name = row["name"]
|
|
||||||
scanned += 1
|
|
||||||
if scanned >= 100:
|
|
||||||
reached_scan_limit = True
|
|
||||||
visible, _ = await self.ds.check_visibility(
|
|
||||||
request.actor,
|
|
||||||
action="view-table",
|
|
||||||
resource=TableResource(database=database_name, table=table_name),
|
|
||||||
)
|
|
||||||
if not visible:
|
|
||||||
if scanned >= 100:
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
label_column = await db.label_column_for_table(table_name)
|
|
||||||
if label_column:
|
|
||||||
suggestions.append(
|
|
||||||
{
|
|
||||||
"database": database_name,
|
|
||||||
"table": table_name,
|
|
||||||
"label_column": label_column,
|
|
||||||
"url": self.ds.urls.path(
|
|
||||||
"-/debug/autocomplete?"
|
|
||||||
+ urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"database": database_name,
|
|
||||||
"table": table_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(suggestions) >= 5:
|
|
||||||
break
|
|
||||||
if scanned >= 100:
|
|
||||||
break
|
|
||||||
return suggestions, scanned, reached_scan_limit
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
|
|
||||||
database_name = request.args.get("database")
|
|
||||||
table_name = request.args.get("table")
|
|
||||||
context = {
|
|
||||||
"database_name": database_name,
|
|
||||||
"table_name": table_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
if database_name or table_name:
|
|
||||||
if not database_name or not table_name:
|
|
||||||
context["error"] = "Both database and table are required."
|
|
||||||
elif database_name not in self.ds.databases:
|
|
||||||
context["error"] = "Database not found."
|
|
||||||
else:
|
|
||||||
db = self.ds.databases[database_name]
|
|
||||||
if not await db.table_exists(table_name):
|
|
||||||
context["error"] = "Table not found."
|
|
||||||
else:
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="view-table",
|
|
||||||
resource=TableResource(
|
|
||||||
database=database_name,
|
|
||||||
table=table_name,
|
|
||||||
),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
context.update(
|
|
||||||
{
|
|
||||||
"autocomplete_url": "{}/-/autocomplete".format(
|
|
||||||
self.ds.urls.table(database_name, table_name)
|
|
||||||
),
|
|
||||||
"label_column": await db.label_column_for_table(table_name),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
suggestions, scanned, reached_scan_limit = await self._suggested_tables(
|
|
||||||
request
|
|
||||||
)
|
|
||||||
context.update(
|
|
||||||
{
|
|
||||||
"suggestions": suggestions,
|
|
||||||
"scanned": scanned,
|
|
||||||
"reached_scan_limit": reached_scan_limit,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self.render(["debug_autocomplete.html"], request, context)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthTokenView(BaseView):
|
class AuthTokenView(BaseView):
|
||||||
name = "auth_token"
|
name = "auth_token"
|
||||||
has_json_alternate = False
|
has_json_alternate = False
|
||||||
|
|
@ -297,12 +188,6 @@ class PermissionsDebugView(BaseView):
|
||||||
response, status = await _check_permission_for_actor(
|
response, status = await _check_permission_for_actor(
|
||||||
self.ds, permission, parent, child, actor
|
self.ds, permission, parent, child, actor
|
||||||
)
|
)
|
||||||
if response.get("ok"):
|
|
||||||
response = {
|
|
||||||
"ok": True,
|
|
||||||
"unstable": UNSTABLE_API_MESSAGE,
|
|
||||||
**response,
|
|
||||||
}
|
|
||||||
return Response.json(response, status=status)
|
return Response.json(response, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -359,32 +244,29 @@ class AllowedResourcesView(BaseView):
|
||||||
async def _allowed_payload(self, request, has_debug_permission):
|
async def _allowed_payload(self, request, has_debug_permission):
|
||||||
action = request.args.get("action")
|
action = request.args.get("action")
|
||||||
if not action:
|
if not action:
|
||||||
return error_body("action parameter is required", 400), 400
|
return {"error": "action parameter is required"}, 400
|
||||||
if action not in self.ds.actions:
|
if action not in self.ds.actions:
|
||||||
return error_body(f"Unknown action: {action}", 404), 404
|
return {"error": f"Unknown action: {action}"}, 404
|
||||||
|
|
||||||
actor = request.actor if isinstance(request.actor, dict) else None
|
actor = request.actor if isinstance(request.actor, dict) else None
|
||||||
actor_id = actor.get("id") if actor else None
|
actor_id = actor.get("id") if actor else None
|
||||||
parent_filter = request.args.get("parent")
|
parent_filter = request.args.get("parent")
|
||||||
child_filter = request.args.get("child")
|
child_filter = request.args.get("child")
|
||||||
if child_filter and not parent_filter:
|
if child_filter and not parent_filter:
|
||||||
return (
|
return {"error": "parent must be provided when child is specified"}, 400
|
||||||
error_body("parent must be provided when child is specified", 400),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page = int(request.args.get("_page", "1"))
|
page = int(request.args.get("page", "1"))
|
||||||
if page < 1:
|
page_size = int(request.args.get("page_size", "50"))
|
||||||
raise ValueError
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return error_body("_page must be a positive integer", 400), 400
|
return {"error": "page and page_size must be integers"}, 400
|
||||||
try:
|
if page < 1:
|
||||||
page_size = parse_size_limit(
|
return {"error": "page must be >= 1"}, 400
|
||||||
request.args.get("_size"), default=50, maximum=200
|
if page_size < 1:
|
||||||
)
|
return {"error": "page_size must be >= 1"}, 400
|
||||||
except ValueError as ex:
|
max_page_size = 200
|
||||||
return error_body(str(ex), 400), 400
|
if page_size > max_page_size:
|
||||||
|
page_size = max_page_size
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# Use the simplified allowed_resources method
|
# Use the simplified allowed_resources method
|
||||||
|
|
@ -424,7 +306,6 @@ class AllowedResourcesView(BaseView):
|
||||||
# If catalog tables don't exist yet, return empty results
|
# If catalog tables don't exist yet, return empty results
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
"ok": True,
|
|
||||||
"action": action,
|
"action": action,
|
||||||
"actor_id": actor_id,
|
"actor_id": actor_id,
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|
@ -449,17 +330,16 @@ class AllowedResourcesView(BaseView):
|
||||||
def build_page_url(page_number):
|
def build_page_url(page_number):
|
||||||
pairs = []
|
pairs = []
|
||||||
for key in request.args:
|
for key in request.args:
|
||||||
if key in {"_page", "_size"}:
|
if key in {"page", "page_size"}:
|
||||||
continue
|
continue
|
||||||
for value in request.args.getlist(key):
|
for value in request.args.getlist(key):
|
||||||
pairs.append((key, value))
|
pairs.append((key, value))
|
||||||
pairs.append(("_page", str(page_number)))
|
pairs.append(("page", str(page_number)))
|
||||||
pairs.append(("_size", str(page_size)))
|
pairs.append(("page_size", str(page_size)))
|
||||||
query = urllib.parse.urlencode(pairs)
|
query = urllib.parse.urlencode(pairs)
|
||||||
return f"{request.path}?{query}"
|
return f"{request.path}?{query}"
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"ok": True,
|
|
||||||
"action": action,
|
"action": action,
|
||||||
"actor_id": actor_id,
|
"actor_id": actor_id,
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|
@ -501,24 +381,26 @@ class PermissionRulesView(BaseView):
|
||||||
# JSON API - action parameter is required
|
# JSON API - action parameter is required
|
||||||
action = request.args.get("action")
|
action = request.args.get("action")
|
||||||
if not action:
|
if not action:
|
||||||
return Response.error("action parameter is required", 400)
|
return Response.json({"error": "action parameter is required"}, status=400)
|
||||||
if action not in self.ds.actions:
|
if action not in self.ds.actions:
|
||||||
return Response.error(f"Unknown action: {action}", 404)
|
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||||
|
|
||||||
actor = request.actor if isinstance(request.actor, dict) else None
|
actor = request.actor if isinstance(request.actor, dict) else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page = int(request.args.get("_page", "1"))
|
page = int(request.args.get("page", "1"))
|
||||||
if page < 1:
|
page_size = int(request.args.get("page_size", "50"))
|
||||||
raise ValueError
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return Response.error("_page must be a positive integer", 400)
|
return Response.json(
|
||||||
try:
|
{"error": "page and page_size must be integers"}, status=400
|
||||||
page_size = parse_size_limit(
|
|
||||||
request.args.get("_size"), default=50, maximum=200
|
|
||||||
)
|
)
|
||||||
except ValueError as ex:
|
if page < 1:
|
||||||
return Response.error(str(ex), 400)
|
return Response.json({"error": "page must be >= 1"}, status=400)
|
||||||
|
if page_size < 1:
|
||||||
|
return Response.json({"error": "page_size must be >= 1"}, status=400)
|
||||||
|
max_page_size = 200
|
||||||
|
if page_size > max_page_size:
|
||||||
|
page_size = max_page_size
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
from datasette.utils.actions_sql import build_permission_rules_sql
|
from datasette.utils.actions_sql import build_permission_rules_sql
|
||||||
|
|
@ -569,17 +451,16 @@ class PermissionRulesView(BaseView):
|
||||||
def build_page_url(page_number):
|
def build_page_url(page_number):
|
||||||
pairs = []
|
pairs = []
|
||||||
for key in request.args:
|
for key in request.args:
|
||||||
if key in {"_page", "_size"}:
|
if key in {"page", "page_size"}:
|
||||||
continue
|
continue
|
||||||
for value in request.args.getlist(key):
|
for value in request.args.getlist(key):
|
||||||
pairs.append((key, value))
|
pairs.append((key, value))
|
||||||
pairs.append(("_page", str(page_number)))
|
pairs.append(("page", str(page_number)))
|
||||||
pairs.append(("_size", str(page_size)))
|
pairs.append(("page_size", str(page_size)))
|
||||||
query = urllib.parse.urlencode(pairs)
|
query = urllib.parse.urlencode(pairs)
|
||||||
return f"{request.path}?{query}"
|
return f"{request.path}?{query}"
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"ok": True,
|
|
||||||
"action": action,
|
"action": action,
|
||||||
"actor_id": (actor or {}).get("id") if actor else None,
|
"actor_id": (actor or {}).get("id") if actor else None,
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|
@ -602,35 +483,32 @@ class PermissionRulesView(BaseView):
|
||||||
async def _check_permission_for_actor(ds, action, parent, child, actor):
|
async def _check_permission_for_actor(ds, action, parent, child, actor):
|
||||||
"""Shared logic for checking permissions. Returns a dict with check results."""
|
"""Shared logic for checking permissions. Returns a dict with check results."""
|
||||||
if action not in ds.actions:
|
if action not in ds.actions:
|
||||||
return error_body(f"Unknown action: {action}", 404), 404
|
return {"error": f"Unknown action: {action}"}, 404
|
||||||
|
|
||||||
if child and not parent:
|
if child and not parent:
|
||||||
return error_body("parent is required when child is provided", 400), 400
|
return {"error": "parent is required when child is provided"}, 400
|
||||||
|
|
||||||
# Use the action's properties to create the appropriate resource object
|
# Use the action's properties to create the appropriate resource object
|
||||||
action_obj = ds.actions.get(action)
|
action_obj = ds.actions.get(action)
|
||||||
if not action_obj:
|
if not action_obj:
|
||||||
return error_body(f"Unknown action: {action}", 400), 400
|
return {"error": f"Unknown action: {action}"}, 400
|
||||||
|
|
||||||
# Global actions (no resource_class) don't have a resource
|
# Global actions (no resource_class) don't have a resource
|
||||||
if action_obj.resource_class is None:
|
if action_obj.resource_class is None:
|
||||||
resource_obj = None
|
resource_obj = None
|
||||||
elif action_obj.takes_parent and action_obj.takes_child:
|
elif action_obj.takes_parent and action_obj.takes_child:
|
||||||
# Child-level resource (e.g., TableResource, QueryResource). The child
|
# Child-level resource (e.g., TableResource, QueryResource)
|
||||||
# argument is named differently per resource class (table, query, ...),
|
resource_obj = action_obj.resource_class(database=parent, table=child)
|
||||||
# so pass positionally - https://github.com/simonw/datasette/issues/2756
|
|
||||||
resource_obj = action_obj.resource_class(parent, child)
|
|
||||||
elif action_obj.takes_parent:
|
elif action_obj.takes_parent:
|
||||||
# Parent-level resource (e.g., DatabaseResource)
|
# Parent-level resource (e.g., DatabaseResource)
|
||||||
resource_obj = action_obj.resource_class(parent)
|
resource_obj = action_obj.resource_class(database=parent)
|
||||||
else:
|
else:
|
||||||
# This shouldn't happen given validation in Action.__post_init__
|
# This shouldn't happen given validation in Action.__post_init__
|
||||||
return error_body(f"Invalid action configuration: {action}", 500), 500
|
return {"error": f"Invalid action configuration: {action}"}, 500
|
||||||
|
|
||||||
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
|
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"ok": True,
|
|
||||||
"action": action,
|
"action": action,
|
||||||
"allowed": bool(allowed),
|
"allowed": bool(allowed),
|
||||||
"resource": {
|
"resource": {
|
||||||
|
|
@ -667,7 +545,7 @@ class PermissionCheckView(BaseView):
|
||||||
# JSON API - action parameter is required
|
# JSON API - action parameter is required
|
||||||
action = request.args.get("action")
|
action = request.args.get("action")
|
||||||
if not action:
|
if not action:
|
||||||
return Response.error("action parameter is required", 400)
|
return Response.json({"error": "action parameter is required"}, status=400)
|
||||||
|
|
||||||
parent = request.args.get("parent")
|
parent = request.args.get("parent")
|
||||||
child = request.args.get("child")
|
child = request.args.get("child")
|
||||||
|
|
@ -1014,15 +892,14 @@ class ApiExplorerView(BaseView):
|
||||||
raise Forbidden("You do not have permission to view this instance")
|
raise Forbidden("You do not have permission to view this instance")
|
||||||
|
|
||||||
def api_path(link):
|
def api_path(link):
|
||||||
return "{}#{}".format(
|
return "/-/api#{}".format(
|
||||||
self.ds.urls.path("/-/api"),
|
|
||||||
urllib.parse.urlencode(
|
urllib.parse.urlencode(
|
||||||
{
|
{
|
||||||
key: json.dumps(value, indent=2) if key == "json" else value
|
key: json.dumps(value, indent=2) if key == "json" else value
|
||||||
for key, value in link.items()
|
for key, value in link.items()
|
||||||
if key in ("path", "method", "json")
|
if key in ("path", "method", "json")
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return await self.render(
|
return await self.render(
|
||||||
|
|
@ -1214,7 +1091,7 @@ class JumpView(BaseView):
|
||||||
match["display_name"] = row["display_name"]
|
match["display_name"] = row["display_name"]
|
||||||
matches.append(match)
|
matches.append(match)
|
||||||
|
|
||||||
return Response.json({"ok": True, "matches": matches, "truncated": truncated})
|
return Response.json({"matches": matches, "truncated": truncated})
|
||||||
|
|
||||||
|
|
||||||
class SchemaBaseView(BaseView):
|
class SchemaBaseView(BaseView):
|
||||||
|
|
@ -1236,7 +1113,7 @@ class SchemaBaseView(BaseView):
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
return Response.json({"ok": True, **data}, headers=headers)
|
return Response.json(data, headers=headers)
|
||||||
|
|
||||||
def format_error_response(self, error_message, format_, status=404):
|
def format_error_response(self, error_message, format_, status=404):
|
||||||
"""Format error response based on requested format."""
|
"""Format error response based on requested format."""
|
||||||
|
|
@ -1245,7 +1122,7 @@ class SchemaBaseView(BaseView):
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
error_body(error_message, status), status=status, headers=headers
|
{"ok": False, "error": error_message}, status=status, headers=headers
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return Response.text(error_message, status=status)
|
return Response.text(error_message, status=status)
|
||||||
|
|
@ -1321,17 +1198,17 @@ class DatabaseSchemaView(SchemaBaseView):
|
||||||
database_name = request.url_vars["database"]
|
database_name = request.url_vars["database"]
|
||||||
format_ = request.url_vars.get("format") or "html"
|
format_ = request.url_vars.get("format") or "html"
|
||||||
|
|
||||||
# Permission check comes first, so actors without view-database
|
# Check if database exists
|
||||||
# cannot distinguish existing databases from missing ones
|
if database_name not in self.ds.databases:
|
||||||
|
return self.format_error_response("Database not found", format_)
|
||||||
|
|
||||||
|
# Check view-database permission
|
||||||
await self.ds.ensure_permission(
|
await self.ds.ensure_permission(
|
||||||
action="view-database",
|
action="view-database",
|
||||||
resource=DatabaseResource(database=database_name),
|
resource=DatabaseResource(database=database_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
if database_name not in self.ds.databases:
|
|
||||||
return self.format_error_response("Database not found", format_)
|
|
||||||
|
|
||||||
schema = await self.get_database_schema(database_name)
|
schema = await self.get_database_schema(database_name)
|
||||||
|
|
||||||
if format_ == "json":
|
if format_ == "json":
|
||||||
|
|
@ -1365,9 +1242,6 @@ class TableSchemaView(SchemaBaseView):
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
if database_name not in self.ds.databases:
|
|
||||||
return self.format_error_response("Database not found", format_)
|
|
||||||
|
|
||||||
# Get schema for the table
|
# Get schema for the table
|
||||||
db = self.ds.databases[database_name]
|
db = self.ds.databases[database_name]
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|
|
||||||
|
|
@ -1,677 +0,0 @@
|
||||||
from urllib.parse import parse_qsl, urlencode
|
|
||||||
|
|
||||||
from datasette.resources import DatabaseResource, QueryResource
|
|
||||||
from datasette.stored_queries import stored_query_to_dict
|
|
||||||
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3, tilde_decode
|
|
||||||
from datasette.utils.asgi import Response
|
|
||||||
|
|
||||||
from .base import BaseView
|
|
||||||
from .query_helpers import (
|
|
||||||
QueryValidationError,
|
|
||||||
_as_bool,
|
|
||||||
_as_optional_bool,
|
|
||||||
_block_framing,
|
|
||||||
_derived_query_parameters,
|
|
||||||
_json_or_form_payload,
|
|
||||||
_prepare_query_create,
|
|
||||||
_prepare_query_update,
|
|
||||||
_query_create_analysis_data,
|
|
||||||
_query_create_form_context,
|
|
||||||
_query_create_form_error_message,
|
|
||||||
_query_edit_form_context,
|
|
||||||
_query_list_limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryParametersView(BaseView):
|
|
||||||
name = "query-parameters"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(["Permission denied: need execute-sql"], 403)
|
|
||||||
)
|
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
|
||||||
if invalid_keys:
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(
|
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
parameters = _derived_query_parameters(request.args.get("sql") or "")
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
return _block_framing(Response.error([ex.message], ex.status))
|
|
||||||
return _block_framing(
|
|
||||||
Response.json(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"unstable": UNSTABLE_API_MESSAGE,
|
|
||||||
"parameters": parameters,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
|
|
||||||
set_args = set_args or {}
|
|
||||||
remove_args = set(remove_args or ())
|
|
||||||
skip = set(set_args) | remove_args | {"_next"}
|
|
||||||
pairs = [
|
|
||||||
(key, value)
|
|
||||||
for key, value in parse_qsl(query_string, keep_blank_values=True)
|
|
||||||
if key not in skip
|
|
||||||
]
|
|
||||||
for key, value in set_args.items():
|
|
||||||
if value not in (None, ""):
|
|
||||||
pairs.append((key, value))
|
|
||||||
return path + (("?" + urlencode(pairs)) if pairs else "")
|
|
||||||
|
|
||||||
|
|
||||||
class QueryListView(BaseView):
|
|
||||||
name = "query-list"
|
|
||||||
|
|
||||||
async def database_name(self, request):
|
|
||||||
return (await self.ds.resolve_database(request)).name
|
|
||||||
|
|
||||||
def query_list_path(self, database):
|
|
||||||
return self.ds.urls.database(database) + "/-/queries"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
database = await self.database_name(request)
|
|
||||||
format_ = request.url_vars.get("format") or "html"
|
|
||||||
try:
|
|
||||||
limit = _query_list_limit(
|
|
||||||
request.args.get("_size"),
|
|
||||||
default=20 if format_ == "html" else 50,
|
|
||||||
maximum=self.ds.max_returned_rows,
|
|
||||||
)
|
|
||||||
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
|
|
||||||
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
return Response.error([ex.message], ex.status)
|
|
||||||
|
|
||||||
page = await self.ds.list_queries(
|
|
||||||
database,
|
|
||||||
actor=request.actor,
|
|
||||||
limit=limit,
|
|
||||||
cursor=request.args.get("_next"),
|
|
||||||
q=request.args.get("q") or None,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
source=request.args.get("source") or None,
|
|
||||||
owner_id=request.args.get("owner_id") or None,
|
|
||||||
include_private=True,
|
|
||||||
)
|
|
||||||
query_list_path = self.query_list_path(database)
|
|
||||||
next_url = None
|
|
||||||
if page.next:
|
|
||||||
pairs = [
|
|
||||||
(key, value)
|
|
||||||
for key, value in parse_qsl(
|
|
||||||
request.query_string, keep_blank_values=True
|
|
||||||
)
|
|
||||||
if key != "_next"
|
|
||||||
]
|
|
||||||
pairs.append(("_next", page.next))
|
|
||||||
next_url = self.ds.absolute_url(
|
|
||||||
request,
|
|
||||||
"{}?{}".format(request.path, urlencode(pairs)),
|
|
||||||
)
|
|
||||||
|
|
||||||
current_filters = {
|
|
||||||
"actor": request.actor,
|
|
||||||
"q": request.args.get("q") or None,
|
|
||||||
"is_write": is_write,
|
|
||||||
"is_private": is_private,
|
|
||||||
"source": request.args.get("source") or None,
|
|
||||||
"owner_id": request.args.get("owner_id") or None,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def facet_count(field, value):
|
|
||||||
if current_filters[field] is not None and current_filters[field] != value:
|
|
||||||
return 0
|
|
||||||
filters = dict(current_filters)
|
|
||||||
filters[field] = value
|
|
||||||
return await self.ds.count_queries(database, **filters)
|
|
||||||
|
|
||||||
def facet_href(field, value):
|
|
||||||
if current_filters[field] == value:
|
|
||||||
return _query_list_url(
|
|
||||||
query_list_path,
|
|
||||||
request.query_string,
|
|
||||||
remove_args=[field],
|
|
||||||
)
|
|
||||||
if current_filters[field] is not None:
|
|
||||||
return None
|
|
||||||
return _query_list_url(
|
|
||||||
query_list_path,
|
|
||||||
request.query_string,
|
|
||||||
set_args={field: str(int(value))},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def facet_item(label, field, value):
|
|
||||||
count = await facet_count(field, value)
|
|
||||||
active = current_filters[field] == value
|
|
||||||
if not active and not count:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"label": label,
|
|
||||||
"count": count,
|
|
||||||
"href": facet_href(field, value) if active or count else None,
|
|
||||||
"active": active,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def facet_items(items):
|
|
||||||
return [
|
|
||||||
item
|
|
||||||
for item in [
|
|
||||||
await facet_item(label, field, value)
|
|
||||||
for label, field, value in items
|
|
||||||
]
|
|
||||||
if item is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
facets = [
|
|
||||||
{
|
|
||||||
"title": "Mode",
|
|
||||||
"items": await facet_items(
|
|
||||||
[
|
|
||||||
("Read-only", "is_write", False),
|
|
||||||
("Writable", "is_write", True),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Visibility",
|
|
||||||
"items": await facet_items(
|
|
||||||
[
|
|
||||||
("Not private", "is_private", False),
|
|
||||||
("Private", "is_private", True),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"ok": True,
|
|
||||||
"database": database,
|
|
||||||
"database_color": (
|
|
||||||
self.ds.get_database(database).color if database is not None else None
|
|
||||||
),
|
|
||||||
"queries": page.queries,
|
|
||||||
"next": page.next,
|
|
||||||
"next_url": next_url,
|
|
||||||
"limit": page.limit,
|
|
||||||
"show_private_note": any(query.is_private for query in page.queries),
|
|
||||||
"show_trusted_note": any(query.is_trusted for query in page.queries),
|
|
||||||
"query_list_path": query_list_path,
|
|
||||||
"show_database": database is None,
|
|
||||||
"facets": facets,
|
|
||||||
"filters": {
|
|
||||||
"q": request.args.get("q") or "",
|
|
||||||
"is_write": request.args.get("is_write") or "",
|
|
||||||
"is_private": request.args.get("is_private") or "",
|
|
||||||
"source": request.args.get("source") or "",
|
|
||||||
"owner_id": request.args.get("owner_id") or "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if format_ == "json":
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
**data,
|
|
||||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return await self.render(
|
|
||||||
["query_list.html"],
|
|
||||||
request,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalQueryListView(QueryListView):
|
|
||||||
name = "global-query-list"
|
|
||||||
|
|
||||||
async def database_name(self, request):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def query_list_path(self, database):
|
|
||||||
return self.ds.urls.path("/-/queries")
|
|
||||||
|
|
||||||
|
|
||||||
class QueryCreateView(BaseView):
|
|
||||||
name = "query-create"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def _render_form(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
*,
|
|
||||||
sql="",
|
|
||||||
name="",
|
|
||||||
title="",
|
|
||||||
description="",
|
|
||||||
is_private=True,
|
|
||||||
status=200,
|
|
||||||
):
|
|
||||||
response = await self.render(
|
|
||||||
["query_create.html"],
|
|
||||||
request,
|
|
||||||
await _query_create_form_context(
|
|
||||||
self.ds,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=sql,
|
|
||||||
name=name,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
is_private=is_private,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
response.status = status
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self._render_form(request, db, sql=request.args.get("sql") or "")
|
|
||||||
|
|
||||||
|
|
||||||
class QueryCreateAnalyzeView(BaseView):
|
|
||||||
name = "query-create-analyze"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(["Permission denied: need execute-sql"], 403)
|
|
||||||
)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(["Permission denied: need store-query"], 403)
|
|
||||||
)
|
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
|
||||||
if invalid_keys:
|
|
||||||
return _block_framing(
|
|
||||||
Response.error(
|
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sql = request.args.get("sql") or ""
|
|
||||||
analysis = await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
|
||||||
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
|
||||||
return _block_framing(Response.json(analysis))
|
|
||||||
|
|
||||||
|
|
||||||
class QueryStoreView(QueryCreateView):
|
|
||||||
name = "query-store"
|
|
||||||
|
|
||||||
async def _error_response(self, request, db, query_data, message, status):
|
|
||||||
message = _query_create_form_error_message(message)
|
|
||||||
self.ds.add_message(request, message, self.ds.ERROR)
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=query_data.get("sql") or "",
|
|
||||||
name=query_data.get("name") or "",
|
|
||||||
title=query_data.get("title") or "",
|
|
||||||
description=query_data.get("description") or "",
|
|
||||||
is_private=_as_bool(query_data.get("is_private", True)),
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return Response.error(["Permission denied: need execute-sql"], 403)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return Response.error(["Permission denied: need store-query"], 403)
|
|
||||||
|
|
||||||
is_json = False
|
|
||||||
query_data = {}
|
|
||||||
try:
|
|
||||||
data, is_json = await _json_or_form_payload(request)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise QueryValidationError("JSON must be a dictionary")
|
|
||||||
query_data = data.get("query") if is_json else data
|
|
||||||
if not isinstance(query_data, dict):
|
|
||||||
raise QueryValidationError("JSON must contain a query dictionary")
|
|
||||||
prepared = await _prepare_query_create(self.ds, request, db, query_data)
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
if not is_json and isinstance(query_data, dict):
|
|
||||||
return await self._error_response(
|
|
||||||
request, db, query_data, ex.message, ex.status
|
|
||||||
)
|
|
||||||
return Response.error([ex.message], ex.status)
|
|
||||||
|
|
||||||
prepared.pop("analysis")
|
|
||||||
name = prepared.pop("name")
|
|
||||||
try:
|
|
||||||
await self.ds.add_query(db.name, name, replace=False, **prepared)
|
|
||||||
except sqlite3.IntegrityError as ex:
|
|
||||||
if not is_json and isinstance(query_data, dict):
|
|
||||||
return await self._error_response(request, db, query_data, str(ex), 400)
|
|
||||||
return Response.error([str(ex)], 400)
|
|
||||||
|
|
||||||
query = await self.ds.get_query(db.name, name)
|
|
||||||
assert query is not None
|
|
||||||
if is_json:
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"unstable": UNSTABLE_API_MESSAGE,
|
|
||||||
"query": stored_query_to_dict(query),
|
|
||||||
},
|
|
||||||
status=201,
|
|
||||||
)
|
|
||||||
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
|
||||||
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
|
|
||||||
|
|
||||||
|
|
||||||
class QueryDefinitionView(BaseView):
|
|
||||||
name = "query-definition"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
|
||||||
query = await self.ds.get_query(db.name, query_name)
|
|
||||||
if query is None:
|
|
||||||
return Response.error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="view-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return Response.error(["Permission denied"], 403)
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"unstable": UNSTABLE_API_MESSAGE,
|
|
||||||
"query": stored_query_to_dict(query),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryUpdateView(BaseView):
|
|
||||||
name = "query-update"
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
|
||||||
existing = await self.ds.get_query(db.name, query_name)
|
|
||||||
if existing is None:
|
|
||||||
return Response.error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="update-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return Response.error(["Permission denied: need update-query"], 403)
|
|
||||||
if existing.is_trusted:
|
|
||||||
return Response.error(
|
|
||||||
["Trusted queries cannot be updated using the API"], 403
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data, _ = await _json_or_form_payload(request)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise QueryValidationError("JSON must be a dictionary")
|
|
||||||
invalid_keys = set(data) - {"update", "return"}
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(invalid_keys))
|
|
||||||
)
|
|
||||||
update = data.get("update")
|
|
||||||
if not isinstance(update, dict):
|
|
||||||
raise QueryValidationError("JSON must contain an update dictionary")
|
|
||||||
if "sql" in update and not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Permission denied: need execute-sql", status=403
|
|
||||||
)
|
|
||||||
update_kwargs = await _prepare_query_update(
|
|
||||||
self.ds, request, db, existing, update
|
|
||||||
)
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
return Response.error([ex.message], ex.status)
|
|
||||||
|
|
||||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
|
||||||
if data.get("return"):
|
|
||||||
query = await self.ds.get_query(db.name, query_name)
|
|
||||||
assert query is not None
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"query": stored_query_to_dict(query),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return Response.json({"ok": True})
|
|
||||||
|
|
||||||
|
|
||||||
class QueryEditView(BaseView):
|
|
||||||
name = "query-edit"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def _load(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
|
||||||
existing = await self.ds.get_query(db.name, query_name)
|
|
||||||
return db, query_name, existing
|
|
||||||
|
|
||||||
async def _render_form(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
existing,
|
|
||||||
*,
|
|
||||||
sql=None,
|
|
||||||
title=None,
|
|
||||||
description=None,
|
|
||||||
is_private=None,
|
|
||||||
status=200,
|
|
||||||
):
|
|
||||||
response = await self.render(
|
|
||||||
["query_edit.html"],
|
|
||||||
request,
|
|
||||||
await _query_edit_form_context(
|
|
||||||
self.ds,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
existing,
|
|
||||||
sql=sql,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
is_private=is_private,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
response.status = status
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db, query_name, existing = await self._load(request)
|
|
||||||
if existing is None:
|
|
||||||
return Response.error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="update-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
if existing.is_trusted:
|
|
||||||
return Response.error(["Trusted queries cannot be edited"], 403)
|
|
||||||
return await self._render_form(request, db, existing)
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db, query_name, existing = await self._load(request)
|
|
||||||
if existing is None:
|
|
||||||
return Response.error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="update-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return Response.error(["Permission denied: need update-query"], 403)
|
|
||||||
if existing.is_trusted:
|
|
||||||
return Response.error(["Trusted queries cannot be edited"], 403)
|
|
||||||
|
|
||||||
data, _ = await _json_or_form_payload(request)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return Response.error(["Invalid form submission"], 400)
|
|
||||||
sql = data.get("sql")
|
|
||||||
sql = existing.sql if sql is None else sql.strip()
|
|
||||||
title = data.get("title") or ""
|
|
||||||
description = data.get("description") or ""
|
|
||||||
is_private = _as_bool(data.get("is_private"))
|
|
||||||
|
|
||||||
update = {
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"is_private": is_private,
|
|
||||||
}
|
|
||||||
if sql != existing.sql:
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
self.ds.add_message(
|
|
||||||
request,
|
|
||||||
"Permission denied: need execute-sql to change the SQL",
|
|
||||||
self.ds.ERROR,
|
|
||||||
)
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
existing,
|
|
||||||
sql=sql,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
is_private=is_private,
|
|
||||||
status=403,
|
|
||||||
)
|
|
||||||
update["sql"] = sql
|
|
||||||
|
|
||||||
try:
|
|
||||||
update_kwargs = await _prepare_query_update(
|
|
||||||
self.ds, request, db, existing, update
|
|
||||||
)
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
self.ds.add_message(request, ex.message, self.ds.ERROR)
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
existing,
|
|
||||||
sql=sql,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
is_private=is_private,
|
|
||||||
status=ex.status,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
|
||||||
self.ds.add_message(request, "Query updated", self.ds.INFO)
|
|
||||||
return Response.redirect(
|
|
||||||
self.ds.urls.path(self.ds.urls.table(db.name, query_name))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryDeleteView(BaseView):
|
|
||||||
name = "query-delete"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def _load(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
|
||||||
existing = await self.ds.get_query(db.name, query_name)
|
|
||||||
return db, query_name, existing
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db, query_name, existing = await self._load(request)
|
|
||||||
if existing is None:
|
|
||||||
return Response.error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="delete-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
if existing.is_trusted:
|
|
||||||
return Response.error(
|
|
||||||
["Trusted queries cannot be deleted using the API"], 403
|
|
||||||
)
|
|
||||||
return await self.render(
|
|
||||||
["query_delete.html"],
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
"database": db.name,
|
|
||||||
"database_color": db.color,
|
|
||||||
"query": stored_query_to_dict(existing),
|
|
||||||
"query_url": self.ds.urls.table(db.name, query_name),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db, query_name, existing = await self._load(request)
|
|
||||||
if existing is None:
|
|
||||||
return Response.error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="delete-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return Response.error(["Permission denied: need delete-query"], 403)
|
|
||||||
if existing.is_trusted:
|
|
||||||
return Response.error(
|
|
||||||
["Trusted queries cannot be deleted using the API"], 403
|
|
||||||
)
|
|
||||||
|
|
||||||
data, is_json = await _json_or_form_payload(request)
|
|
||||||
await self.ds.remove_query(db.name, query_name)
|
|
||||||
if is_json:
|
|
||||||
return Response.json({"ok": True})
|
|
||||||
self.ds.add_message(
|
|
||||||
request,
|
|
||||||
"Query “{}” deleted".format(existing.title or query_name),
|
|
||||||
self.ds.INFO,
|
|
||||||
)
|
|
||||||
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,280 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .permissions import Resource
|
|
||||||
from .resources import DatabaseResource, TableResource
|
|
||||||
from .utils import named_parameters, sqlite3
|
|
||||||
from .utils.asgi import Forbidden
|
|
||||||
from .utils.sql_analysis import Operation, SQLAnalysis
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .app import Datasette
|
|
||||||
|
|
||||||
|
|
||||||
class QueryWriteRejected(Exception):
|
|
||||||
def __init__(self, message: str):
|
|
||||||
self.message = message
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PermissionRequirement:
|
|
||||||
action: str
|
|
||||||
resource: Resource
|
|
||||||
|
|
||||||
|
|
||||||
PermissionRequirements = tuple[PermissionRequirement, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class WriteSqlOperationDecision:
|
|
||||||
"""What Datasette should do with one operation in user-supplied write SQL."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IgnoreWriteSqlOperation(WriteSqlOperationDecision):
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RequireWriteSqlPermissions(WriteSqlOperationDecision):
|
|
||||||
permissions: PermissionRequirements
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RejectWriteSqlOperation(WriteSqlOperationDecision):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class UnsupportedWriteSqlOperation(WriteSqlOperationDecision):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
def row_mutation_requirements(database: str, table: str) -> PermissionRequirements:
|
|
||||||
resource = TableResource(database=database, table=table)
|
|
||||||
return tuple(
|
|
||||||
PermissionRequirement(action=action, resource=resource)
|
|
||||||
for action in ("insert-row", "update-row", "delete-row")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def decision_for_write_sql_operation(
|
|
||||||
operation: Operation,
|
|
||||||
) -> WriteSqlOperationDecision:
|
|
||||||
unsupported_message = (
|
|
||||||
f"Unsupported SQL operation: {operation.operation} {operation.target_type}"
|
|
||||||
)
|
|
||||||
if operation.internal:
|
|
||||||
return IgnoreWriteSqlOperation("internal SQLite operation")
|
|
||||||
if operation.operation == "select":
|
|
||||||
return IgnoreWriteSqlOperation("select statement")
|
|
||||||
if operation.operation == "vacuum":
|
|
||||||
return RejectWriteSqlOperation("VACUUM is not allowed in user-supplied SQL")
|
|
||||||
if operation.operation in {"insert", "update", "delete"}:
|
|
||||||
if operation.table_kind == "virtual":
|
|
||||||
return RejectWriteSqlOperation(
|
|
||||||
"Writes to virtual tables are not allowed in user-supplied SQL"
|
|
||||||
)
|
|
||||||
if operation.table_kind == "shadow":
|
|
||||||
return RejectWriteSqlOperation(
|
|
||||||
"Writes to shadow tables are not allowed in user-supplied SQL"
|
|
||||||
)
|
|
||||||
if operation.operation == "function":
|
|
||||||
return IgnoreWriteSqlOperation("SQL function")
|
|
||||||
if (
|
|
||||||
operation.operation == "read"
|
|
||||||
and operation.target_type == "table"
|
|
||||||
and operation.database is not None
|
|
||||||
and operation.table is not None
|
|
||||||
):
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="view-table",
|
|
||||||
resource=TableResource(
|
|
||||||
database=operation.database, table=operation.table
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
operation.operation in {"insert", "update"}
|
|
||||||
and operation.target_type == "table"
|
|
||||||
and operation.database is not None
|
|
||||||
and operation.table is not None
|
|
||||||
):
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
row_mutation_requirements(
|
|
||||||
database=operation.database,
|
|
||||||
table=operation.table,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
operation.operation == "delete"
|
|
||||||
and operation.target_type == "table"
|
|
||||||
and operation.database is not None
|
|
||||||
and operation.table is not None
|
|
||||||
):
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="delete-row",
|
|
||||||
resource=TableResource(
|
|
||||||
database=operation.database, table=operation.table
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if operation.operation == "create" and operation.target_type == "table":
|
|
||||||
if operation.database is None:
|
|
||||||
return UnsupportedWriteSqlOperation(unsupported_message)
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="create-table",
|
|
||||||
resource=DatabaseResource(database=operation.database),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if operation.operation == "create" and operation.target_type == "view":
|
|
||||||
if operation.database is None:
|
|
||||||
return UnsupportedWriteSqlOperation(unsupported_message)
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="create-view",
|
|
||||||
resource=DatabaseResource(database=operation.database),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
operation.operation == "drop"
|
|
||||||
and operation.target_type == "view"
|
|
||||||
and operation.database is not None
|
|
||||||
and operation.table is not None
|
|
||||||
):
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="drop-view",
|
|
||||||
resource=TableResource(
|
|
||||||
database=operation.database, table=operation.table
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
operation.operation == "alter"
|
|
||||||
and operation.target_type == "table"
|
|
||||||
and operation.database is not None
|
|
||||||
and operation.table is not None
|
|
||||||
):
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="alter-table",
|
|
||||||
resource=TableResource(
|
|
||||||
database=operation.database, table=operation.table
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
operation.operation == "drop"
|
|
||||||
and operation.target_type == "table"
|
|
||||||
and operation.database is not None
|
|
||||||
and operation.table is not None
|
|
||||||
):
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="drop-table",
|
|
||||||
resource=TableResource(
|
|
||||||
database=operation.database, table=operation.table
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
operation.operation in {"create", "drop"}
|
|
||||||
and operation.target_type == "index"
|
|
||||||
and operation.database is not None
|
|
||||||
and operation.table is not None
|
|
||||||
):
|
|
||||||
return RequireWriteSqlPermissions(
|
|
||||||
(
|
|
||||||
PermissionRequirement(
|
|
||||||
action="alter-table",
|
|
||||||
resource=TableResource(
|
|
||||||
database=operation.database, table=operation.table
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return UnsupportedWriteSqlOperation(unsupported_message)
|
|
||||||
|
|
||||||
|
|
||||||
def operation_is_write(operation: Operation) -> bool:
|
|
||||||
return operation.operation in {
|
|
||||||
"insert",
|
|
||||||
"update",
|
|
||||||
"delete",
|
|
||||||
"create",
|
|
||||||
"alter",
|
|
||||||
"drop",
|
|
||||||
"begin",
|
|
||||||
"commit",
|
|
||||||
"rollback",
|
|
||||||
"savepoint",
|
|
||||||
"attach",
|
|
||||||
"detach",
|
|
||||||
"pragma",
|
|
||||||
"analyze",
|
|
||||||
"reindex",
|
|
||||||
"vacuum",
|
|
||||||
"unknown",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_query_write_permissions(
|
|
||||||
datasette: Datasette,
|
|
||||||
database: str,
|
|
||||||
sql: str,
|
|
||||||
*,
|
|
||||||
actor: dict[str, object] | None = None,
|
|
||||||
params: dict[str, object] | None = None,
|
|
||||||
analysis: SQLAnalysis | None = None,
|
|
||||||
) -> SQLAnalysis:
|
|
||||||
db = datasette.get_database(database)
|
|
||||||
if analysis is None:
|
|
||||||
if params is None:
|
|
||||||
params = {name: "" for name in named_parameters(sql)}
|
|
||||||
try:
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
raise Forbidden(f"Could not analyze query: {ex}") from ex
|
|
||||||
|
|
||||||
for operation in analysis.operations:
|
|
||||||
decision = decision_for_write_sql_operation(operation)
|
|
||||||
if isinstance(decision, IgnoreWriteSqlOperation):
|
|
||||||
continue
|
|
||||||
if isinstance(decision, RejectWriteSqlOperation):
|
|
||||||
raise QueryWriteRejected(decision.message)
|
|
||||||
if isinstance(decision, UnsupportedWriteSqlOperation):
|
|
||||||
raise Forbidden(decision.message)
|
|
||||||
permissions = decision.permissions
|
|
||||||
if operation.database != database:
|
|
||||||
raise Forbidden("Writable queries may not access attached databases")
|
|
||||||
for permission in permissions:
|
|
||||||
if not await datasette.allowed(
|
|
||||||
action=permission.action,
|
|
||||||
resource=permission.resource,
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
raise Forbidden(
|
|
||||||
f"Permission denied: need {permission.action} "
|
|
||||||
f"on {permission.resource}"
|
|
||||||
)
|
|
||||||
return analysis
|
|
||||||
|
|
@ -20,4 +20,4 @@ help:
|
||||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
livehtml:
|
livehtml:
|
||||||
sphinx-autobuild -b html --watch ../datasette "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)
|
sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue