mirror of
https://github.com/simonw/datasette.git
synced 2026-07-23 07:54:43 +02:00
Compare commits
181 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
481df7ff6d | ||
|
|
2ffd8a860e | ||
|
|
8b7c942d5e | ||
|
|
591b909a4d |
||
|
|
9cfc252394 |
||
|
|
7f0a8b38ae |
||
|
|
10088dfa1d |
||
|
|
ccace40e5a |
||
|
|
db82123108 | ||
|
|
52ae7d1b6d | ||
|
|
a31673c90b | ||
|
|
54597f22fa | ||
|
|
bf3e277c98 |
||
|
|
211e70d4e1 |
||
|
|
617acedd38 | ||
|
|
7f37205e76 | ||
|
|
a926ab392e | ||
|
|
db7ba1d30c | ||
|
|
96e8b85523 | ||
|
|
6f27aa112a |
||
|
|
d2695a0c2f |
||
|
|
ebd013c6ef | ||
|
|
27a5be1326 |
||
|
|
b7bbde04be |
||
|
|
be25d6e3e4 |
||
|
|
4a853cb10c |
||
|
|
57ce1a059f |
||
|
|
b83b12dd7a |
||
|
|
b23fc4ec48 |
||
|
|
610c24d59a |
||
|
|
4874c29286 |
||
|
|
8b159144a5 |
||
|
|
53ccca5e15 |
||
|
|
0d962deb05 |
||
|
|
60bac9439d |
||
|
|
87cd695ca3 |
||
|
|
022eb6d3a0 |
||
|
|
8154f7149f |
||
|
|
3322e1f528 |
||
|
|
6e17c51361 |
||
|
|
e892c686c2 |
||
|
|
5c418efd7f |
||
|
|
194ee95ae2 |
||
|
|
f4dfd6e0f7 |
||
|
|
b6f5fd5cd0 | ||
|
|
9a0b78b76c | ||
|
|
c833217401 | ||
|
|
557e08c6ef | ||
|
|
6d253d10c8 | ||
|
|
3a0ea58557 |
||
|
|
404ee4c3a7 |
||
|
|
afa7b1ba0d |
||
|
|
5cb2bc6909 |
||
|
|
13dc7a08b7 |
||
|
|
e5e9aca871 |
||
|
|
0bf3a54716 |
||
|
|
e0ba8b3c6a |
||
|
|
9ee95cab3d |
||
|
|
b958d03c0f |
||
|
|
b09dceea88 |
||
|
|
6488b7a30e |
||
|
|
f3f5e891c9 |
||
|
|
ea9c1b1524 |
||
|
|
aaaffe45b8 |
||
|
|
b2cdc81d34 |
||
|
|
e8048e023f |
||
|
|
ae10a99811 |
||
|
|
f091b6dab1 |
||
|
|
23ccdaeffc |
||
|
|
19e54b10d4 |
||
|
|
b74a8e5b12 |
||
|
|
089e96a437 |
||
|
|
bc51c00724 |
||
|
|
0679e04bd3 |
||
|
|
58c07cc264 | ||
|
|
5bcf191e60 |
||
|
|
7ab8b644a7 | ||
|
|
ae26c3372a | ||
|
|
81d6ee69cd | ||
|
|
5f05d33ef7 | ||
|
|
19dde1c860 | ||
|
|
b476218edb | ||
|
|
8856914be8 | ||
|
|
3b24c88e93 | ||
|
|
c9975c1fe1 |
||
|
|
2bcabd8e1f | ||
|
|
8f32a8f134 | ||
|
|
b1d990ceba | ||
|
|
3d67168ee5 | ||
|
|
3aea678eab | ||
|
|
c864bc866d | ||
|
|
ed6235e59a | ||
|
|
141fe194c6 | ||
|
|
4a39e44eb6 | ||
|
|
9c033b7ce9 |
||
|
|
4bd9d41c43 | ||
|
|
2f84ab77f2 |
||
|
|
8985ecf438 |
||
|
|
b759ea5486 | ||
|
|
9ec42b2dad | ||
|
|
e0a138ffbd | ||
|
|
34ab85e664 |
||
|
|
cb622a3dd6 | ||
|
|
7ae601588e | ||
|
|
b7a896a803 | ||
|
|
d621bdfbfe | ||
|
|
fc2922a300 |
||
|
|
488c9cf3d3 | ||
|
|
753fa3b316 | ||
|
|
85be50ac71 | ||
|
|
463eea2bd0 |
||
|
|
a913ba372a | ||
|
|
a5931594de |
||
|
|
22ccd8a087 | ||
|
|
39f1df5997 | ||
|
|
5eca46a4bc |
||
|
|
a4f74d1d2b | ||
|
|
e3ff63b0f9 | ||
|
|
e0cdd38786 |
||
|
|
0c523dda20 | ||
|
|
8276879997 | ||
|
|
0d1c097396 | ||
|
|
34d9a3bf33 | ||
|
|
59ab0c0ca0 | ||
|
|
a43e76c31a | ||
|
|
cda8f7bbef | ||
|
|
17ec88503e | ||
|
|
2680e3c4bd | ||
|
|
4ac795e20c | ||
|
|
29971d9729 | ||
|
|
4d031c8562 | ||
|
|
49b1adba7b | ||
|
|
86ea1d4722 | ||
|
|
f831352551 | ||
|
|
f0645c6ddf |
||
|
|
b3b5c25df8 | ||
|
|
b932d0dc78 | ||
|
|
1717ab02f7 | ||
|
|
2ebae5ed71 | ||
|
|
fa43aba309 | ||
|
|
063b04ad83 | ||
|
|
dada4de172 | ||
|
|
4b219be8bd | ||
|
|
87354cf94e | ||
|
|
a2e75967ce | ||
|
|
084df1fba2 | ||
|
|
c4aead65ee | ||
|
|
17876ccf45 | ||
|
|
b02999bca6 | ||
|
|
a87c4ac555 | ||
|
|
354780a136 | ||
|
|
e834008075 | ||
|
|
c77dad910b | ||
|
|
1f863def5e | ||
|
|
21c156dfb1 | ||
|
|
a6ef65f90d | ||
|
|
2900efb32d | ||
|
|
9d9a2d3ff3 | ||
|
|
9766a9c087 | ||
|
|
1972ba8952 | ||
|
|
8cec528eeb | ||
|
|
4115213e17 | ||
|
|
c9c79fdfc8 | ||
|
|
15a3ac58cc | ||
|
|
fdd1b61a3e | ||
|
|
b40665dd14 | ||
|
|
2d3c85dfc0 | ||
|
|
57e7bba38f | ||
|
|
bccb7f17e5 | ||
|
|
387f4dd4bc | ||
|
|
ad6fe47a95 | ||
|
|
f673e7416f |
||
|
|
dfd5b95ec8 | ||
|
|
8e01542fe9 | ||
|
|
3cc0fc07b4 | ||
|
|
8b89a3aca8 | ||
|
|
63995ce823 | ||
|
|
3ea7ed8606 | ||
|
|
a55ae2adfc | ||
|
|
6a1b237b39 | ||
|
|
435ff7fa88 |
127 changed files with 20411 additions and 3144 deletions
39
.github/actions/setup-sqlite-version/action.yml
vendored
Normal file
39
.github/actions/setup-sqlite-version/action.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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 }}
|
||||
144
.github/actions/setup-sqlite-version/setup-sqlite-version.sh
vendored
Normal file
144
.github/actions/setup-sqlite-version/setup-sqlite-version.sh
vendored
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}"
|
||||
cflags="${SQLITE_CFLAGS:-}"
|
||||
skip_activate="${SQLITE_SKIP_ACTIVATE:-false}"
|
||||
extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}"
|
||||
|
||||
case "$version_spec" in
|
||||
3.46 | 3.46.0)
|
||||
sqlite_version="3.46.0"
|
||||
sqlite_year="2024"
|
||||
amalgamation_id="3460000"
|
||||
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip"
|
||||
;;
|
||||
3.25 | 3.25.0)
|
||||
sqlite_version="3.25.0"
|
||||
sqlite_year="2018"
|
||||
amalgamation_id="3250000"
|
||||
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3250000.zip?v=1"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
library_name="libsqlite3.so.0"
|
||||
library_path_var="LD_LIBRARY_PATH"
|
||||
;;
|
||||
Darwin)
|
||||
library_name="libsqlite3.dylib"
|
||||
library_path_var="DYLD_LIBRARY_PATH"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unsupported platform $(uname -s)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
runner_temp="${RUNNER_TEMP:-}"
|
||||
if [ -z "$runner_temp" ]; then
|
||||
runner_temp="$(mktemp -d)"
|
||||
fi
|
||||
|
||||
filename="sqlite-amalgamation-${amalgamation_id}"
|
||||
official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip"
|
||||
download_dir="${runner_temp}/sqlite-versions/downloads"
|
||||
source_root="${runner_temp}/sqlite-versions/source"
|
||||
source_dir="${source_root}/${filename}"
|
||||
build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}"
|
||||
archive_path="${download_dir}/${filename}.zip"
|
||||
|
||||
mkdir -p "$download_dir" "$source_root" "$build_dir"
|
||||
|
||||
download_archive() {
|
||||
local url
|
||||
local candidate_path="${archive_path}.tmp"
|
||||
local urls=("$official_url")
|
||||
|
||||
for url in $builtin_fallback_urls $extra_fallback_urls; do
|
||||
urls+=("$url")
|
||||
done
|
||||
|
||||
rm -f "$candidate_path"
|
||||
for url in "${urls[@]}"; do
|
||||
echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}"
|
||||
if curl \
|
||||
--fail \
|
||||
--location \
|
||||
--show-error \
|
||||
--retry 5 \
|
||||
--retry-delay 2 \
|
||||
--retry-max-time 180 \
|
||||
--retry-all-errors \
|
||||
--connect-timeout 20 \
|
||||
--max-time 240 \
|
||||
--output "$candidate_path" \
|
||||
"$url"; then
|
||||
mv "$candidate_path" "$archive_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "::warning::Download failed from ${url}"
|
||||
rm -f "$candidate_path"
|
||||
done
|
||||
|
||||
echo "::error::Could not download SQLite ${sqlite_version} amalgamation"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ ! -f "${source_dir}/sqlite3.c" ]; then
|
||||
if [ ! -f "$archive_path" ]; then
|
||||
download_archive
|
||||
fi
|
||||
|
||||
rm -rf "$source_dir"
|
||||
unzip -q "$archive_path" -d "$source_root"
|
||||
fi
|
||||
|
||||
if [ ! -f "${source_dir}/sqlite3.c" ]; then
|
||||
echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -a cflag_args <<< "$cflags"
|
||||
|
||||
echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}"
|
||||
gcc \
|
||||
-fPIC \
|
||||
-shared \
|
||||
"${cflag_args[@]}" \
|
||||
"${source_dir}/sqlite3.c" \
|
||||
"-I${source_dir}" \
|
||||
-o "${build_dir}/${library_name}"
|
||||
|
||||
if [ "$library_name" = "libsqlite3.so.0" ]; then
|
||||
ln -sf "$library_name" "${build_dir}/libsqlite3.so"
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "sqlite-location=${build_dir}"
|
||||
fi
|
||||
|
||||
case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in
|
||||
true | 1 | yes)
|
||||
echo "Skipping ${library_path_var} activation"
|
||||
;;
|
||||
*)
|
||||
existing_value="${!library_path_var:-}"
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
if [ -n "$existing_value" ]; then
|
||||
echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
fi
|
||||
echo "Added ${build_dir} to ${library_path_var}"
|
||||
;;
|
||||
esac
|
||||
2
.github/workflows/deploy-latest.yml
vendored
2
.github/workflows/deploy-latest.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
16
.github/workflows/documentation-links.yml
vendored
16
.github/workflows/documentation-links.yml
vendored
|
|
@ -1,16 +0,0 @@
|
|||
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"
|
||||
6
.github/workflows/playwright.yml
vendored
6
.github/workflows/playwright.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
matrix:
|
||||
browser: [chromium, firefox, webkit]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python 3.14
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -25,14 +25,14 @@ jobs:
|
|||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Cache uv
|
||||
uses: actions/cache@v5
|
||||
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@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
|
||||
|
|
|
|||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Configure npm caching
|
||||
with:
|
||||
path: ~/.npm
|
||||
|
|
|
|||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
deploy_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/spellcheck.yml
vendored
2
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/stable-docs.yml
vendored
2
.github/workflows/stable-docs.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||
- name: Set up Git user
|
||||
|
|
|
|||
2
.github/workflows/test-coverage.yml
vendored
2
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/test-pyodide.yml
vendored
4
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-browsers
|
||||
|
|
|
|||
4
.github/workflows/test-sqlite-support.yml
vendored
4
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
#"3.23.1" # 2018-04-10, before UPSERT
|
||||
]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
||||
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
|
||||
uses: ./.github/actions/setup-sqlite-version
|
||||
with:
|
||||
version: ${{ matrix.sqlite-version }}
|
||||
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
||||
|
|
|
|||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
|
@ -9,10 +9,11 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
|||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
|
|
|
|||
2
.github/workflows/tmate.yml
vendored
2
.github/workflows/tmate.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,6 +5,8 @@ datasets.json
|
|||
|
||||
scratchpad
|
||||
|
||||
ignored/
|
||||
|
||||
.vscode
|
||||
|
||||
uv.lock
|
||||
|
|
|
|||
3
Justfile
3
Justfile
|
|
@ -33,10 +33,11 @@ export DATASETTE_SECRET := "not_a_secret"
|
|||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Run linters: black, ruff, cog
|
||||
# Run linters: black, ruff, prettier, cog
|
||||
@lint: codespell
|
||||
uv run black datasette tests --check
|
||||
uv run ruff check datasette tests
|
||||
npm run prettier -- --check
|
||||
uv run cog --check README.md docs/*.rst
|
||||
|
||||
# Apply ruff fixes
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
from datasette.permissions import Permission # noqa
|
||||
from datasette.version import __version_info__, __version__ # noqa
|
||||
from datasette.events import Event # noqa
|
||||
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
||||
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import ( # noqa
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PayloadTooLarge,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from datasette.utils import actor_matches_allow # noqa
|
||||
from datasette.views import Context # noqa
|
||||
from .hookspecs import hookimpl # noqa
|
||||
|
|
|
|||
267
datasette/app.py
267
datasette/app.py
|
|
@ -12,7 +12,6 @@ import dataclasses
|
|||
import datetime
|
||||
import functools
|
||||
import glob
|
||||
import hashlib
|
||||
import httpx
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
|
|
@ -35,6 +34,7 @@ from jinja2 import (
|
|||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
pass_context,
|
||||
PrefixLoader,
|
||||
)
|
||||
from jinja2.environment import Template
|
||||
|
|
@ -47,9 +47,14 @@ from .views import Context
|
|||
from .views.database import (
|
||||
database_download,
|
||||
DatabaseView,
|
||||
TableCreateView,
|
||||
QueryView,
|
||||
)
|
||||
from .views.table_create_alter import (
|
||||
DatabaseForeignKeyTargetsView,
|
||||
TableAlterView,
|
||||
TableCreateView,
|
||||
TableForeignKeySuggestionsView,
|
||||
)
|
||||
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
||||
from .views.stored_queries import (
|
||||
QueryCreateAnalyzeView,
|
||||
|
|
@ -106,6 +111,7 @@ from .utils import (
|
|||
baseconv,
|
||||
call_with_supported_arguments,
|
||||
detect_json1,
|
||||
add_cors_headers,
|
||||
display_actor,
|
||||
escape_css_string,
|
||||
escape_sqlite,
|
||||
|
|
@ -117,6 +123,7 @@ from .utils import (
|
|||
parse_metadata,
|
||||
resolve_env_secrets,
|
||||
resolve_routes,
|
||||
sha256_file,
|
||||
tilde_decode,
|
||||
tilde_encode,
|
||||
to_css_class,
|
||||
|
|
@ -124,8 +131,10 @@ from .utils import (
|
|||
redact_keys,
|
||||
row_sql_params_pks,
|
||||
)
|
||||
from .tokens import TokenInvalid
|
||||
from .utils.asgi import (
|
||||
AsgiLifespan,
|
||||
BadRequest,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
DatabaseNotFound,
|
||||
|
|
@ -200,6 +209,11 @@ SETTINGS = (
|
|||
100,
|
||||
"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(
|
||||
"num_sql_threads",
|
||||
3,
|
||||
|
|
@ -308,7 +322,7 @@ async def favicon(request, send):
|
|||
send,
|
||||
str(FAVICON_PATH),
|
||||
content_type="image/png",
|
||||
headers={"Cache-Control": "max-age=3600, immutable, public"},
|
||||
headers={"Cache-Control": "max-age=3600, public"},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -325,6 +339,57 @@ def _to_string(value):
|
|||
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:
|
||||
# Message constants:
|
||||
INFO = 1
|
||||
|
|
@ -417,6 +482,7 @@ class Datasette:
|
|||
self._internal_database.name = INTERNAL_DB_NAME
|
||||
|
||||
self.cache_headers = cache_headers
|
||||
self._static_asset_hashes = {}
|
||||
self.cors = cors
|
||||
config_files = []
|
||||
metadata_files = []
|
||||
|
|
@ -557,6 +623,8 @@ class Datasette:
|
|||
)
|
||||
environment.filters["escape_css_string"] = escape_css_string
|
||||
environment.filters["quote_plus"] = urllib.parse.quote_plus
|
||||
environment.globals["csrftoken"] = _legacy_template_csrftoken
|
||||
environment.globals["static"] = self.static
|
||||
self._jinja_env = environment
|
||||
environment.filters["escape_sqlite"] = escape_sqlite
|
||||
environment.filters["to_css_class"] = to_css_class
|
||||
|
|
@ -625,9 +693,12 @@ class Datasette:
|
|||
return action
|
||||
return None
|
||||
|
||||
async def refresh_schemas(self):
|
||||
async def refresh_schemas(self, *, force=False):
|
||||
# Throttle schema refreshes to at most once per second
|
||||
if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0:
|
||||
if (
|
||||
not force
|
||||
and time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0
|
||||
):
|
||||
return
|
||||
self._last_schema_refresh = time.monotonic()
|
||||
if self._refresh_schemas_lock.locked():
|
||||
|
|
@ -682,19 +753,7 @@ class Datasette:
|
|||
# Compare schema versions to see if we should skip it
|
||||
if schema_version == current_schema_versions.get(database_name):
|
||||
continue
|
||||
placeholders = "(?, ?, ?, ?)"
|
||||
values = [database_name, str(db.path), db.is_memory, schema_version]
|
||||
if db.path is None:
|
||||
placeholders = "(?, null, ?, ?)"
|
||||
values = [database_name, db.is_memory, schema_version]
|
||||
await internal_db.execute_write(
|
||||
"""
|
||||
INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)
|
||||
VALUES {}
|
||||
""".format(placeholders),
|
||||
values,
|
||||
)
|
||||
await populate_schema_tables(internal_db, db)
|
||||
await populate_schema_tables(internal_db, db, schema_version)
|
||||
|
||||
@property
|
||||
def urls(self):
|
||||
|
|
@ -842,7 +901,9 @@ class Datasette:
|
|||
Verify an API token by trying all registered token handlers.
|
||||
|
||||
Returns an actor dict from the first handler that recognizes the
|
||||
token, or None if no handler accepts it.
|
||||
token, or None if no handler accepts it. A handler may raise
|
||||
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():
|
||||
result = await token_handler.verify_token(self, token)
|
||||
|
|
@ -1427,24 +1488,55 @@ class Datasette:
|
|||
|
||||
return db_plugin_config
|
||||
|
||||
def static_hash(self, filename):
|
||||
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_asset_path(self, path):
|
||||
return _resolve_static_asset_path(app_root / "datasette" / "static", path)
|
||||
|
||||
def app_css_hash(self):
|
||||
return self.static_hash("app.css")
|
||||
def _static_plugin_asset_path(self, plugin_name, path):
|
||||
for plugin in get_plugins():
|
||||
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):
|
||||
mount_name = mount_name.strip("/")
|
||||
for mount, dirname in self.static_mounts:
|
||||
if mount.strip("/") == mount_name:
|
||||
return (
|
||||
_resolve_static_asset_path(dirname, path),
|
||||
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))),
|
||||
)
|
||||
raise FileNotFoundError("No static mount found for {}".format(mount_name))
|
||||
|
||||
def _static_asset_hash(self, filepath):
|
||||
filepath = Path(filepath)
|
||||
if self.cache_headers:
|
||||
cached = self._static_asset_hashes.get(filepath)
|
||||
if cached:
|
||||
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):
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
|
@ -2074,6 +2166,18 @@ class Datasette:
|
|||
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):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
self._prepare_connection(conn, "_memory")
|
||||
|
|
@ -2257,7 +2361,11 @@ class Datasette:
|
|||
templates = [templates]
|
||||
template = self.get_jinja_environment(request).select_template(templates)
|
||||
if dataclasses.is_dataclass(context):
|
||||
context = dataclasses.asdict(context)
|
||||
# Shallow conversion - asdict() would deep-copy values, which
|
||||
# is wasteful and fails on values like sqlite3.Row
|
||||
context = {
|
||||
f.name: getattr(context, f.name) for f in dataclasses.fields(context)
|
||||
}
|
||||
body_scripts = []
|
||||
# pylint: disable=no-member
|
||||
for extra_script in pm.hook.extra_body_script(
|
||||
|
|
@ -2307,6 +2415,8 @@ class Datasette:
|
|||
links.extend(extra_links)
|
||||
return links
|
||||
|
||||
# Keys added here must be documented in TEMPLATE_BASE_CONTEXT -
|
||||
# the contract tests fail otherwise
|
||||
template_context = {
|
||||
**context,
|
||||
**{
|
||||
|
|
@ -2319,9 +2429,6 @@ class Datasette:
|
|||
"show_logout": request is not None
|
||||
and "ds_actor" in request.cookies
|
||||
and request.actor,
|
||||
"app_css_hash": self.app_css_hash(),
|
||||
"edit_tools_js_hash": self.static_hash("edit-tools.js"),
|
||||
"table_js_hash": self.static_hash("table.js"),
|
||||
"zip": zip,
|
||||
"body_scripts": body_scripts,
|
||||
"format_bytes": format_bytes,
|
||||
|
|
@ -2333,18 +2440,19 @@ class Datasette:
|
|||
"extra_js_urls", template, context, request, view_name
|
||||
),
|
||||
"base_url": self.setting("base_url"),
|
||||
"csrftoken": (
|
||||
request.scope["csrftoken"]
|
||||
if request and "csrftoken" in request.scope
|
||||
else lambda: ""
|
||||
),
|
||||
"datasette_version": __version__,
|
||||
},
|
||||
**extra_template_vars,
|
||||
}
|
||||
if request and request.args.get("_context") and self.setting("template_debug"):
|
||||
return "<pre>{}</pre>".format(
|
||||
escape(json.dumps(template_context, default=repr, indent=4))
|
||||
escape(
|
||||
json.dumps(
|
||||
template_context,
|
||||
default=_template_context_json_default,
|
||||
indent=4,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return await template.render_async(template_context)
|
||||
|
|
@ -2416,10 +2524,9 @@ class Datasette:
|
|||
def add_route(view, regex):
|
||||
routes.append((regex, view))
|
||||
|
||||
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
|
||||
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>json))?$")
|
||||
add_route(permanent_redirect("/-/"), r"/-$")
|
||||
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
|
||||
add_route(favicon, "/favicon.ico")
|
||||
|
||||
add_route(
|
||||
|
|
@ -2454,7 +2561,10 @@ class Datasette:
|
|||
)
|
||||
add_route(
|
||||
JsonDataView.as_view(
|
||||
self, "plugins.json", self._plugins, needs_request=True
|
||||
self,
|
||||
"plugins.json",
|
||||
self._plugins,
|
||||
needs_request=True,
|
||||
),
|
||||
r"/-/plugins(\.(?P<format>json))?$",
|
||||
)
|
||||
|
|
@ -2467,11 +2577,18 @@ class Datasette:
|
|||
r"/-/config(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
JsonDataView.as_view(self, "threads.json", self._threads),
|
||||
JsonDataView.as_view(
|
||||
self, "threads.json", self._threads, permission="permissions-debug"
|
||||
),
|
||||
r"/-/threads(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
JsonDataView.as_view(self, "databases.json", self._connected_databases),
|
||||
JsonDataView.as_view(
|
||||
self,
|
||||
"databases.json",
|
||||
self._databases_data,
|
||||
needs_request=True,
|
||||
),
|
||||
r"/-/databases(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
|
|
@ -2484,7 +2601,7 @@ class Datasette:
|
|||
JsonDataView.as_view(
|
||||
self,
|
||||
"actions.json",
|
||||
self._actions,
|
||||
lambda: {"actions": self._actions()},
|
||||
template="debug_actions.html",
|
||||
permission="permissions-debug",
|
||||
),
|
||||
|
|
@ -2559,6 +2676,10 @@ class Datasette:
|
|||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
)
|
||||
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))?$",
|
||||
|
|
@ -2623,6 +2744,14 @@ class Datasette:
|
|||
TableUpsertView.as_view(self),
|
||||
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(
|
||||
TableSetColumnTypeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
||||
|
|
@ -2680,6 +2809,10 @@ class Datasette:
|
|||
db, table_name, _ = await self.resolve_table(request)
|
||||
pk_values = urlsafe_components(request.url_vars["pks"])
|
||||
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)
|
||||
row = results.first()
|
||||
if row is None:
|
||||
|
|
@ -2738,7 +2871,11 @@ class DatasetteRouter:
|
|||
if base_url != "/" and path.startswith(base_url):
|
||||
path = "/" + path[len(base_url) :]
|
||||
scope = dict(scope, route_path=path)
|
||||
request = Request(scope, receive)
|
||||
request = Request(
|
||||
scope,
|
||||
receive,
|
||||
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
|
||||
)
|
||||
# Populate request_messages if ds_messages cookie is present
|
||||
try:
|
||||
request._messages = self.ds.unsign(
|
||||
|
|
@ -2758,13 +2895,24 @@ class DatasetteRouter:
|
|||
# Handle authentication
|
||||
default_actor = scope.get("actor") or None
|
||||
actor = None
|
||||
token_error = None
|
||||
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
|
||||
for result in results:
|
||||
result = await await_me_maybe(result)
|
||||
try:
|
||||
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:
|
||||
actor = result
|
||||
# Don't break — we must await all coroutines to avoid
|
||||
# "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 = dict(scope, **scope_modifications)
|
||||
|
||||
|
|
@ -2796,6 +2944,15 @@ class DatasetteRouter:
|
|||
except Exception as 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):
|
||||
# If path contains % encoding, redirect to tilde encoding
|
||||
if "%" in request.path:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from .utils import (
|
|||
detect_fts,
|
||||
detect_primary_keys,
|
||||
detect_spatialite,
|
||||
escape_sqlite,
|
||||
get_all_foreign_keys,
|
||||
get_outbound_foreign_keys,
|
||||
md5_not_usedforsecurity,
|
||||
|
|
@ -246,6 +247,7 @@ class Database:
|
|||
request=None,
|
||||
return_all=False,
|
||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||
transaction=True,
|
||||
):
|
||||
self._check_not_closed()
|
||||
if returning_limit < 0:
|
||||
|
|
@ -258,7 +260,9 @@ class Database:
|
|||
)
|
||||
|
||||
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, transaction=transaction
|
||||
)
|
||||
return results
|
||||
|
||||
async def execute_write_script(self, sql, block=True, request=None):
|
||||
|
|
@ -348,6 +352,7 @@ class Database:
|
|||
self.ds._prepare_connection(self._write_connection, self.name)
|
||||
if transaction:
|
||||
with self._write_connection:
|
||||
self._write_connection.execute("BEGIN IMMEDIATE")
|
||||
result = fn(self._write_connection)
|
||||
else:
|
||||
result = fn(self._write_connection)
|
||||
|
|
@ -477,6 +482,7 @@ class Database:
|
|||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
|
|
@ -603,7 +609,7 @@ class Database:
|
|||
try:
|
||||
table_count = (
|
||||
await self.execute(
|
||||
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
|
||||
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
|
||||
custom_time_limit=limit,
|
||||
)
|
||||
).rows[0][0]
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ def register_actions():
|
|||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="create-view",
|
||||
abbr="cv",
|
||||
description="Create views",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="store-query",
|
||||
abbr="sq",
|
||||
|
|
@ -111,6 +117,12 @@ def register_actions():
|
|||
description="Drop tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-view",
|
||||
abbr="dv",
|
||||
description="Drop views",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Query-level actions (child-level)
|
||||
Action(
|
||||
name="view-query",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ class ConfigPermissionProcessor:
|
|||
"""Evaluate an allow block against the current actor."""
|
||||
if allow_block is None:
|
||||
return None
|
||||
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
|
||||
# integers, but should retain the CLI's boolean 1/0 behavior.
|
||||
if isinstance(allow_block, int) and allow_block in (0, 1):
|
||||
return bool(allow_block)
|
||||
return actor_matches_allow(self.actor, allow_block)
|
||||
|
||||
def is_in_restriction_allowlist(
|
||||
|
|
|
|||
29
datasette/default_table_actions.py
Normal file
29
datasette/default_table_actions.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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
|
||||
|
|
@ -5,6 +5,8 @@ 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")
|
||||
|
|
@ -81,6 +83,16 @@ class ExtraRegistry:
|
|||
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:
|
||||
|
|
@ -103,6 +115,17 @@ class ExtraRegistry:
|
|||
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]
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class Facet:
|
|||
self.database = database
|
||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||
self.table = table
|
||||
self.sql = sql or f"select * from [{table}]"
|
||||
self.sql = sql or f"select * from {escape_sqlite(table)}"
|
||||
self.params = params or []
|
||||
self.table_config = table_config
|
||||
# row_count can be None, in which case we calculate it ourselves:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
from datasette import hookimpl, Response
|
||||
from .utils import add_cors_headers
|
||||
|
||||
|
||||
@hookimpl(trylast=True)
|
||||
def forbidden(datasette, request, message):
|
||||
async def inner():
|
||||
if (
|
||||
request.path.split("?")[0].endswith(".json")
|
||||
or "application/json" in (request.headers.get("accept") or "")
|
||||
or request.headers.get("content-type") == "application/json"
|
||||
):
|
||||
headers = {}
|
||||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.error(message, 403, headers=headers)
|
||||
return Response.html(
|
||||
await datasette.render_template(
|
||||
"error.html",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from datasette import hookimpl, Response
|
||||
from .utils import add_cors_headers
|
||||
from .utils import add_cors_headers, error_body
|
||||
from .utils.asgi import (
|
||||
Base400,
|
||||
)
|
||||
|
|
@ -28,6 +28,7 @@ def handle_exception(datasette, request, exception):
|
|||
rich.get_console().print_exception(show_locals=True)
|
||||
|
||||
title = None
|
||||
plain_message = None
|
||||
if isinstance(exception, Base400):
|
||||
status = exception.status
|
||||
info = {}
|
||||
|
|
@ -36,6 +37,7 @@ def handle_exception(datasette, request, exception):
|
|||
status = exception.status
|
||||
info = exception.error_dict
|
||||
message = exception.message
|
||||
plain_message = exception.plain_message
|
||||
if exception.message_is_html:
|
||||
message = Markup(message)
|
||||
title = exception.title
|
||||
|
|
@ -45,6 +47,13 @@ def handle_exception(datasette, request, exception):
|
|||
message = str(exception)
|
||||
traceback.print_exc()
|
||||
templates = [f"{status}.html", "error.html"]
|
||||
headers = {}
|
||||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
if request.path.split("?")[0].endswith(".json"):
|
||||
body = dict(info)
|
||||
body.update(error_body(plain_message or message, status))
|
||||
return Response.json(body, status=status, headers=headers)
|
||||
info.update(
|
||||
{
|
||||
"ok": False,
|
||||
|
|
@ -53,25 +62,18 @@ def handle_exception(datasette, request, exception):
|
|||
"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)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
info,
|
||||
urls=datasette.urls,
|
||||
app_css_hash=datasette.app_css_hash(),
|
||||
menu_links=lambda: [],
|
||||
)
|
||||
),
|
||||
status=status,
|
||||
headers=headers,
|
||||
)
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
info,
|
||||
urls=datasette.urls,
|
||||
menu_links=lambda: [],
|
||||
)
|
||||
),
|
||||
status=status,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.default_debug_menu",
|
||||
"datasette.default_jump_items",
|
||||
"datasette.default_database_actions",
|
||||
"datasette.default_table_actions",
|
||||
"datasette.default_query_actions",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.utils import (
|
||||
error_body,
|
||||
value_as_boolean,
|
||||
remove_infinites,
|
||||
CustomJSONEncoder,
|
||||
|
|
@ -52,8 +53,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
if error:
|
||||
shape = "objects"
|
||||
status_code = 400
|
||||
data["error"] = error
|
||||
data["ok"] = False
|
||||
data.update(error_body(error, status_code))
|
||||
|
||||
if truncated is not None:
|
||||
data["truncated"] = truncated
|
||||
|
|
@ -87,7 +87,8 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
object_rows[pk_string] = row
|
||||
data = object_rows
|
||||
if shape_error:
|
||||
data = {"ok": False, "error": shape_error}
|
||||
status_code = 400
|
||||
data = error_body(shape_error, status_code)
|
||||
elif shape == "array":
|
||||
data = data["rows"]
|
||||
|
||||
|
|
@ -100,12 +101,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
data["rows"] = [list(row.values()) for row in data["rows"]]
|
||||
else:
|
||||
status_code = 400
|
||||
data = {
|
||||
"ok": False,
|
||||
"error": f"Invalid _shape: {shape}",
|
||||
"status": 400,
|
||||
"title": None,
|
||||
}
|
||||
data = error_body(f"Invalid _shape: {shape}", status_code)
|
||||
|
||||
# Don't include "columns" in output
|
||||
# https://github.com/simonw/datasette/issues/2136
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -633,32 +633,151 @@ const initDatasetteTable = function (manager) {
|
|||
});
|
||||
};
|
||||
|
||||
/* Add x buttons to the filter rows */
|
||||
function addButtonsToFilterRows(manager) {
|
||||
var x = "✖";
|
||||
var rows = Array.from(
|
||||
document.querySelectorAll(manager.selectors.filterRow),
|
||||
function filterRowSelector(manager) {
|
||||
return manager.selectors.filterRows || manager.selectors.filterRow;
|
||||
}
|
||||
|
||||
function filterRowsWithControls(manager) {
|
||||
return Array.from(
|
||||
document.querySelectorAll(filterRowSelector(manager)),
|
||||
).filter((el) => el.querySelector(".filter-op"));
|
||||
rows.forEach((row) => {
|
||||
var a = document.createElement("a");
|
||||
a.setAttribute("href", "#");
|
||||
a.setAttribute("aria-label", "Remove this filter");
|
||||
a.style.textDecoration = "none";
|
||||
a.innerText = x;
|
||||
a.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
let row = ev.target.closest("div");
|
||||
row.querySelector("select").value = "";
|
||||
row.querySelector(".filter-op select").value = "exact";
|
||||
row.querySelector("input.filter-value").value = "";
|
||||
ev.target.closest("a").style.display = "none";
|
||||
});
|
||||
row.appendChild(a);
|
||||
}
|
||||
|
||||
function filterRowNumberFromName(name) {
|
||||
var match = name && name.match(/^_filter_column_(\d+)$/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
function nextFilterRowNumber(manager) {
|
||||
return filterRowsWithControls(manager).reduce((max, row) => {
|
||||
var column = row.querySelector("select");
|
||||
if (!column.value) {
|
||||
a.style.display = "none";
|
||||
return Math.max(max, filterRowNumberFromName(column && column.name));
|
||||
}, 0) + 1;
|
||||
}
|
||||
|
||||
function setFilterRowNumber(row, number) {
|
||||
row.querySelector("select").name = `_filter_column_${number}`;
|
||||
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
|
||||
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
|
||||
}
|
||||
|
||||
function resetFilterRow(row) {
|
||||
row.querySelector("select").value = "";
|
||||
row.querySelector(".filter-op select").value = "exact";
|
||||
row.querySelector("input.filter-value").value = "";
|
||||
}
|
||||
|
||||
function updateFilterRowButtons(manager) {
|
||||
var rows = filterRowsWithControls(manager);
|
||||
rows.forEach((row, index) => {
|
||||
var removeButton = row.querySelector(".filter-row-remove");
|
||||
var addButton = row.querySelector(".filter-row-add");
|
||||
var column = row.querySelector("select");
|
||||
if (removeButton) {
|
||||
removeButton.hidden = index === 0;
|
||||
}
|
||||
if (addButton) {
|
||||
addButton.hidden = index !== rows.length - 1 || !column.value;
|
||||
}
|
||||
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
|
||||
return button && !button.hidden;
|
||||
}).length;
|
||||
row.classList.toggle(
|
||||
"filter-controls-row-has-buttons",
|
||||
visibleButtonCount > 0,
|
||||
);
|
||||
row.classList.toggle(
|
||||
"filter-controls-row-one-button",
|
||||
visibleButtonCount === 1,
|
||||
);
|
||||
row.classList.toggle(
|
||||
"filter-controls-row-two-buttons",
|
||||
visibleButtonCount === 2,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function cloneFilterRow(row) {
|
||||
var clone = row.cloneNode(true);
|
||||
clone.querySelector("select").name = "_filter_column";
|
||||
clone.querySelector(".filter-op select").name = "_filter_op";
|
||||
clone.querySelector("input.filter-value").name = "_filter_value";
|
||||
resetFilterRow(clone);
|
||||
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
|
||||
return clone;
|
||||
}
|
||||
|
||||
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
|
||||
<path d="M10 11v6"></path>
|
||||
<path d="M14 11v6"></path>
|
||||
</svg>`;
|
||||
|
||||
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg>`;
|
||||
|
||||
function addFilterRowButtons(row, manager) {
|
||||
var removeButton = document.createElement("button");
|
||||
removeButton.type = "button";
|
||||
removeButton.className = "filter-row-icon filter-row-remove";
|
||||
removeButton.setAttribute("aria-label", "Remove this filter");
|
||||
removeButton.title = "Remove this filter";
|
||||
removeButton.tabIndex = 0;
|
||||
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
|
||||
removeButton.addEventListener("click", (ev) => {
|
||||
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
||||
var rows = filterRowsWithControls(manager);
|
||||
var rowIndex = rows.indexOf(row);
|
||||
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
|
||||
row.remove();
|
||||
updateFilterRowButtons(manager);
|
||||
if (focusRow) {
|
||||
var focusTarget =
|
||||
focusRow.querySelector(".filter-row-add:not([hidden])") ||
|
||||
focusRow.querySelector("select");
|
||||
if (focusTarget) {
|
||||
focusTarget.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
row.appendChild(removeButton);
|
||||
|
||||
var addButton = document.createElement("button");
|
||||
addButton.type = "button";
|
||||
addButton.className = "filter-row-icon filter-row-add";
|
||||
addButton.setAttribute("aria-label", "Add another filter");
|
||||
addButton.title = "Add another filter";
|
||||
addButton.tabIndex = 0;
|
||||
addButton.innerHTML = FILTER_ADD_ICON_SVG;
|
||||
addButton.addEventListener("click", (ev) => {
|
||||
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
||||
if (row.querySelector("select").name === "_filter_column") {
|
||||
setFilterRowNumber(row, nextFilterRowNumber(manager));
|
||||
}
|
||||
var clone = cloneFilterRow(row);
|
||||
addFilterRowButtons(clone, manager);
|
||||
row.parentNode.insertBefore(clone, row.nextSibling);
|
||||
updateFilterRowButtons(manager);
|
||||
clone.querySelector("select").focus();
|
||||
});
|
||||
row.appendChild(addButton);
|
||||
|
||||
row.querySelector("select").addEventListener("change", () => {
|
||||
updateFilterRowButtons(manager);
|
||||
});
|
||||
}
|
||||
|
||||
/* Add buttons to the filter rows */
|
||||
function addButtonsToFilterRows(manager) {
|
||||
var rows = filterRowsWithControls(manager);
|
||||
rows.forEach((row) => {
|
||||
addFilterRowButtons(row, manager);
|
||||
});
|
||||
updateFilterRowButtons(manager);
|
||||
}
|
||||
|
||||
/* Set up datalist autocomplete for filter values */
|
||||
|
|
@ -687,11 +806,11 @@ function initAutocompleteForFilterValues(manager) {
|
|||
});
|
||||
}
|
||||
createDataLists();
|
||||
// When any select with name=_filter_column changes, update the datalist
|
||||
// When any filter column select changes, update the datalist
|
||||
document.body.addEventListener("change", function (event) {
|
||||
if (event.target.name === "_filter_column") {
|
||||
if (event.target.name && event.target.name.startsWith("_filter_column")) {
|
||||
event.target
|
||||
.closest(manager.selectors.filterRow)
|
||||
.closest(filterRowSelector(manager))
|
||||
.querySelector(".filter-value")
|
||||
.setAttribute("list", "datalist-" + event.target.value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
|
|||
"description_html": query.description_html,
|
||||
"hide_sql": query.hide_sql,
|
||||
"fragment": query.fragment,
|
||||
"params": list(query.parameters),
|
||||
"parameters": list(query.parameters),
|
||||
"is_write": query.is_write,
|
||||
"is_private": query.is_private,
|
||||
|
|
@ -84,7 +83,6 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
|
|||
return {
|
||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
||||
"next": page.next,
|
||||
"has_more": page.has_more,
|
||||
"limit": page.limit,
|
||||
}
|
||||
|
||||
|
|
|
|||
40
datasette/template_contexts.py
Normal file
40
datasette/template_contexts.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""
|
||||
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()
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
|
||||
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
|
||||
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
|
||||
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
|
||||
<style>
|
||||
.cm-editor {
|
||||
resize: both;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,20 @@
|
|||
padding: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.permission-form form {
|
||||
max-width: 60rem;
|
||||
}
|
||||
.permission-form-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.permission-form-result {
|
||||
margin-top: 1rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 1em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
|
|
@ -15,22 +27,51 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
.form-section input[type="text"],
|
||||
.form-section select {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 0.5em;
|
||||
.form-section input[type="number"],
|
||||
.form-section select,
|
||||
.permission-textarea {
|
||||
background-color: #fff;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
color: #222;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
.form-section input[type="text"] {
|
||||
height: 3rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.form-section input[type="number"] {
|
||||
height: 3rem;
|
||||
max-width: 7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.form-section select {
|
||||
height: 3rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.permission-textarea {
|
||||
font-family: monospace;
|
||||
min-height: 12rem;
|
||||
padding: 0.75rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
.form-section input[type="number"]:focus,
|
||||
.form-section select:focus,
|
||||
.permission-textarea:focus {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.3em;
|
||||
margin-top: 0.45em;
|
||||
color: #666;
|
||||
}
|
||||
.form-actions {
|
||||
|
|
@ -142,4 +183,9 @@
|
|||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.permission-form-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -44,10 +44,10 @@
|
|||
</style>
|
||||
|
||||
<nav class="permissions-debug-tabs">
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
|
||||
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
||||
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -3,29 +3,11 @@
|
|||
{% block title %}Debug allow rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
<style>
|
||||
textarea {
|
||||
height: 10em;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border: 2px dotted black;
|
||||
}
|
||||
.two-col {
|
||||
display: inline-block;
|
||||
width: 48%;
|
||||
}
|
||||
.two-col label {
|
||||
width: 48%;
|
||||
}
|
||||
p.message-warning {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -38,24 +20,28 @@ p.message-warning {
|
|||
|
||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||
<div class="two-col">
|
||||
<p><label>Allow block</label></p>
|
||||
<textarea name="allow">{{ allow_input }}</textarea>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<p><label>Actor</label></p>
|
||||
<textarea name="actor">{{ actor_input }}</textarea>
|
||||
</div>
|
||||
<div style="margin-top: 1em;">
|
||||
<input type="submit" value="Apply allow block to actor">
|
||||
</div>
|
||||
</form>
|
||||
<div class="permission-form">
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
|
||||
<div class="permission-form-grid">
|
||||
<div class="form-section">
|
||||
<label for="allow-block">Allow block</label>
|
||||
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="allow-actor">Actor</label>
|
||||
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">Apply allow block to actor</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
|
||||
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
|
||||
|
||||
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
|
||||
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
|
||||
|
||||
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
|
||||
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{% block title %}API Explorer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
||||
<link rel="stylesheet" href="{{ static('app.css') }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
{% for url in extra_css_urls %}
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
{% endfor %}
|
||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
||||
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
||||
{% for url in extra_js_urls %}
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
{% endfor %}
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<script src="{{ static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% if database_page_data.createTable %}
|
||||
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
|
||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||
|
|
@ -72,7 +76,7 @@
|
|||
<div class="db-table">
|
||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} 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_truncated %}>{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p style="margin-bottom: 2em;">
|
||||
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
|
||||
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
|
||||
Actions are used by the permission system to control access to different features.
|
||||
</p>
|
||||
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for action in data %}
|
||||
{% for action in data.actions %}
|
||||
<tr>
|
||||
<td><strong>{{ action.name }}</strong></td>
|
||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{% block title %}Allowed Resources{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
const page = params.get('_page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
if (value && key !== '_size' && key !== '_page') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
params.append('_page', page.toString());
|
||||
params.append('_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Check{% endblock %}
|
||||
{% block title %}Explain a permission decision{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
<style>
|
||||
|
|
@ -13,29 +13,35 @@
|
|||
border-radius: 5px;
|
||||
}
|
||||
#output.allowed {
|
||||
background-color: #e8f5e9;
|
||||
background-color: #f3fbf4;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
#output.denied {
|
||||
background-color: #ffebee;
|
||||
background-color: #fff7f7;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
#output h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
#output .result-badge {
|
||||
#output h3 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
#output .result-badge,
|
||||
.effect-badge,
|
||||
.rule-status {
|
||||
display: inline-block;
|
||||
padding: 0.3em 0.8em;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
#output .allowed-badge {
|
||||
background-color: #4caf50;
|
||||
#output .allowed-badge,
|
||||
.effect-allow {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
}
|
||||
#output .denied-badge {
|
||||
background-color: #f44336;
|
||||
#output .denied-badge,
|
||||
.effect-deny {
|
||||
background-color: #c62828;
|
||||
color: white;
|
||||
}
|
||||
.details-section {
|
||||
|
|
@ -48,70 +54,130 @@
|
|||
.details-section dd {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.explanation-section {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
padding: 0 1em 1em;
|
||||
}
|
||||
.rules-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.rules-table th,
|
||||
.rules-table td {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rule-status {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
.rule-ignored {
|
||||
background: #eee;
|
||||
color: #555;
|
||||
font-weight: normal;
|
||||
}
|
||||
.requirement-allowed {
|
||||
color: #1b5e20;
|
||||
}
|
||||
.requirement-denied {
|
||||
color: #b71c1c;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.rules-table,
|
||||
.rules-table tbody,
|
||||
.rules-table tr,
|
||||
.rules-table td {
|
||||
display: block;
|
||||
}
|
||||
.rules-table thead {
|
||||
display: none;
|
||||
}
|
||||
.rules-table td::before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission check</h1>
|
||||
<h1>Explain a permission decision</h1>
|
||||
|
||||
{% set current_tab = "check" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
||||
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<label for="actor">Actor JSON:</label>
|
||||
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
|
||||
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="action">Action:</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% for action in actions %}
|
||||
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
<small id="action-help">The operation to evaluate</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Parent resource (optional):</label>
|
||||
<div class="form-section" id="parent-section">
|
||||
<label for="parent">Parent resource:</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>For database-level permissions, specify the database name</small>
|
||||
<small>The database or other parent resource</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Child resource (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||
<div class="form-section" id="child-section">
|
||||
<label for="child">Child resource:</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
|
||||
<small>The table, query or other child resource</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="output" style="display: none;">
|
||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||
<p id="result-summary"></p>
|
||||
|
||||
<dl class="details-section">
|
||||
<dt>Actor:</dt>
|
||||
<dd><code id="result-actor"></code></dd>
|
||||
<dt>Action:</dt>
|
||||
<dd id="result-action"></dd>
|
||||
|
||||
<dt>Resource Path:</dt>
|
||||
<dd id="result-resource"></dd>
|
||||
|
||||
<dt>Actor ID:</dt>
|
||||
<dd id="result-actor"></dd>
|
||||
|
||||
<div id="additional-details"></div>
|
||||
<dd><code id="result-action"></code></dd>
|
||||
<dt>Resource:</dt>
|
||||
<dd><code id="result-resource"></code></dd>
|
||||
</dl>
|
||||
|
||||
<section class="explanation-section">
|
||||
<h3>Matching rules</h3>
|
||||
<div id="matching-rules"></div>
|
||||
</section>
|
||||
|
||||
<section class="explanation-section" id="restrictions-section">
|
||||
<h3>Actor restrictions</h3>
|
||||
<div id="restriction-results"></div>
|
||||
</section>
|
||||
|
||||
<section class="explanation-section" id="requirements-section">
|
||||
<h3>Required actions</h3>
|
||||
<div id="requirement-results"></div>
|
||||
</section>
|
||||
|
||||
<details style="margin-top: 1em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
|
|
@ -119,152 +185,134 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
|
||||
const form = document.getElementById('check-form');
|
||||
const output = document.getElementById('output');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const actionSelect = document.getElementById('action');
|
||||
|
||||
function updateResourceFields() {
|
||||
const action = actions[actionSelect.value];
|
||||
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
|
||||
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
|
||||
let help = action && action.description ? action.description : 'The operation to evaluate';
|
||||
if (action && action.also_requires) {
|
||||
help += `; also requires ${action.also_requires}`;
|
||||
}
|
||||
document.getElementById('action-help').textContent = help;
|
||||
}
|
||||
|
||||
async function performCheck() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Checking...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
submitBtn.textContent = 'Explaining...';
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResult(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
displayError({error: error.message});
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Permission';
|
||||
submitBtn.textContent = 'Explain decision';
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
performCheck();
|
||||
}
|
||||
})();
|
||||
|
||||
function displayResult(data) {
|
||||
output.style.display = 'block';
|
||||
|
||||
// Set badge and styling
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
if (data.allowed) {
|
||||
output.className = 'allowed';
|
||||
resultBadge.className = 'result-badge allowed-badge';
|
||||
resultBadge.textContent = 'ALLOWED ✓';
|
||||
} else {
|
||||
output.className = 'denied';
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'DENIED ✗';
|
||||
}
|
||||
|
||||
// Basic details
|
||||
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||
|
||||
// Additional details
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '';
|
||||
|
||||
if (data.reason !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Reason:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.reason || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.source_plugin !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Source Plugin:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.source_plugin || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.used_default !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Used Default:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.depth !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Depth:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.depth;
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
// Raw JSON
|
||||
output.className = data.allowed ? 'allowed' : 'denied';
|
||||
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
|
||||
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
||||
document.getElementById('result-summary').textContent = data.explanation.summary;
|
||||
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
|
||||
document.getElementById('result-action').textContent = data.action;
|
||||
document.getElementById('result-resource').textContent = data.resource.path;
|
||||
displayRules(data.explanation);
|
||||
displayRestrictions(data.explanation.restrictions);
|
||||
displayRequirements(data.explanation.required_actions);
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
// Scroll to output
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
function displayRules(explanation) {
|
||||
const container = document.getElementById('matching-rules');
|
||||
if (!explanation.matched_rules.length) {
|
||||
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
|
||||
return;
|
||||
}
|
||||
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
|
||||
for (const rule of explanation.matched_rules) {
|
||||
const status = rule.decisive
|
||||
? '<span class="rule-status">Decisive</span>'
|
||||
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
|
||||
html += '<tr>';
|
||||
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
|
||||
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
|
||||
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
|
||||
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
|
||||
html += `<td data-label="Role in decision">${status}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
container.innerHTML = html + '</tbody></table>';
|
||||
}
|
||||
|
||||
function displayRestrictions(restrictions) {
|
||||
const section = document.getElementById('restrictions-section');
|
||||
const container = document.getElementById('restriction-results');
|
||||
section.style.display = restrictions.length ? 'block' : 'none';
|
||||
container.innerHTML = restrictions.map(restriction => {
|
||||
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
|
||||
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
|
||||
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function displayRequirements(requirements) {
|
||||
const section = document.getElementById('requirements-section');
|
||||
const container = document.getElementById('requirement-results');
|
||||
section.style.display = requirements.length ? 'block' : 'none';
|
||||
container.innerHTML = requirements.map(requirement => {
|
||||
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
|
||||
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
||||
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
output.style.display = 'block';
|
||||
output.className = 'denied';
|
||||
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'ERROR';
|
||||
|
||||
document.getElementById('result-action').textContent = 'N/A';
|
||||
document.getElementById('result-resource').textContent = 'N/A';
|
||||
document.getElementById('result-actor').textContent = 'N/A';
|
||||
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||
|
||||
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
|
||||
document.getElementById('result-actor').textContent = '—';
|
||||
document.getElementById('result-action').textContent = '—';
|
||||
document.getElementById('result-resource').textContent = '—';
|
||||
document.getElementById('matching-rules').innerHTML = '';
|
||||
document.getElementById('restrictions-section').style.display = 'none';
|
||||
document.getElementById('requirements-section').style.display = 'none';
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before adding a child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
performCheck();
|
||||
});
|
||||
</script>
|
||||
actionSelect.addEventListener('change', updateResourceFields);
|
||||
|
||||
(function initializeFromUrl() {
|
||||
const params = populateFormFromURL();
|
||||
updateResourceFields();
|
||||
if (params.get('action')) {
|
||||
performCheck();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
{% block title %}Permission activity{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
|
|
@ -20,60 +20,45 @@
|
|||
.check-action, .check-when, .check-result {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
textarea {
|
||||
height: 10em;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border: 2px dotted black;
|
||||
}
|
||||
.two-col {
|
||||
display: inline-block;
|
||||
width: 48%;
|
||||
}
|
||||
.two-col label {
|
||||
width: 48%;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission playground</h1>
|
||||
<h1>Permission activity</h1>
|
||||
|
||||
{% set current_tab = "permissions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||
<h2>Raw simulator</h2>
|
||||
|
||||
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||
<div class="two-col">
|
||||
<div class="form-section">
|
||||
<label>Actor</label>
|
||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
<div class="permission-form-grid">
|
||||
<div>
|
||||
<div class="form-section">
|
||||
<label for="activity-actor">Actor</label>
|
||||
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col" style="vertical-align: top">
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
<div>
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
|
|
@ -125,7 +110,7 @@ debugPost.addEventListener('submit', function(ev) {
|
|||
});
|
||||
</script>
|
||||
|
||||
<h1>Recent permissions checks</h1>
|
||||
<h2>Recent permission checks</h2>
|
||||
|
||||
<p>
|
||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
{% block title %}Permission Rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
const page = params.get('_page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
if (value && key !== '_size' && key !== '_page') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
params.append('_page', page.toString());
|
||||
params.append('_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.execute-write form.sql .sql-editor-min-lines .cm-content,
|
||||
.execute-write form.sql .sql-editor-min-lines .cm-gutter {
|
||||
/* Four visible editor lines without adding blank lines to the SQL value. */
|
||||
min-height: calc(5.6em + 8px);
|
||||
}
|
||||
.execute-write-disabled-reason {
|
||||
color: #4f5b6d;
|
||||
font-size: 0.85rem;
|
||||
|
|
@ -93,20 +98,25 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
{% endif %}
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
|
||||
{% if write_template_tables %}
|
||||
{% 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">
|
||||
<label for="execute-write-template-table">Table</label>
|
||||
<select id="execute-write-template-table">
|
||||
{% for table_name, table in write_template_tables|dictsort %}
|
||||
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
|
||||
{% 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 %}
|
||||
</select>
|
||||
{% for operation in write_template_operations %}
|
||||
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
|
@ -114,7 +124,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><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 %}
|
||||
|
|
@ -159,19 +169,13 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
|||
</p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (executeWriteSqlInput && !executeWriteSqlInput.value) {
|
||||
executeWriteSqlInput.value = "\n\n\n";
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
||||
const submitButton = form
|
||||
|
|
@ -252,11 +256,12 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
});
|
||||
</script>
|
||||
|
||||
{% if write_template_tables %}
|
||||
{% 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`;
|
||||
|
|
@ -266,26 +271,59 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
|
||||
}
|
||||
|
||||
function templateSql(operation) {
|
||||
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.dataset.sqlTemplate);
|
||||
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.dataset.sqlTemplate);
|
||||
const sql = templateSql(button);
|
||||
if (!sql) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sql", sql);
|
||||
window.location.href = url.toString();
|
||||
setEditorSql(sql);
|
||||
});
|
||||
});
|
||||
if (tableSelect) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title>Datasette: Pattern Portfolio</title>
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
|
||||
<link rel="stylesheet" href="{{ static('app.css') }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex">
|
||||
<style></style>
|
||||
|
|
@ -202,9 +202,9 @@
|
|||
<h3>3 rows
|
||||
where characteristic_id = 2
|
||||
</h3>
|
||||
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
||||
<form class="core filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
||||
<div class="filter-row">
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column_1">
|
||||
<option value="">- remove filter -</option>
|
||||
|
|
@ -238,7 +238,7 @@
|
|||
</select>
|
||||
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column">
|
||||
<option value="">- column -</option>
|
||||
|
|
@ -272,8 +272,8 @@
|
|||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper small-screen-only">
|
||||
<div class="filter-row filter-actions-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_sort" id="sort_by">
|
||||
<option value="">Sort...</option>
|
||||
<option value="rowid" selected>Sort by rowid</option>
|
||||
|
|
@ -281,8 +281,8 @@
|
|||
<option value="characteristic_id">Sort by characteristic_id</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
||||
<input type="submit" value="Apply">
|
||||
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
||||
<input type="submit" value="Apply filters">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
{% if row_mutation_ui %}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% 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 title %}{{ database }}: {{ table }}: {% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- 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>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<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>
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
@ -48,19 +48,19 @@
|
|||
|
||||
{% if count or human_description_en %}
|
||||
<h3>
|
||||
{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows
|
||||
{% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows
|
||||
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
{% if supports_search %}
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
||||
{% endif %}
|
||||
{% for column, lookup, value in filters.selections() %}
|
||||
<div class="filter-row">
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column_{{ loop.index }}">
|
||||
<option value="">- remove filter -</option>
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="filter-row">
|
||||
<div class="filter-row filter-controls-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column">
|
||||
<option value="">- column -</option>
|
||||
|
|
@ -93,9 +93,9 @@
|
|||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="filter-row filter-actions-row">
|
||||
{% if is_sortable %}
|
||||
<div class="select-wrapper small-screen-only">
|
||||
<div class="select-wrapper">
|
||||
<select name="_sort" id="sort_by">
|
||||
<option value="">Sort...</option>
|
||||
{% for column in display_columns %}
|
||||
|
|
@ -105,12 +105,12 @@
|
|||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
|
||||
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
|
||||
{% endif %}
|
||||
{% for key, value in form_hidden_args %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<input type="submit" value="Apply">
|
||||
<input type="submit" value="Apply filters" tabindex="0">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,21 @@ if TYPE_CHECKING:
|
|||
from datasette.app import Datasette
|
||||
|
||||
|
||||
class TokenInvalid(Exception):
|
||||
"""
|
||||
Raised by a TokenHandler when a token it recognizes is invalid -
|
||||
for example a bad signature, malformed payload or expired token.
|
||||
|
||||
Datasette responds to this with an HTTP 401 error. Handlers should
|
||||
return None instead for tokens they do not recognize at all, so that
|
||||
other registered handlers get a chance to verify them.
|
||||
"""
|
||||
|
||||
def __init__(self, message="Invalid token"):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TokenRestrictions:
|
||||
"""
|
||||
|
|
@ -108,8 +123,12 @@ class TokenHandler:
|
|||
|
||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verify a token and return an actor dict, or None if this handler
|
||||
does not recognize the token.
|
||||
Verify a token and return an actor dict.
|
||||
|
||||
Return None if this handler does not recognize the token at all,
|
||||
so other handlers can try it. Raise TokenInvalid if the token is
|
||||
recognized but invalid (bad signature, malformed, expired) - the
|
||||
request will fail with a 401 error.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
|
@ -147,29 +166,32 @@ class SignedTokenHandler(TokenHandler):
|
|||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||
prefix = "dstok_"
|
||||
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
if not token.startswith(prefix):
|
||||
# Not one of our tokens - leave it for other handlers
|
||||
return None
|
||||
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
raise TokenInvalid(
|
||||
"Signed tokens are not enabled for this Datasette instance"
|
||||
)
|
||||
|
||||
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||
|
||||
if not token.startswith(prefix):
|
||||
return None
|
||||
|
||||
raw = token[len(prefix) :]
|
||||
try:
|
||||
decoded = datasette.unsign(raw, namespace="token")
|
||||
except itsdangerous.BadSignature:
|
||||
return None
|
||||
raise TokenInvalid("Invalid token signature")
|
||||
|
||||
if "t" not in decoded:
|
||||
return None
|
||||
raise TokenInvalid("Invalid token: no timestamp")
|
||||
created = decoded["t"]
|
||||
if not isinstance(created, int):
|
||||
return None
|
||||
raise TokenInvalid("Invalid token: invalid timestamp")
|
||||
|
||||
duration = decoded.get("d")
|
||||
if duration is not None and not isinstance(duration, int):
|
||||
return None
|
||||
raise TokenInvalid("Invalid token: invalid duration")
|
||||
|
||||
if (duration is None and max_signed_tokens_ttl) or (
|
||||
duration is not None
|
||||
|
|
@ -180,7 +202,7 @@ class SignedTokenHandler(TokenHandler):
|
|||
|
||||
if duration:
|
||||
if time.time() - created > duration:
|
||||
return None
|
||||
raise TokenInvalid("Token has expired")
|
||||
|
||||
actor = {"id": decoded["a"], "token": "dstok"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import binascii
|
||||
from contextlib import contextmanager
|
||||
import aiofiles
|
||||
import click
|
||||
|
|
@ -224,24 +225,71 @@ def compound_keys_after_sql(pks, start_index=0):
|
|||
return "({})".format("\n or\n".join(or_clauses))
|
||||
|
||||
|
||||
@documented
|
||||
class CustomJSONEncoder(json.JSONEncoder):
|
||||
"""
|
||||
The CustomJSONEncoder class handles serialization for objects commonly used by Datasette,
|
||||
including SQLite cursors and binary blobs. Datasette uses it internally to serve .json endpoints,
|
||||
and plugins that return JSON can use it to match Datasette's own handling.
|
||||
|
||||
Built-in types (text, numbers, lists, etc) are encoded the same as Python's built-in ``json`` module.
|
||||
|
||||
- ``sqlite3.Row`` becomes a tuple
|
||||
- ``sqlite3.Cursor`` becomes a list
|
||||
|
||||
Binary blobs are encoded as an object, with the actual data base64-encoded,
|
||||
like so: ::
|
||||
|
||||
{
|
||||
"$base64": True,
|
||||
"encoded": ...,
|
||||
}
|
||||
|
||||
Example: https://latest.datasette.io/fixtures/binary_data.json
|
||||
"""
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, sqlite3.Row):
|
||||
return tuple(obj)
|
||||
if isinstance(obj, sqlite3.Cursor):
|
||||
return list(obj)
|
||||
if isinstance(obj, bytes):
|
||||
# Does it encode to utf8?
|
||||
try:
|
||||
return obj.decode("utf8")
|
||||
except UnicodeDecodeError:
|
||||
return {
|
||||
"$base64": True,
|
||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||
}
|
||||
return {
|
||||
"$base64": True,
|
||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||
}
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
class WriteJsonValueError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def decode_write_json_cell(value):
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
keys = set(value.keys())
|
||||
if keys == {"$raw"}:
|
||||
return value["$raw"]
|
||||
if keys == {"$base64", "encoded"} and value.get("$base64") is True:
|
||||
encoded = value["encoded"]
|
||||
if not isinstance(encoded, str):
|
||||
raise WriteJsonValueError("$base64 encoded value must be a string")
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except binascii.Error as ex:
|
||||
raise WriteJsonValueError("Invalid $base64 encoded value") from ex
|
||||
return value
|
||||
|
||||
|
||||
def decode_write_json_row(row):
|
||||
return {key: decode_write_json_cell(value) for key, value in row.items()}
|
||||
|
||||
|
||||
def decode_write_json_rows(rows):
|
||||
return [decode_write_json_row(row) for row in rows]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sqlite_timelimit(conn, ms):
|
||||
deadline = time.perf_counter() + (ms / 1000)
|
||||
|
|
@ -410,12 +458,7 @@ def escape_css_string(s):
|
|||
def escape_sqlite(s):
|
||||
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
||||
return s
|
||||
elif "]" in s:
|
||||
# SQLite does not support escaping ] inside [bracket] quoting, so fall
|
||||
# back to double-quote quoting (doubling any embedded ") - #2677
|
||||
return '"{}"'.format(s.replace('"', '""'))
|
||||
else:
|
||||
return f"[{s}]"
|
||||
return '"{}"'.format(s.replace('"', '""'))
|
||||
|
||||
|
||||
def make_dockerfile(
|
||||
|
|
@ -593,7 +636,7 @@ def detect_primary_keys(conn, table):
|
|||
|
||||
|
||||
def get_outbound_foreign_keys(conn, table):
|
||||
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
|
||||
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
|
||||
fks = []
|
||||
for info in infos:
|
||||
if info is not None:
|
||||
|
|
@ -1245,10 +1288,20 @@ class StartupError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
_single_line_comment_re = re.compile(r"--.*")
|
||||
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
|
||||
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
|
||||
# Comments and string literals, matched in a single pass so that whichever
|
||||
# construct starts first "wins" - this ensures a comment marker inside a string
|
||||
# literal (or a quote inside a comment) does not confuse the parameter scan.
|
||||
_comments_and_strings_re = re.compile(
|
||||
r"""
|
||||
--[^\n]* # single line comment
|
||||
| /\*.*?(?:\*/|\Z) # multi line comment, possibly to end-of-input
|
||||
| '(?:''|[^'])*' # single quoted string ('' escapes a quote)
|
||||
| "(?:""|[^"])*" # double quoted identifier ("" escapes a quote)
|
||||
| \[(?:[^\]])*\] # square-bracket quoted identifier
|
||||
| `(?:``|[^`])*` # backtick quoted identifier
|
||||
""",
|
||||
re.DOTALL | re.VERBOSE,
|
||||
)
|
||||
_named_param_re = re.compile(r":(\w+)")
|
||||
|
||||
|
||||
|
|
@ -1259,10 +1312,9 @@ def named_parameters(sql: str) -> List[str]:
|
|||
|
||||
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
||||
"""
|
||||
sql = _single_line_comment_re.sub("", sql)
|
||||
sql = _multi_line_comment_re.sub("", sql)
|
||||
sql = _single_quote_re.sub("", sql)
|
||||
sql = _double_quote_re.sub("", sql)
|
||||
# Strip comments and string literals first so that any ":name" sequences
|
||||
# inside them are not mistaken for named parameters
|
||||
sql = _comments_and_strings_re.sub("", sql)
|
||||
# Extract parameters from what is left
|
||||
return _named_param_re.findall(sql)
|
||||
|
||||
|
|
@ -1275,6 +1327,54 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
|
|||
return named_parameters(sql)
|
||||
|
||||
|
||||
def parse_size_limit(value, default, maximum, name="_size"):
|
||||
"""
|
||||
Parse a page-size parameter using the same semantics as the table
|
||||
view's ?_size=: blank means default, "max" means maximum, integers
|
||||
must be 0 or greater and no larger than maximum. Raises ValueError
|
||||
with a message suitable for a 400 response.
|
||||
"""
|
||||
if value in (None, ""):
|
||||
return default
|
||||
if value == "max":
|
||||
return maximum
|
||||
try:
|
||||
size = int(value)
|
||||
if size < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError("{} must be a positive integer".format(name))
|
||||
if size > maximum:
|
||||
raise ValueError("{} must be <= {}".format(name, maximum))
|
||||
return size
|
||||
|
||||
|
||||
UNSTABLE_API_MESSAGE = (
|
||||
"This API is not part of Datasette's stable interface and may change at any time"
|
||||
)
|
||||
|
||||
|
||||
def error_body(messages, status):
|
||||
"""
|
||||
The canonical JSON error body used by every Datasette JSON error response:
|
||||
|
||||
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
|
||||
|
||||
"error" is all of the messages joined with "; ", "errors" is the full
|
||||
list, "status" matches the HTTP status code. Callers may add extra
|
||||
context keys to the returned dictionary but must not remove these four.
|
||||
"""
|
||||
if isinstance(messages, str):
|
||||
messages = [messages]
|
||||
messages = [str(message) for message in messages]
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "; ".join(messages),
|
||||
"errors": messages,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def add_cors_headers(headers):
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||
|
|
@ -1548,6 +1648,17 @@ def md5_not_usedforsecurity(s):
|
|||
_etag_cache = {}
|
||||
|
||||
|
||||
def sha256_file(filepath, chunk_size=4096):
|
||||
hasher = hashlib.sha256()
|
||||
with open(filepath, "rb") as fp:
|
||||
while True:
|
||||
chunk = fp.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
async def calculate_etag(filepath, chunk_size=4096):
|
||||
if filepath in _etag_cache:
|
||||
return _etag_cache[filepath]
|
||||
|
|
|
|||
|
|
@ -252,88 +252,62 @@ async def _build_single_action_sql(
|
|||
]
|
||||
)
|
||||
|
||||
# Continue with the cascading logic
|
||||
query_parts.extend(
|
||||
[
|
||||
"child_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"parent_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"global_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||
" FROM base b",
|
||||
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
# Continue with the cascading logic.
|
||||
# Aggregate the RULES by cascade level (small), rather than grouping
|
||||
# base x rules (which scales with the number of resources).
|
||||
def _agg(select_key, where, group_by):
|
||||
parts = [
|
||||
f" SELECT {select_key}",
|
||||
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
|
||||
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
|
||||
f" FROM all_rules WHERE {where}",
|
||||
]
|
||||
if group_by:
|
||||
parts.append(f" GROUP BY {group_by}")
|
||||
return parts
|
||||
|
||||
query_parts.extend(
|
||||
["child_agg AS ("]
|
||||
+ _agg(
|
||||
"parent, child,",
|
||||
"parent IS NOT NULL AND child IS NOT NULL",
|
||||
"parent, child",
|
||||
)
|
||||
+ ["),", "parent_agg AS ("]
|
||||
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
||||
+ ["),", "global_agg AS ("]
|
||||
+ _agg("", "parent IS NULL AND child IS NULL", None)
|
||||
+ ["),"]
|
||||
)
|
||||
|
||||
# Add anonymous decision logic if needed
|
||||
if include_is_private:
|
||||
query_parts.extend(
|
||||
[
|
||||
"anon_child_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_parent_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_global_lvl AS (",
|
||||
" SELECT b.parent, b.child,",
|
||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
" FROM base b",
|
||||
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"anon_decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" CASE",
|
||||
" WHEN acl.any_deny = 1 THEN 0",
|
||||
" WHEN acl.any_allow = 1 THEN 1",
|
||||
" WHEN apl.any_deny = 1 THEN 0",
|
||||
" WHEN apl.any_allow = 1 THEN 1",
|
||||
" WHEN agl.any_deny = 1 THEN 0",
|
||||
" WHEN agl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS anon_is_allowed",
|
||||
" FROM base b",
|
||||
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
|
||||
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
|
||||
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
|
||||
"),",
|
||||
|
||||
def _anon_agg(select_key, where, group_by):
|
||||
parts = [
|
||||
f" SELECT {select_key}",
|
||||
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||
f" FROM anon_rules WHERE {where}",
|
||||
]
|
||||
if group_by:
|
||||
parts.append(f" GROUP BY {group_by}")
|
||||
return parts
|
||||
|
||||
query_parts.extend(
|
||||
["anon_child_agg AS ("]
|
||||
+ _anon_agg(
|
||||
"parent, child,",
|
||||
"parent IS NOT NULL AND child IS NOT NULL",
|
||||
"parent, child",
|
||||
)
|
||||
+ ["),", "anon_parent_agg AS ("]
|
||||
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
||||
+ ["),", "anon_global_agg AS ("]
|
||||
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
|
||||
+ ["),"]
|
||||
)
|
||||
|
||||
# Final decisions
|
||||
|
|
@ -342,31 +316,28 @@ async def _build_single_action_sql(
|
|||
"decisions AS (",
|
||||
" SELECT",
|
||||
" b.parent, b.child,",
|
||||
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
|
||||
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
|
||||
" -- Priority order:",
|
||||
" -- 1. Child-level deny (most specific, blocks access)",
|
||||
" -- 2. Child-level allow (most specific, grants access)",
|
||||
" -- 3. Parent-level deny (intermediate, blocks access)",
|
||||
" -- 4. Parent-level allow (intermediate, grants access)",
|
||||
" -- 5. Global-level deny (least specific, blocks access)",
|
||||
" -- 6. Global-level allow (least specific, grants access)",
|
||||
" -- 1. Child-level deny 2. Child-level allow",
|
||||
" -- 3. Parent-level deny 4. Parent-level allow",
|
||||
" -- 5. Global-level deny 6. Global-level allow",
|
||||
" -- 7. Default deny (no rules match)",
|
||||
" CASE",
|
||||
" WHEN cl.any_deny = 1 THEN 0",
|
||||
" WHEN cl.any_allow = 1 THEN 1",
|
||||
" WHEN pl.any_deny = 1 THEN 0",
|
||||
" WHEN pl.any_allow = 1 THEN 1",
|
||||
" WHEN gl.any_deny = 1 THEN 0",
|
||||
" WHEN gl.any_allow = 1 THEN 1",
|
||||
" WHEN ca.any_deny = 1 THEN 0",
|
||||
" WHEN ca.any_allow = 1 THEN 1",
|
||||
" WHEN pa.any_deny = 1 THEN 0",
|
||||
" WHEN pa.any_allow = 1 THEN 1",
|
||||
" WHEN ga.any_deny = 1 THEN 0",
|
||||
" WHEN ga.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS is_allowed,",
|
||||
" CASE",
|
||||
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
|
||||
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
|
||||
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
|
||||
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
|
||||
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
|
||||
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
|
||||
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
|
||||
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
|
||||
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
|
||||
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
|
||||
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
|
||||
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
|
||||
" ELSE '[]'",
|
||||
" END AS reason",
|
||||
]
|
||||
|
|
@ -374,21 +345,34 @@ async def _build_single_action_sql(
|
|||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
||||
" , CASE WHEN ("
|
||||
"CASE"
|
||||
" WHEN aca.any_deny = 1 THEN 0"
|
||||
" WHEN aca.any_allow = 1 THEN 1"
|
||||
" WHEN apa.any_deny = 1 THEN 0"
|
||||
" WHEN apa.any_allow = 1 THEN 1"
|
||||
" WHEN aga.any_deny = 1 THEN 0"
|
||||
" WHEN aga.any_allow = 1 THEN 1"
|
||||
" ELSE 0 END"
|
||||
") = 0 THEN 1 ELSE 0 END AS is_private"
|
||||
)
|
||||
|
||||
query_parts.extend(
|
||||
[
|
||||
" FROM base b",
|
||||
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
||||
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
||||
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
|
||||
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
|
||||
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
|
||||
" CROSS JOIN global_agg ga",
|
||||
]
|
||||
)
|
||||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
|
||||
query_parts.extend(
|
||||
[
|
||||
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
|
||||
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
|
||||
" CROSS JOIN anon_global_agg aga",
|
||||
]
|
||||
)
|
||||
|
||||
query_parts.append(")")
|
||||
|
|
@ -400,8 +384,28 @@ async def _build_single_action_sql(
|
|||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
# Decompose by NULL-pattern so the final filter can use pure-equality
|
||||
# EXISTS lookups (satisfiable via automatic indexes) instead of a
|
||||
# correlated OR-scan over the whole list.
|
||||
query_parts.extend(
|
||||
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
|
||||
[
|
||||
",",
|
||||
"restriction_list AS (",
|
||||
f" {restriction_intersect}",
|
||||
"),",
|
||||
"restriction_exact AS (",
|
||||
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
|
||||
"),",
|
||||
"restriction_parent_any AS (",
|
||||
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
|
||||
"),",
|
||||
"restriction_child_any AS (",
|
||||
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
|
||||
"),",
|
||||
"restriction_all AS (",
|
||||
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
# Final SELECT
|
||||
|
|
@ -416,10 +420,11 @@ async def _build_single_action_sql(
|
|||
# Add restriction filter if there are restrictions
|
||||
if restriction_sqls:
|
||||
query_parts.append("""
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM restriction_list r
|
||||
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
|
||||
AND (r.child = decisions.child OR r.child IS NULL)
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM restriction_all)
|
||||
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
|
||||
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
|
||||
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
|
||||
)""")
|
||||
|
||||
# Add parent filter if specified
|
||||
|
|
@ -673,3 +678,239 @@ async def check_permission_for_resource(
|
|||
child=child,
|
||||
)
|
||||
return results[action]
|
||||
|
||||
|
||||
async def explain_permission_for_resource(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> dict:
|
||||
"""Explain a permission decision for one action and resource.
|
||||
|
||||
This is intended for Datasette's permission debugging tools. It uses the
|
||||
same ``permission_resources_sql`` hook results and the same resolution
|
||||
rules as :func:`check_permissions_for_actions`, but also returns the
|
||||
matching rules, actor restriction results and ``also_requires`` chain.
|
||||
|
||||
The returned dictionary is part of Datasette's unstable debugging API.
|
||||
"""
|
||||
|
||||
action_obj = datasette.actions.get(action)
|
||||
if action_obj is None:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
explanation = await _explain_single_action(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
|
||||
required_actions = []
|
||||
if action_obj.also_requires:
|
||||
required = await explain_permission_for_resource(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action_obj.also_requires,
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
required_actions.append(required)
|
||||
|
||||
explanation["required_actions"] = required_actions
|
||||
explanation["allowed"] = bool(
|
||||
explanation["rule_allowed"]
|
||||
and explanation["restriction_allowed"]
|
||||
and all(required["allowed"] for required in required_actions)
|
||||
)
|
||||
explanation["summary"] = _permission_explanation_summary(explanation)
|
||||
return explanation
|
||||
|
||||
|
||||
async def _explain_single_action(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
action: str,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> dict:
|
||||
"""Return matching rules and restrictions for a single action."""
|
||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||
|
||||
permission_sqls = await gather_permission_sql_from_hooks(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
action=action,
|
||||
)
|
||||
|
||||
if permission_sqls is SKIP_PERMISSION_CHECKS:
|
||||
return {
|
||||
"action": action,
|
||||
"rule_allowed": True,
|
||||
"restriction_allowed": True,
|
||||
"winning_scope": "global",
|
||||
"matched_rules": [
|
||||
{
|
||||
"scope": "global",
|
||||
"effect": "allow",
|
||||
"source": "skip_permission_checks",
|
||||
"reason": "Permission checks were explicitly skipped",
|
||||
"decisive": True,
|
||||
"ignored_because": None,
|
||||
}
|
||||
],
|
||||
"restrictions": [],
|
||||
}
|
||||
|
||||
db = datasette.get_internal_database()
|
||||
matched_rules = []
|
||||
restrictions = []
|
||||
|
||||
for permission_sql in permission_sqls:
|
||||
params = dict(permission_sql.params or {})
|
||||
parent_param = _unused_parameter_name(params, "_explain_parent")
|
||||
params[parent_param] = parent
|
||||
child_param = _unused_parameter_name(params, "_explain_child")
|
||||
params[child_param] = child
|
||||
|
||||
if permission_sql.sql:
|
||||
rows = await db.execute(
|
||||
f"""
|
||||
SELECT parent, child, allow, reason
|
||||
FROM ({permission_sql.sql}) AS permission_rules
|
||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||
AND (child IS NULL OR child = :{child_param})
|
||||
""",
|
||||
params,
|
||||
)
|
||||
for row in rows:
|
||||
specificity = (
|
||||
2
|
||||
if row["child"] is not None
|
||||
else 1 if row["parent"] is not None else 0
|
||||
)
|
||||
matched_rules.append(
|
||||
{
|
||||
"scope": ("resource", "parent", "global")[2 - specificity],
|
||||
"effect": "allow" if row["allow"] else "deny",
|
||||
"source": permission_sql.source,
|
||||
"reason": row["reason"],
|
||||
"_specificity": specificity,
|
||||
}
|
||||
)
|
||||
|
||||
if permission_sql.restriction_sql:
|
||||
restriction_row = (
|
||||
await db.execute(
|
||||
f"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
|
||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||
AND (child IS NULL OR child = :{child_param})
|
||||
) AS resource_is_in_allowlist
|
||||
""",
|
||||
params,
|
||||
)
|
||||
).first()
|
||||
restriction_allowed = bool(restriction_row[0])
|
||||
restrictions.append(
|
||||
{
|
||||
"source": permission_sql.source,
|
||||
"allowed": restriction_allowed,
|
||||
"reason": params.get("deny")
|
||||
or (
|
||||
"Resource is included in this restriction allowlist"
|
||||
if restriction_allowed
|
||||
else "Resource is not included in this restriction allowlist"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
matched_rules.sort(
|
||||
key=lambda rule: (
|
||||
-rule["_specificity"],
|
||||
0 if rule["effect"] == "deny" else 1,
|
||||
rule["source"] or "",
|
||||
rule["reason"] or "",
|
||||
)
|
||||
)
|
||||
|
||||
if matched_rules:
|
||||
winning_specificity = matched_rules[0]["_specificity"]
|
||||
winning_rules = [
|
||||
rule
|
||||
for rule in matched_rules
|
||||
if rule["_specificity"] == winning_specificity
|
||||
]
|
||||
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
|
||||
winning_scope = winning_rules[0]["scope"]
|
||||
else:
|
||||
winning_specificity = None
|
||||
rule_allowed = False
|
||||
winning_scope = None
|
||||
|
||||
for rule in matched_rules:
|
||||
specificity = rule.pop("_specificity")
|
||||
if specificity != winning_specificity:
|
||||
rule["decisive"] = False
|
||||
rule["ignored_because"] = "A more specific rule matched"
|
||||
elif not rule_allowed and rule["effect"] == "allow":
|
||||
rule["decisive"] = False
|
||||
rule["ignored_because"] = "A deny rule matched at the same scope"
|
||||
else:
|
||||
rule["decisive"] = True
|
||||
rule["ignored_because"] = None
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"rule_allowed": rule_allowed,
|
||||
"restriction_allowed": all(
|
||||
restriction["allowed"] for restriction in restrictions
|
||||
),
|
||||
"winning_scope": winning_scope,
|
||||
"matched_rules": matched_rules,
|
||||
"restrictions": restrictions,
|
||||
}
|
||||
|
||||
|
||||
def _unused_parameter_name(params: dict, preferred: str) -> str:
|
||||
"""Return a SQL parameter name that is not already in ``params``."""
|
||||
candidate = preferred
|
||||
suffix = 2
|
||||
while candidate in params:
|
||||
candidate = f"{preferred}_{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _permission_explanation_summary(explanation: dict) -> str:
|
||||
denied_requirement = next(
|
||||
(
|
||||
required
|
||||
for required in explanation["required_actions"]
|
||||
if not required["allowed"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if denied_requirement:
|
||||
return (
|
||||
f"Denied because {explanation['action']} also requires "
|
||||
f"{denied_requirement['action']}, which was denied."
|
||||
)
|
||||
if not explanation["matched_rules"]:
|
||||
return "Denied because no permission rule matched this actor and resource."
|
||||
if not explanation["rule_allowed"]:
|
||||
return (
|
||||
f"Denied by a {explanation['winning_scope']}-level rule. "
|
||||
"Deny rules take precedence over allow rules at the same scope."
|
||||
)
|
||||
if not explanation["restriction_allowed"]:
|
||||
return (
|
||||
"Denied because the resource is not included in the actor's restrictions."
|
||||
)
|
||||
return f"Allowed by the matching {explanation['winning_scope']}-level rule."
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
from typing import Optional
|
||||
from datasette.utils import MultiParams, calculate_etag
|
||||
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
|
||||
from datasette.utils.multipart import (
|
||||
parse_form_data,
|
||||
MultipartParseError,
|
||||
|
|
@ -67,13 +67,25 @@ class BadRequest(Base400):
|
|||
status = 400
|
||||
|
||||
|
||||
class PayloadTooLarge(Base400):
|
||||
status = 413
|
||||
|
||||
|
||||
SAMESITE_VALUES = ("strict", "lax", "none")
|
||||
|
||||
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
|
||||
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
|
||||
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
|
||||
# while these bodies are held in RAM and json.loads() can multiply their
|
||||
# footprint several times over.
|
||||
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
class Request:
|
||||
def __init__(self, scope, receive):
|
||||
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.max_post_body_bytes = max_post_body_bytes
|
||||
|
||||
def __repr__(self):
|
||||
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
||||
|
|
@ -141,15 +153,43 @@ class Request:
|
|||
def actor(self):
|
||||
return self.scope.get("actor", None)
|
||||
|
||||
async def post_body(self):
|
||||
body = b""
|
||||
async def post_body(self, max_bytes=None):
|
||||
"""
|
||||
Read the request body fully into memory.
|
||||
|
||||
The body is capped at max_bytes - or self.max_post_body_bytes
|
||||
(default 2MB, set from the max_post_body_bytes setting for requests
|
||||
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
|
||||
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
|
||||
oversized bodies are rejected as soon as the limit is passed, without
|
||||
buffering the rest.
|
||||
"""
|
||||
if max_bytes is None:
|
||||
max_bytes = self.max_post_body_bytes
|
||||
too_large = PayloadTooLarge(
|
||||
"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
|
||||
while more_body:
|
||||
message = await self.receive()
|
||||
assert message["type"] == "http.request", message
|
||||
body += message.get("body", b"")
|
||||
chunk = 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)
|
||||
return body
|
||||
return b"".join(chunks)
|
||||
|
||||
async def post_vars(self):
|
||||
body = await self.post_body()
|
||||
|
|
@ -397,6 +437,9 @@ async def asgi_send_file(
|
|||
)
|
||||
|
||||
|
||||
HASHED_STATIC_CACHE_CONTROL = "max-age=31536000, immutable, public"
|
||||
|
||||
|
||||
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||
root_path = Path(root_path)
|
||||
static_headers = {}
|
||||
|
|
@ -423,11 +466,17 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
|||
return
|
||||
try:
|
||||
# Calculate ETag for filepath
|
||||
hash_value = request.args.get("_hash")
|
||||
if (
|
||||
hash_value
|
||||
and hash_value == sha256_file(full_path, chunk_size=chunk_size)[:12]
|
||||
):
|
||||
headers["Cache-Control"] = HASHED_STATIC_CACHE_CONTROL
|
||||
etag = await calculate_etag(full_path, chunk_size=chunk_size)
|
||||
headers["ETag"] = etag
|
||||
if_none_match = request.headers.get("if-none-match")
|
||||
if if_none_match and if_none_match == etag:
|
||||
return await asgi_send(send, "", 304)
|
||||
return await asgi_send(send, "", 304, headers=headers)
|
||||
await asgi_send_file(
|
||||
send, full_path, chunk_size=chunk_size, headers=headers
|
||||
)
|
||||
|
|
@ -526,6 +575,18 @@ class Response:
|
|||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def error(cls, messages, status=400, headers=None):
|
||||
"""
|
||||
A JSON error response using Datasette's standard error format.
|
||||
|
||||
messages can be a single string or a list of strings. For errors
|
||||
that should content-negotiate between JSON and HTML, raise
|
||||
Forbidden, NotFound, BadRequest or DatasetteError instead and let
|
||||
Datasette's error handling hooks build the response.
|
||||
"""
|
||||
return cls.json(error_body(messages, status), status=status, headers=headers)
|
||||
|
||||
@classmethod
|
||||
def redirect(cls, path, status=302, headers=None):
|
||||
headers = headers or {}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
import textwrap
|
||||
from datasette.utils import table_column_details
|
||||
|
||||
from sqlite_utils import Database as SQLiteUtilsDatabase
|
||||
from sqlite_utils import Migrations
|
||||
|
||||
async def init_internal_db(db):
|
||||
create_tables_sql = textwrap.dedent("""
|
||||
from datasette.utils import escape_sqlite, table_column_details
|
||||
|
||||
INTERNAL_DB_SCHEMA_TABLES = {
|
||||
"catalog_databases",
|
||||
"catalog_tables",
|
||||
"catalog_views",
|
||||
"catalog_columns",
|
||||
"catalog_indexes",
|
||||
"catalog_foreign_keys",
|
||||
"metadata_instance",
|
||||
"metadata_databases",
|
||||
"metadata_resources",
|
||||
"metadata_columns",
|
||||
"column_types",
|
||||
"queries",
|
||||
}
|
||||
|
||||
INTERNAL_DB_SCHEMA_INDEXES = {
|
||||
"queries_owner_idx",
|
||||
}
|
||||
|
||||
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
|
||||
CREATE TABLE IF NOT EXISTS catalog_databases (
|
||||
database_name TEXT PRIMARY KEY,
|
||||
path TEXT,
|
||||
|
|
@ -67,99 +88,101 @@ async def init_internal_db(db):
|
|||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
||||
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_instance (
|
||||
key text,
|
||||
value text,
|
||||
unique(key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_databases (
|
||||
database_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_resources (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_columns (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
column_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, column_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS column_types (
|
||||
database_name TEXT NOT NULL,
|
||||
resource_name TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
column_type TEXT NOT NULL,
|
||||
config TEXT,
|
||||
PRIMARY KEY (database_name, resource_name, column_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queries (
|
||||
database_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sql TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
description_html TEXT,
|
||||
options TEXT NOT NULL DEFAULT '{}',
|
||||
parameters TEXT NOT NULL DEFAULT '[]',
|
||||
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
|
||||
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
|
||||
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
owner_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (database_name, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS queries_owner_idx
|
||||
ON queries(owner_id);
|
||||
""").strip()
|
||||
await db.execute_write_script(create_tables_sql)
|
||||
await initialize_metadata_tables(db)
|
||||
|
||||
|
||||
async def initialize_metadata_tables(db):
|
||||
await db.execute_write_script(textwrap.dedent("""
|
||||
CREATE TABLE IF NOT EXISTS metadata_instance (
|
||||
key text,
|
||||
value text,
|
||||
unique(key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_databases (
|
||||
database_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_resources (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_columns (
|
||||
database_name text,
|
||||
resource_name text,
|
||||
column_name text,
|
||||
key text,
|
||||
value text,
|
||||
unique(database_name, resource_name, column_name, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS column_types (
|
||||
database_name TEXT NOT NULL,
|
||||
resource_name TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
column_type TEXT NOT NULL,
|
||||
config TEXT,
|
||||
PRIMARY KEY (database_name, resource_name, column_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queries (
|
||||
database_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sql TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
description_html TEXT,
|
||||
options TEXT NOT NULL DEFAULT '{}',
|
||||
parameters TEXT NOT NULL DEFAULT '[]',
|
||||
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
|
||||
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
|
||||
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
owner_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (database_name, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS queries_owner_idx
|
||||
ON queries(owner_id);
|
||||
"""))
|
||||
internal_migrations = Migrations("datasette_internal")
|
||||
|
||||
|
||||
async def populate_schema_tables(internal_db, db):
|
||||
def _internal_schema_exists(db):
|
||||
table_names = set(db.table_names())
|
||||
if not INTERNAL_DB_SCHEMA_TABLES.issubset(table_names):
|
||||
return False
|
||||
index_names = {
|
||||
row[0]
|
||||
for row in db.execute("select name from sqlite_master where type = 'index'")
|
||||
}
|
||||
return INTERNAL_DB_SCHEMA_INDEXES.issubset(index_names)
|
||||
|
||||
|
||||
@internal_migrations(name="0001_initial")
|
||||
def initial_internal_schema(db):
|
||||
if _internal_schema_exists(db):
|
||||
return
|
||||
db.executescript(INTERNAL_DB_SCHEMA_SQL)
|
||||
|
||||
|
||||
async def init_internal_db(db):
|
||||
def apply_migrations(conn):
|
||||
internal_migrations.apply(SQLiteUtilsDatabase(conn, execute_plugins=False))
|
||||
|
||||
await db.execute_write_fn(apply_migrations, transaction=False)
|
||||
|
||||
|
||||
async def populate_schema_tables(internal_db, db, schema_version):
|
||||
database_name = db.name
|
||||
|
||||
def delete_everything(conn):
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
|
||||
[database_name],
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
|
||||
)
|
||||
|
||||
await internal_db.execute_write_fn(delete_everything)
|
||||
|
||||
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
||||
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
||||
|
||||
|
|
@ -190,7 +213,7 @@ async def populate_schema_tables(internal_db, db):
|
|||
for column in columns
|
||||
)
|
||||
foreign_keys = conn.execute(
|
||||
f"PRAGMA foreign_key_list([{table_name}])"
|
||||
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
|
||||
).fetchall()
|
||||
foreign_keys_to_insert.extend(
|
||||
{
|
||||
|
|
@ -199,7 +222,9 @@ async def populate_schema_tables(internal_db, db):
|
|||
}
|
||||
for foreign_key in foreign_keys
|
||||
)
|
||||
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
|
||||
indexes = conn.execute(
|
||||
f"PRAGMA index_list({escape_sqlite(table_name)})"
|
||||
).fetchall()
|
||||
indexes_to_insert.extend(
|
||||
{
|
||||
**{"database_name": database_name, "table_name": table_name},
|
||||
|
|
@ -223,47 +248,76 @@ async def populate_schema_tables(internal_db, db):
|
|||
indexes_to_insert,
|
||||
) = await db.execute_fn(collect_info)
|
||||
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
tables_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
views_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_columns (
|
||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||
) VALUES (
|
||||
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
||||
def replace_catalog(conn):
|
||||
# Delete child rows before their catalog_tables parents so this also
|
||||
# works if a prepare_connection plugin enables foreign key enforcement.
|
||||
for table in (
|
||||
"catalog_columns",
|
||||
"catalog_foreign_keys",
|
||||
"catalog_indexes",
|
||||
"catalog_views",
|
||||
"catalog_tables",
|
||||
):
|
||||
conn.execute(
|
||||
"DELETE FROM {} WHERE database_name = ?".format(table),
|
||||
[database_name],
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO catalog_databases (
|
||||
database_name, path, is_memory, schema_version
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
database_name,
|
||||
str(db.path) if db.path is not None else None,
|
||||
db.is_memory,
|
||||
schema_version,
|
||||
],
|
||||
)
|
||||
""",
|
||||
columns_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_foreign_keys (
|
||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||
) VALUES (
|
||||
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
tables_to_insert,
|
||||
)
|
||||
""",
|
||||
foreign_keys_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_indexes (
|
||||
database_name, table_name, seq, name, "unique", origin, partial
|
||||
) VALUES (
|
||||
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
views_to_insert,
|
||||
)
|
||||
""",
|
||||
indexes_to_insert,
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_columns (
|
||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||
) VALUES (
|
||||
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
||||
)
|
||||
""",
|
||||
columns_to_insert,
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_foreign_keys (
|
||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||
) VALUES (
|
||||
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
||||
)
|
||||
""",
|
||||
foreign_keys_to_insert,
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_indexes (
|
||||
database_name, table_name, seq, name, "unique", origin, partial
|
||||
) VALUES (
|
||||
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
||||
)
|
||||
""",
|
||||
indexes_to_insert,
|
||||
)
|
||||
|
||||
await internal_db.execute_write_fn(replace_catalog)
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ _SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
|
|||
"sqlite_rename_test",
|
||||
"substr",
|
||||
}
|
||||
|
||||
_AUTHORIZER_ACTION_NAMES = {
|
||||
getattr(sqlite3, name): name
|
||||
for name in (
|
||||
|
|
@ -391,6 +390,10 @@ def analyze_sql_tables(
|
|||
)
|
||||
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",
|
||||
|
|
@ -485,17 +488,17 @@ def analyze_sql_tables(
|
|||
and key.operation in {"create", "alter", "drop"}
|
||||
for key in operations
|
||||
)
|
||||
dropped_tables = {
|
||||
dropped_tables_and_views = {
|
||||
(key.database, key.table)
|
||||
for key in operations
|
||||
if key.operation == "drop" and key.target_type == "table"
|
||||
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 (key.database, key.table) in dropped_tables_and_views
|
||||
)
|
||||
|
||||
has_user_table_access_in_schema_operation = any(
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
__version__ = "1.0a34"
|
||||
__version__ = "1.0a37"
|
||||
__version_info__ = tuple(__version__.split("."))
|
||||
|
|
|
|||
|
|
@ -1,2 +1,89 @@
|
|||
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:
|
||||
"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,31 +1,19 @@
|
|||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
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 import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
EscapeHtmlWriter,
|
||||
InvalidSql,
|
||||
LimitedWriter,
|
||||
call_with_supported_arguments,
|
||||
path_from_row_pks,
|
||||
path_with_added_args,
|
||||
path_with_removed_args,
|
||||
path_with_format,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import (
|
||||
AsgiStream,
|
||||
NotFound,
|
||||
Response,
|
||||
BadRequest,
|
||||
)
|
||||
|
|
@ -40,12 +28,15 @@ class DatasetteError(Exception):
|
|||
status=500,
|
||||
template=None,
|
||||
message_is_html=False,
|
||||
plain_message=None,
|
||||
):
|
||||
self.message = message
|
||||
self.title = title
|
||||
self.error_dict = error_dict or {}
|
||||
self.status = status
|
||||
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:
|
||||
|
|
@ -61,9 +52,7 @@ class View:
|
|||
request.path.endswith(".json")
|
||||
or request.headers.get("content-type") == "application/json"
|
||||
):
|
||||
response = Response.json(
|
||||
{"ok": False, "error": "Method not allowed"}, status=405
|
||||
)
|
||||
response = Response.error("Method not allowed", 405)
|
||||
else:
|
||||
response = Response.text("Method not allowed", status=405)
|
||||
return response
|
||||
|
|
@ -102,9 +91,7 @@ class BaseView:
|
|||
request.path.endswith(".json")
|
||||
or request.headers.get("content-type") == "application/json"
|
||||
):
|
||||
response = Response.json(
|
||||
{"ok": False, "error": "Method not allowed"}, status=405
|
||||
)
|
||||
response = Response.error("Method not allowed", 405)
|
||||
else:
|
||||
response = Response.text("Method not allowed", status=405)
|
||||
return response
|
||||
|
|
@ -192,231 +179,6 @@ class BaseView:
|
|||
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,
|
||||
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,
|
||||
**{
|
||||
"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):
|
||||
kwargs = {}
|
||||
stream = request.args.get("_stream")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
|
@ -6,19 +6,17 @@ import itertools
|
|||
import json
|
||||
import markupsafe
|
||||
import os
|
||||
import re
|
||||
import sqlite_utils
|
||||
import textwrap
|
||||
|
||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.extras import extra_names_from_request, ExtraScope
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.resources import DatabaseResource, QueryResource
|
||||
from datasette.stored_queries import stored_query_to_dict
|
||||
from datasette.stored_queries import StoredQuery, stored_query_to_dict
|
||||
from datasette.write_sql import QueryWriteRejected
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
error_body,
|
||||
call_with_supported_arguments,
|
||||
named_parameters as derive_named_parameters,
|
||||
format_bytes,
|
||||
|
|
@ -37,16 +35,40 @@ from datasette.utils import (
|
|||
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
||||
from datasette.plugins import pm
|
||||
|
||||
from .base import BaseView, DatasetteError, View, _error, stream_csv
|
||||
from .base import DatasetteError, View, stream_csv
|
||||
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
||||
from .table_extras import (
|
||||
QueryExtraContext,
|
||||
resolve_query_extras,
|
||||
table_extra_registry,
|
||||
)
|
||||
from .table_create_alter import _create_table_ui_context
|
||||
from . import Context
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseTable:
|
||||
"Summary of a table or view shown on database and query pages."
|
||||
|
||||
name: str
|
||||
columns: list[str]
|
||||
primary_keys: list[str]
|
||||
count: int | None
|
||||
count_truncated: bool
|
||||
hidden: bool
|
||||
fts_table: str | None
|
||||
foreign_keys: dict[str, list[dict[str, str]]]
|
||||
private: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseViewInfo:
|
||||
"Summary of a SQLite view shown on the database page."
|
||||
|
||||
name: str
|
||||
private: bool
|
||||
|
||||
|
||||
class DatabaseView(View):
|
||||
async def get(self, request, datasette):
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
|
@ -96,7 +118,7 @@ class DatabaseView(View):
|
|||
# Filter to just views
|
||||
view_names_set = set(await db.view_names())
|
||||
sql_views = [
|
||||
{"name": name, "private": allowed_dict[name].private}
|
||||
DatabaseViewInfo(name=name, private=allowed_dict[name].private)
|
||||
for name in allowed_dict
|
||||
if name in view_names_set
|
||||
]
|
||||
|
|
@ -117,21 +139,36 @@ class DatabaseView(View):
|
|||
else len(stored_queries)
|
||||
)
|
||||
|
||||
# Resolve the registered database-level actions for this database in
|
||||
# one batched query, seeding the request permission cache so allowed()
|
||||
# calls made inside plugin hooks below are served from the cache.
|
||||
database_action_permissions = await datasette.allowed_many(
|
||||
actions=[
|
||||
name
|
||||
for name, action in datasette.actions.items()
|
||||
if action.resource_class is DatabaseResource
|
||||
],
|
||||
resource=DatabaseResource(database),
|
||||
actor=request.actor,
|
||||
)
|
||||
create_table_ui = await _create_table_ui_context(
|
||||
datasette, request, db, database, database_action_permissions
|
||||
)
|
||||
|
||||
async def database_actions():
|
||||
# Resolve the registered database-level actions for this
|
||||
# database in one batched query, seeding the request permission
|
||||
# cache so that allowed() calls made inside the plugin hooks
|
||||
# below are served from the cache
|
||||
await datasette.allowed_many(
|
||||
actions=[
|
||||
name
|
||||
for name, action in datasette.actions.items()
|
||||
if action.resource_class is DatabaseResource
|
||||
],
|
||||
resource=DatabaseResource(database),
|
||||
actor=request.actor,
|
||||
)
|
||||
links = []
|
||||
if create_table_ui:
|
||||
links.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Create table",
|
||||
"description": "Create a new table in this database.",
|
||||
"attrs": {
|
||||
"aria-label": "Create table in {}".format(database),
|
||||
"data-database-action": "create-table",
|
||||
},
|
||||
}
|
||||
)
|
||||
for hook in pm.hook.database_actions(
|
||||
datasette=datasette,
|
||||
database=database,
|
||||
|
|
@ -156,9 +193,9 @@ class DatabaseView(View):
|
|||
"private": private,
|
||||
"path": datasette.urls.database(database),
|
||||
"size": db.size,
|
||||
"tables": tables,
|
||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
||||
"views": sql_views,
|
||||
"tables": [asdict(table) for table in tables],
|
||||
"hidden_count": len([table for table in tables if table.hidden]),
|
||||
"views": [asdict(view) for view in sql_views],
|
||||
"queries": [stored_query_to_dict(query) for query in stored_queries],
|
||||
"queries_more": queries_more,
|
||||
"queries_count": queries_count,
|
||||
|
|
@ -198,7 +235,7 @@ class DatabaseView(View):
|
|||
path=datasette.urls.database(database),
|
||||
size=db.size,
|
||||
tables=tables,
|
||||
hidden_count=len([t for t in tables if t["hidden"]]),
|
||||
hidden_count=len([table for table in tables if table.hidden]),
|
||||
views=sql_views,
|
||||
queries=stored_queries,
|
||||
queries_more=queries_more,
|
||||
|
|
@ -211,10 +248,12 @@ class DatabaseView(View):
|
|||
),
|
||||
metadata=metadata,
|
||||
database_color=db.color,
|
||||
database_page_data=(
|
||||
{"createTable": create_table_ui} if create_table_ui else {}
|
||||
),
|
||||
database_actions=database_actions,
|
||||
show_hidden=request.args.get("_show_hidden"),
|
||||
editable=True,
|
||||
count_limit=db.count_limit,
|
||||
allow_download=datasette.setting("allow_download")
|
||||
and not db.is_mutable
|
||||
and not db.is_memory,
|
||||
|
|
@ -241,16 +280,32 @@ class DatabaseView(View):
|
|||
|
||||
@dataclass
|
||||
class DatabaseContext(Context):
|
||||
"The page listing the tables, views and queries in a database, e.g. /fixtures."
|
||||
|
||||
documented_template = "database.html"
|
||||
|
||||
database: str = field(metadata={"help": "The name of the database"})
|
||||
private: bool = field(
|
||||
metadata={"help": "Boolean indicating if this is a private database"}
|
||||
)
|
||||
path: str = field(metadata={"help": "The URL path to this database"})
|
||||
size: int = field(metadata={"help": "The size of the database in bytes"})
|
||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||
tables: list[DatabaseTable] = field(
|
||||
metadata={
|
||||
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
}
|
||||
)
|
||||
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
||||
views: list = field(metadata={"help": "List of view objects in the database"})
|
||||
queries: list = field(metadata={"help": "List of stored query objects"})
|
||||
views: list[DatabaseViewInfo] = field(
|
||||
metadata={
|
||||
"help": "List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes."
|
||||
}
|
||||
)
|
||||
queries: list[StoredQuery] = field(
|
||||
metadata={
|
||||
"help": "List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``."
|
||||
}
|
||||
)
|
||||
queries_more: bool = field(
|
||||
metadata={"help": "Boolean indicating if more stored queries are available"}
|
||||
)
|
||||
|
|
@ -259,45 +314,65 @@ class DatabaseContext(Context):
|
|||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||
)
|
||||
table_columns: dict = field(
|
||||
metadata={"help": "Dictionary mapping table names to their column lists"}
|
||||
metadata={
|
||||
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
||||
}
|
||||
)
|
||||
metadata: dict = field(
|
||||
metadata={
|
||||
"help": "Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata."
|
||||
}
|
||||
)
|
||||
metadata: dict = field(metadata={"help": "Metadata for the database"})
|
||||
database_color: str = field(metadata={"help": "The color assigned to the database"})
|
||||
database_page_data: dict = field(
|
||||
metadata={
|
||||
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
|
||||
}
|
||||
)
|
||||
database_actions: callable = field(
|
||||
metadata={
|
||||
"help": "Callable returning list of action links for the database menu"
|
||||
"help": 'Async callable returning action items for the database menu. 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_database_actions`.'
|
||||
}
|
||||
)
|
||||
show_hidden: str = field(metadata={"help": "Value of _show_hidden query parameter"})
|
||||
editable: bool = field(
|
||||
metadata={"help": "Boolean indicating if the database is editable"}
|
||||
)
|
||||
count_limit: int = field(metadata={"help": "The maximum number of rows to count"})
|
||||
allow_download: bool = field(
|
||||
metadata={"help": "Boolean indicating if database download is allowed"}
|
||||
)
|
||||
attached_databases: list = field(
|
||||
metadata={"help": "List of names of attached databases"}
|
||||
metadata={
|
||||
"help": "List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`."
|
||||
}
|
||||
)
|
||||
alternate_url_json: str = field(
|
||||
metadata={"help": "URL for the alternate JSON version of this page"}
|
||||
)
|
||||
select_templates: list = field(
|
||||
metadata={
|
||||
"help": "List of templates that were considered for rendering this page"
|
||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||
}
|
||||
)
|
||||
top_database: callable = field(
|
||||
metadata={"help": "Callable to render the top_database slot"}
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_database`` plugin slot for this database and returns HTML."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryContext(Context):
|
||||
"The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name)."
|
||||
|
||||
documented_template = "query.html"
|
||||
|
||||
database: str = field(metadata={"help": "The name of the database being queried"})
|
||||
database_color: str = field(metadata={"help": "The color of the database"})
|
||||
query: dict = field(
|
||||
metadata={"help": "The SQL query object containing the `sql` string"}
|
||||
metadata={
|
||||
"help": "Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys."
|
||||
}
|
||||
)
|
||||
stored_query: str = field(
|
||||
metadata={"help": "The name of the stored query if this is a stored query"}
|
||||
|
|
@ -314,7 +389,9 @@ class QueryContext(Context):
|
|||
}
|
||||
)
|
||||
metadata: dict = field(
|
||||
metadata={"help": "Metadata about the database or the stored query"}
|
||||
metadata={
|
||||
"help": "Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``."
|
||||
}
|
||||
)
|
||||
db_is_immutable: bool = field(
|
||||
metadata={"help": "Boolean indicating if this database is immutable"}
|
||||
|
|
@ -338,22 +415,44 @@ class QueryContext(Context):
|
|||
save_query_url: str = field(
|
||||
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
||||
)
|
||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||
tables: list[DatabaseTable] = field(
|
||||
metadata={
|
||||
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
}
|
||||
)
|
||||
named_parameter_values: dict = field(
|
||||
metadata={"help": "Dictionary of parameter names/values"}
|
||||
metadata={
|
||||
"help": "Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``."
|
||||
}
|
||||
)
|
||||
edit_sql_url: str = field(
|
||||
metadata={"help": "URL to edit the SQL for a stored query"}
|
||||
)
|
||||
display_rows: list = field(metadata={"help": "List of result rows to display"})
|
||||
columns: list = field(metadata={"help": "List of column names"})
|
||||
renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"})
|
||||
display_rows: list = field(
|
||||
metadata={
|
||||
"help": "List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``."
|
||||
}
|
||||
)
|
||||
columns: list = field(
|
||||
metadata={
|
||||
"help": "List of result column names in the order they appear in ``display_rows`` and ``rows``."
|
||||
}
|
||||
)
|
||||
renderers: dict = field(
|
||||
metadata={
|
||||
"help": "Dictionary mapping output format names such as ``json`` to URLs for this query in that format."
|
||||
}
|
||||
)
|
||||
url_csv: str = field(metadata={"help": "URL for CSV export"})
|
||||
show_hide_hidden: str = field(
|
||||
metadata={"help": "Hidden input field for the _show_sql parameter"}
|
||||
metadata={
|
||||
"help": "Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state."
|
||||
}
|
||||
)
|
||||
table_columns: dict = field(
|
||||
metadata={"help": "Dictionary of table name to list of column names"}
|
||||
metadata={
|
||||
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
||||
}
|
||||
)
|
||||
alternate_url_json: str = field(
|
||||
metadata={"help": "URL for alternate JSON version of this page"}
|
||||
|
|
@ -361,23 +460,27 @@ class QueryContext(Context):
|
|||
# TODO: refactor this to somewhere else, probably ds.render_template()
|
||||
select_templates: list = field(
|
||||
metadata={
|
||||
"help": "List of templates that were considered for rendering this page"
|
||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||
}
|
||||
)
|
||||
top_query: callable = field(
|
||||
metadata={"help": "Callable to render the top_query slot"}
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_query`` plugin slot for this query and returns HTML."
|
||||
}
|
||||
)
|
||||
top_stored_query: callable = field(
|
||||
metadata={"help": "Callable to render the top_stored_query slot"}
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML."
|
||||
}
|
||||
)
|
||||
query_actions: callable = field(
|
||||
metadata={
|
||||
"help": "Callable returning a list of links for the query action menu"
|
||||
"help": 'Async callable returning action items for the query menu. 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_query_actions`.'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def get_tables(datasette, request, db, allowed_dict):
|
||||
async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable]:
|
||||
"""
|
||||
Get list of tables with metadata for the database view.
|
||||
|
||||
|
|
@ -398,21 +501,36 @@ async def get_tables(datasette, request, db, allowed_dict):
|
|||
|
||||
table_columns = await db.table_columns(table)
|
||||
tables.append(
|
||||
{
|
||||
"name": table,
|
||||
"columns": table_columns,
|
||||
"primary_keys": await db.primary_keys(table),
|
||||
"count": table_counts[table],
|
||||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"foreign_keys": all_foreign_keys[table],
|
||||
"private": allowed_dict[table].private,
|
||||
}
|
||||
DatabaseTable(
|
||||
name=table,
|
||||
columns=table_columns,
|
||||
primary_keys=await db.primary_keys(table),
|
||||
count=table_counts[table],
|
||||
count_truncated=_table_count_truncated(
|
||||
datasette, db, table, table_counts[table]
|
||||
),
|
||||
hidden=table in hidden_table_names,
|
||||
fts_table=await db.fts_table(table),
|
||||
foreign_keys=all_foreign_keys[table],
|
||||
private=allowed_dict[table].private,
|
||||
)
|
||||
)
|
||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
||||
tables.sort(key=lambda table: (table.hidden, table.name))
|
||||
return tables
|
||||
|
||||
|
||||
def _table_count_truncated(datasette, db, table, count):
|
||||
if count != db.count_limit + 1:
|
||||
return False
|
||||
if not db.is_mutable and datasette.inspect_data:
|
||||
try:
|
||||
datasette.inspect_data[db.name]["tables"][table]["count"]
|
||||
return False
|
||||
except KeyError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
async def database_download(request, datasette):
|
||||
from datasette.resources import DatabaseResource
|
||||
|
||||
|
|
@ -490,11 +608,7 @@ class QueryView(View):
|
|||
"_json"
|
||||
):
|
||||
return Response.json(
|
||||
{
|
||||
"ok": False,
|
||||
"message": ex.message,
|
||||
"redirect": None,
|
||||
},
|
||||
dict(error_body([ex.message], 403), redirect=None),
|
||||
status=403,
|
||||
)
|
||||
datasette.add_message(request, ex.message, datasette.ERROR)
|
||||
|
|
@ -529,8 +643,15 @@ class QueryView(View):
|
|||
ok = None
|
||||
redirect_url = None
|
||||
try:
|
||||
execute_write_kwargs = {"request": request}
|
||||
if stored_query.is_trusted:
|
||||
analysis = await db.analyze_sql(stored_query.sql, params_for_query)
|
||||
if any(
|
||||
operation.operation == "vacuum" for operation in analysis.operations
|
||||
):
|
||||
execute_write_kwargs["transaction"] = False
|
||||
cursor = await db.execute_write(
|
||||
stored_query.sql, params_for_query, request=request
|
||||
stored_query.sql, params_for_query, **execute_write_kwargs
|
||||
)
|
||||
# success message can come from on_success_message or on_success_message_sql
|
||||
message = None
|
||||
|
|
@ -564,12 +685,17 @@ class QueryView(View):
|
|||
redirect_url = stored_query.on_error_redirect
|
||||
ok = False
|
||||
if should_return_json:
|
||||
if ok:
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"message": message,
|
||||
"redirect": redirect_url,
|
||||
}
|
||||
)
|
||||
return Response.json(
|
||||
{
|
||||
"ok": ok,
|
||||
"message": message,
|
||||
"redirect": redirect_url,
|
||||
}
|
||||
dict(error_body([message], 400), redirect=redirect_url),
|
||||
status=400,
|
||||
)
|
||||
else:
|
||||
datasette.add_message(request, message, message_type)
|
||||
|
|
@ -700,6 +826,10 @@ class QueryView(View):
|
|||
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.DatabaseError as ex:
|
||||
query_error = str(ex)
|
||||
|
|
@ -732,8 +862,11 @@ class QueryView(View):
|
|||
|
||||
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
||||
elif format_ in datasette.renderers.keys():
|
||||
if not sql:
|
||||
raise DatasetteError("?sql= is required", status=400)
|
||||
data = {"ok": True, "rows": rows, "columns": columns}
|
||||
extras = extra_names_from_request(request)
|
||||
table_extra_registry.validate_requested(extras, ExtraScope.QUERY)
|
||||
if extras:
|
||||
query_extra_context = QueryExtraContext(
|
||||
datasette=datasette,
|
||||
|
|
@ -1055,260 +1188,6 @@ class MagicParameters(dict):
|
|||
return super().__getitem__(key)
|
||||
|
||||
|
||||
class TableCreateView(BaseView):
|
||||
name = "table-create"
|
||||
|
||||
_valid_keys = {
|
||||
"table",
|
||||
"rows",
|
||||
"row",
|
||||
"columns",
|
||||
"pk",
|
||||
"pks",
|
||||
"ignore",
|
||||
"replace",
|
||||
"alter",
|
||||
}
|
||||
_supported_column_types = {
|
||||
"text",
|
||||
"integer",
|
||||
"float",
|
||||
"blob",
|
||||
}
|
||||
# Any string that does not contain a newline or start with sqlite_
|
||||
_table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$")
|
||||
|
||||
def __init__(self, datasette):
|
||||
self.ds = datasette
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
database_name = db.name
|
||||
|
||||
# Must have create-table permission
|
||||
if not await self.ds.allowed(
|
||||
action="create-table",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _error(["Invalid JSON: {}".format(e)])
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _error(["JSON must be an object"])
|
||||
|
||||
invalid_keys = set(data.keys()) - self._valid_keys
|
||||
if invalid_keys:
|
||||
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
||||
|
||||
# ignore and replace are mutually exclusive
|
||||
if data.get("ignore") and data.get("replace"):
|
||||
return _error(["ignore and replace are mutually exclusive"])
|
||||
|
||||
# ignore and replace only allowed with row or rows
|
||||
if "ignore" in data or "replace" in data:
|
||||
if not data.get("row") and not data.get("rows"):
|
||||
return _error(["ignore and replace require row or rows"])
|
||||
|
||||
# ignore and replace require pk or pks
|
||||
if "ignore" in data or "replace" in data:
|
||||
if not data.get("pk") and not data.get("pks"):
|
||||
return _error(["ignore and replace require pk or pks"])
|
||||
|
||||
ignore = data.get("ignore")
|
||||
replace = data.get("replace")
|
||||
|
||||
if replace:
|
||||
# Must have update-row permission
|
||||
if not await self.ds.allowed(
|
||||
action="update-row",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need update-row"], 403)
|
||||
|
||||
table_name = data.get("table")
|
||||
if not table_name:
|
||||
return _error(["Table is required"])
|
||||
|
||||
if not self._table_name_re.match(table_name):
|
||||
return _error(["Invalid table name"])
|
||||
|
||||
table_exists = await db.table_exists(data["table"])
|
||||
columns = data.get("columns")
|
||||
rows = data.get("rows")
|
||||
row = data.get("row")
|
||||
if not columns and not rows and not row:
|
||||
return _error(["columns, rows or row is required"])
|
||||
|
||||
if rows and row:
|
||||
return _error(["Cannot specify both rows and row"])
|
||||
|
||||
if rows or row:
|
||||
# Must have insert-row permission
|
||||
if not await self.ds.allowed(
|
||||
action="insert-row",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need insert-row"], 403)
|
||||
|
||||
alter = False
|
||||
if rows or row:
|
||||
if not table_exists:
|
||||
# if table is being created for the first time, alter=True
|
||||
alter = True
|
||||
else:
|
||||
# alter=True only if they request it AND they have permission
|
||||
if data.get("alter"):
|
||||
if not await self.ds.allowed(
|
||||
action="alter-table",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need alter-table"], 403)
|
||||
alter = True
|
||||
|
||||
if columns:
|
||||
if rows or row:
|
||||
return _error(["Cannot specify columns with rows or row"])
|
||||
if not isinstance(columns, list):
|
||||
return _error(["columns must be a list"])
|
||||
for column in columns:
|
||||
if not isinstance(column, dict):
|
||||
return _error(["columns must be a list of objects"])
|
||||
if not column.get("name") or not isinstance(column.get("name"), str):
|
||||
return _error(["Column name is required"])
|
||||
if not column.get("type"):
|
||||
column["type"] = "text"
|
||||
if column["type"] not in self._supported_column_types:
|
||||
return _error(
|
||||
["Unsupported column type: {}".format(column["type"])]
|
||||
)
|
||||
# No duplicate column names
|
||||
dupes = {c["name"] for c in columns if columns.count(c) > 1}
|
||||
if dupes:
|
||||
return _error(["Duplicate column name: {}".format(", ".join(dupes))])
|
||||
|
||||
if row:
|
||||
rows = [row]
|
||||
|
||||
if rows:
|
||||
if not isinstance(rows, list):
|
||||
return _error(["rows must be a list"])
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
return _error(["rows must be a list of objects"])
|
||||
|
||||
pk = data.get("pk")
|
||||
pks = data.get("pks")
|
||||
|
||||
if pk and pks:
|
||||
return _error(["Cannot specify both pk and pks"])
|
||||
if pk:
|
||||
if not isinstance(pk, str):
|
||||
return _error(["pk must be a string"])
|
||||
if pks:
|
||||
if not isinstance(pks, list):
|
||||
return _error(["pks must be a list"])
|
||||
for pk in pks:
|
||||
if not isinstance(pk, str):
|
||||
return _error(["pks must be a list of strings"])
|
||||
|
||||
# If table exists already, read pks from that instead
|
||||
if table_exists:
|
||||
actual_pks = await db.primary_keys(table_name)
|
||||
# if pk passed and table already exists check it does not change
|
||||
bad_pks = False
|
||||
if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]:
|
||||
bad_pks = True
|
||||
elif (
|
||||
len(actual_pks) > 1
|
||||
and data.get("pks")
|
||||
and set(data["pks"]) != set(actual_pks)
|
||||
):
|
||||
bad_pks = True
|
||||
if bad_pks:
|
||||
return _error(["pk cannot be changed for existing table"])
|
||||
pks = actual_pks
|
||||
|
||||
initial_schema = None
|
||||
if table_exists:
|
||||
initial_schema = await db.execute_fn(
|
||||
lambda conn: sqlite_utils.Database(conn)[table_name].schema
|
||||
)
|
||||
|
||||
def create_table(conn):
|
||||
table = sqlite_utils.Database(conn)[table_name]
|
||||
if rows:
|
||||
table.insert_all(
|
||||
rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter
|
||||
)
|
||||
else:
|
||||
table.create(
|
||||
{c["name"]: c["type"] for c in columns},
|
||||
pk=pks or pk,
|
||||
)
|
||||
return table.schema
|
||||
|
||||
try:
|
||||
schema = await db.execute_write_fn(create_table, request=request)
|
||||
except Exception as e:
|
||||
return _error([str(e)])
|
||||
|
||||
if initial_schema is not None and initial_schema != schema:
|
||||
await self.ds.track_event(
|
||||
AlterTableEvent(
|
||||
request.actor,
|
||||
database=database_name,
|
||||
table=table_name,
|
||||
before_schema=initial_schema,
|
||||
after_schema=schema,
|
||||
)
|
||||
)
|
||||
|
||||
table_url = self.ds.absolute_url(
|
||||
request, self.ds.urls.table(db.name, table_name)
|
||||
)
|
||||
table_api_url = self.ds.absolute_url(
|
||||
request, self.ds.urls.table(db.name, table_name, format="json")
|
||||
)
|
||||
details = {
|
||||
"ok": True,
|
||||
"database": db.name,
|
||||
"table": table_name,
|
||||
"table_url": table_url,
|
||||
"table_api_url": table_api_url,
|
||||
"schema": schema,
|
||||
}
|
||||
if rows:
|
||||
details["row_count"] = len(rows)
|
||||
|
||||
if not table_exists:
|
||||
# Only log creation if we created a table
|
||||
await self.ds.track_event(
|
||||
CreateTableEvent(
|
||||
request.actor, database=db.name, table=table_name, schema=schema
|
||||
)
|
||||
)
|
||||
if rows:
|
||||
await self.ds.track_event(
|
||||
InsertRowsEvent(
|
||||
request.actor,
|
||||
database=db.name,
|
||||
table=table_name,
|
||||
num_rows=len(rows),
|
||||
ignore=ignore,
|
||||
replace=replace,
|
||||
)
|
||||
)
|
||||
return Response.json(details, status=201)
|
||||
|
||||
|
||||
async def display_rows(datasette, database, request, rows, columns):
|
||||
display_rows = []
|
||||
truncate_cells = datasette.setting("truncate_cells_html")
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import re
|
|||
from urllib.parse import urlencode
|
||||
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.utils import sqlite3
|
||||
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
from .base import BaseView, _error
|
||||
from .base import BaseView
|
||||
from .database import display_rows as display_query_rows
|
||||
from .query_helpers import (
|
||||
QueryValidationError,
|
||||
|
|
@ -31,6 +31,15 @@ WRITE_TEMPLATE_LABELS = {
|
|||
"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):
|
||||
|
|
@ -207,6 +216,23 @@ def _write_template_operations(write_template_tables):
|
|||
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
|
||||
|
|
@ -241,6 +267,9 @@ class ExecuteWriteView(BaseView):
|
|||
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)
|
||||
|
|
@ -302,6 +331,7 @@ class ExecuteWriteView(BaseView):
|
|||
"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,
|
||||
},
|
||||
|
|
@ -318,7 +348,7 @@ class ExecuteWriteView(BaseView):
|
|||
)
|
||||
if not db.is_mutable:
|
||||
return _block_framing(
|
||||
_error(
|
||||
Response.error(
|
||||
["Cannot execute write SQL because this database is immutable."],
|
||||
403,
|
||||
)
|
||||
|
|
@ -337,10 +367,10 @@ class ExecuteWriteView(BaseView):
|
|||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(
|
||||
_error(["Permission denied: need execute-write-sql"], 403)
|
||||
Response.error(["Permission denied: need execute-write-sql"], 403)
|
||||
)
|
||||
if not db.is_mutable:
|
||||
return _block_framing(_error(["Database is immutable"], 403))
|
||||
return _block_framing(Response.error(["Database is immutable"], 403))
|
||||
|
||||
data = {}
|
||||
is_json = request.headers.get("content-type", "").startswith("application/json")
|
||||
|
|
@ -354,7 +384,7 @@ class ExecuteWriteView(BaseView):
|
|||
)
|
||||
except QueryValidationError as ex:
|
||||
if _wants_json(request, is_json, data):
|
||||
return _block_framing(_error([ex.message], ex.status))
|
||||
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(
|
||||
|
|
@ -375,7 +405,7 @@ class ExecuteWriteView(BaseView):
|
|||
except sqlite3.DatabaseError as ex:
|
||||
message = str(ex)
|
||||
if wants_json:
|
||||
return _block_framing(_error([message], 400))
|
||||
return _block_framing(Response.error([message], 400))
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
|
|
@ -387,6 +417,9 @@ class ExecuteWriteView(BaseView):
|
|||
status=400,
|
||||
)
|
||||
|
||||
if _analysis_changes_schema(analysis):
|
||||
await self.ds.refresh_schemas(force=True)
|
||||
|
||||
if cursor.rowcount == -1:
|
||||
message = "Query executed"
|
||||
else:
|
||||
|
|
@ -455,20 +488,18 @@ class ExecuteWriteAnalyzeView(BaseView):
|
|||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(
|
||||
_error(["Permission denied: need execute-write-sql"], 403)
|
||||
Response.error(["Permission denied: need execute-write-sql"], 403)
|
||||
)
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
_error(
|
||||
Response.error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
||||
)
|
||||
)
|
||||
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
||||
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
||||
return _block_framing(Response.json(analysis))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from datasette.utils import (
|
|||
await_me_maybe,
|
||||
make_slot_function,
|
||||
CustomJSONEncoder,
|
||||
UNSTABLE_API_MESSAGE,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
from datasette.version import __version__
|
||||
|
|
@ -151,7 +152,9 @@ class IndexView(BaseView):
|
|||
return Response(
|
||||
json.dumps(
|
||||
{
|
||||
"databases": {db["name"]: db for db in databases},
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"databases": databases,
|
||||
"metadata": await self.ds.get_instance_metadata(),
|
||||
},
|
||||
cls=CustomJSONEncoder,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from datasette.write_sql import (
|
|||
operation_is_write,
|
||||
)
|
||||
from datasette.utils import (
|
||||
parse_size_limit,
|
||||
named_parameters as derive_named_parameters,
|
||||
escape_sqlite,
|
||||
path_from_row_pks,
|
||||
|
|
@ -32,7 +33,6 @@ _query_fields = {
|
|||
"hide_sql",
|
||||
"fragment",
|
||||
"parameters",
|
||||
"params",
|
||||
"is_private",
|
||||
"on_success_message",
|
||||
"on_success_redirect",
|
||||
|
|
@ -94,13 +94,11 @@ def _as_optional_bool(value, name):
|
|||
raise QueryValidationError("{} must be 0 or 1".format(name))
|
||||
|
||||
|
||||
def _query_list_limit(value, default=50):
|
||||
if value in (None, ""):
|
||||
return default
|
||||
def _query_list_limit(value, default, maximum):
|
||||
try:
|
||||
return min(max(1, int(value)), 1000)
|
||||
return parse_size_limit(value, default, maximum)
|
||||
except ValueError as ex:
|
||||
raise QueryValidationError("_size must be an integer") from ex
|
||||
raise QueryValidationError(str(ex)) from ex
|
||||
|
||||
|
||||
def _derived_query_parameters(sql):
|
||||
|
|
@ -541,7 +539,7 @@ async def _prepare_query_create(datasette, request, db, data):
|
|||
raise QueryValidationError("Writable query fields require writable SQL")
|
||||
|
||||
parameters = _coerce_query_parameters(
|
||||
data.get("parameters", data.get("params")),
|
||||
data.get("parameters"),
|
||||
derived,
|
||||
)
|
||||
return {
|
||||
|
|
@ -586,9 +584,9 @@ async def _prepare_query_update(datasette, request, db, existing: StoredQuery, u
|
|||
actor=request.actor,
|
||||
)
|
||||
|
||||
if "parameters" in update or "params" in update:
|
||||
if "parameters" in update:
|
||||
parameters = _coerce_query_parameters(
|
||||
update.get("parameters", update.get("params")),
|
||||
update.get("parameters"),
|
||||
derived,
|
||||
)
|
||||
elif "sql" in update:
|
||||
|
|
|
|||
|
|
@ -1,21 +1,38 @@
|
|||
from datasette.utils.asgi import NotFound, Forbidden, Response
|
||||
import asyncio
|
||||
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.events import UpdateRowEvent, DeleteRowEvent
|
||||
from datasette.resources import TableResource
|
||||
from .base import DataView, BaseView, _error
|
||||
from .base import BaseView, DatasetteError, stream_csv
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
call_with_supported_arguments,
|
||||
CustomJSONEncoder,
|
||||
CustomRow,
|
||||
decode_write_json_row,
|
||||
InvalidSql,
|
||||
make_slot_function,
|
||||
path_from_row_pks,
|
||||
path_with_format,
|
||||
path_with_removed_args,
|
||||
to_css_class,
|
||||
escape_sqlite,
|
||||
sqlite3,
|
||||
WriteJsonValueError,
|
||||
)
|
||||
from datasette.plugins import pm
|
||||
import json
|
||||
import markupsafe
|
||||
import sqlite_utils
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.extras import extra_names_from_request, ExtraScope
|
||||
from . import Context, from_extra
|
||||
from .table import (
|
||||
display_columns_and_rows,
|
||||
_table_page_data,
|
||||
|
|
@ -24,9 +41,358 @@ from .table import (
|
|||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
||||
|
||||
|
||||
class RowView(DataView):
|
||||
@dataclass
|
||||
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"
|
||||
|
||||
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):
|
||||
resolved = await self.ds.resolve_row(request)
|
||||
db = resolved.db
|
||||
|
|
@ -205,13 +571,14 @@ class RowView(DataView):
|
|||
],
|
||||
"row_mutation_ui": any(row_action_permissions.values()),
|
||||
"table_page_data": await _table_page_data(
|
||||
self.ds,
|
||||
request,
|
||||
db,
|
||||
database,
|
||||
table,
|
||||
not is_table,
|
||||
None,
|
||||
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,
|
||||
"top_row": make_slot_function(
|
||||
|
|
@ -236,6 +603,9 @@ class RowView(DataView):
|
|||
}
|
||||
|
||||
extras = extra_names_from_request(request)
|
||||
if request.url_vars.get("format"):
|
||||
# Data formats reject unknown extras; HTML ignores them
|
||||
table_extra_registry.validate_requested(extras, ExtraScope.ROW)
|
||||
|
||||
# Process extras
|
||||
row_extra_context = RowExtraContext(
|
||||
|
|
@ -345,11 +715,13 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
|
|||
try:
|
||||
resolved = await datasette.resolve_row(request)
|
||||
except DatabaseNotFound as e:
|
||||
return False, _error(["Database not found: {}".format(e.database_name)], 404)
|
||||
return False, Response.error(
|
||||
["Database not found: {}".format(e.database_name)], 404
|
||||
)
|
||||
except TableNotFound as e:
|
||||
return False, _error(["Table not found: {}".format(e.table)], 404)
|
||||
return False, Response.error(["Table not found: {}".format(e.table)], 404)
|
||||
except RowNotFound as e:
|
||||
return False, _error(["Record not found: {}".format(e.pk_values)], 404)
|
||||
return False, Response.error(["Record not found: {}".format(e.pk_values)], 404)
|
||||
|
||||
# Ensure user has permission to delete this row
|
||||
if not await datasette.allowed(
|
||||
|
|
@ -357,7 +729,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
|
|||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||
actor=request.actor,
|
||||
):
|
||||
return False, _error(["Permission denied"], 403)
|
||||
return False, Response.error(["Permission denied"], 403)
|
||||
|
||||
return True, resolved
|
||||
|
||||
|
|
@ -382,7 +754,7 @@ class RowDeleteView(BaseView):
|
|||
try:
|
||||
await resolved.db.execute_write_fn(delete_row, request=request)
|
||||
except Exception as e:
|
||||
return _error([str(e)], 500)
|
||||
return Response.error([str(e)], 400)
|
||||
|
||||
await self.ds.track_event(
|
||||
DeleteRowEvent(
|
||||
|
|
@ -421,18 +793,24 @@ class RowUpdateView(BaseView):
|
|||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _error(["Invalid JSON: {}".format(e)])
|
||||
return Response.error(["Invalid JSON: {}".format(e)])
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _error(["JSON must be a dictionary"])
|
||||
return Response.error(["JSON must be a dictionary"])
|
||||
if "update" not in data or not isinstance(data["update"], dict):
|
||||
return _error(["JSON must contain an update dictionary"])
|
||||
return Response.error(["JSON must contain an update dictionary"])
|
||||
|
||||
invalid_keys = set(data.keys()) - {"update", "return", "alter"}
|
||||
if invalid_keys:
|
||||
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
||||
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
||||
|
||||
update = data["update"]
|
||||
try:
|
||||
update = decode_write_json_row(update)
|
||||
except WriteJsonValueError as e:
|
||||
return Response.error([str(e)], 400)
|
||||
|
||||
# Validate column types
|
||||
from datasette.views.table import _validate_column_types
|
||||
|
|
@ -441,7 +819,7 @@ class RowUpdateView(BaseView):
|
|||
self.ds, resolved.db.name, resolved.table, [update]
|
||||
)
|
||||
if ct_errors:
|
||||
return _error(ct_errors, 400)
|
||||
return Response.error(ct_errors, 400)
|
||||
|
||||
alter = data.get("alter")
|
||||
if alter and not await self.ds.allowed(
|
||||
|
|
@ -449,7 +827,7 @@ class RowUpdateView(BaseView):
|
|||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied for alter-table"], 403)
|
||||
return Response.error(["Permission denied for alter-table"], 403)
|
||||
|
||||
def update_row(conn):
|
||||
sqlite_utils.Database(conn)[resolved.table].update(
|
||||
|
|
@ -459,7 +837,7 @@ class RowUpdateView(BaseView):
|
|||
try:
|
||||
await resolved.db.execute_write_fn(update_row, request=request)
|
||||
except Exception as e:
|
||||
return _error([str(e)], 400)
|
||||
return Response.error([str(e)], 400)
|
||||
|
||||
result = {"ok": True}
|
||||
returned_row = None
|
||||
|
|
@ -468,7 +846,7 @@ class RowUpdateView(BaseView):
|
|||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
returned_row = results.dicts()[0]
|
||||
result["row"] = returned_row
|
||||
result["rows"] = [returned_row]
|
||||
|
||||
await self.ds.track_event(
|
||||
UpdateRowEvent(
|
||||
|
|
@ -494,4 +872,4 @@ class RowUpdateView(BaseView):
|
|||
self.ds.INFO,
|
||||
)
|
||||
|
||||
return Response.json(result, status=200)
|
||||
return Response.json(result, status=200, default=CustomJSONEncoder().default)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
|||
from datasette.resources import DatabaseResource, TableResource
|
||||
from datasette.utils.asgi import Response, Forbidden
|
||||
from datasette.utils import (
|
||||
UNSTABLE_API_MESSAGE,
|
||||
actor_matches_allow,
|
||||
parse_size_limit,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
error_body,
|
||||
tilde_encode,
|
||||
tilde_decode,
|
||||
)
|
||||
|
|
@ -52,9 +55,9 @@ class JsonDataView(BaseView):
|
|||
if self.permission:
|
||||
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
|
||||
if self.needs_request:
|
||||
data = self.data_callback(request)
|
||||
data = await await_me_maybe(self.data_callback(request))
|
||||
else:
|
||||
data = self.data_callback()
|
||||
data = await await_me_maybe(self.data_callback())
|
||||
|
||||
# Return JSON or HTML depending on format parameter
|
||||
as_format = request.url_vars.get("format")
|
||||
|
|
@ -62,6 +65,8 @@ class JsonDataView(BaseView):
|
|||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
if isinstance(data, dict):
|
||||
data = {"ok": True, **data}
|
||||
return Response.json(data, headers=headers)
|
||||
else:
|
||||
context = {
|
||||
|
|
@ -292,6 +297,12 @@ class PermissionsDebugView(BaseView):
|
|||
response, status = await _check_permission_for_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)
|
||||
|
||||
|
||||
|
|
@ -348,29 +359,32 @@ class AllowedResourcesView(BaseView):
|
|||
async def _allowed_payload(self, request, has_debug_permission):
|
||||
action = request.args.get("action")
|
||||
if not action:
|
||||
return {"error": "action parameter is required"}, 400
|
||||
return error_body("action parameter is required", 400), 400
|
||||
if action not in self.ds.actions:
|
||||
return {"error": f"Unknown action: {action}"}, 404
|
||||
return error_body(f"Unknown action: {action}", 404), 404
|
||||
|
||||
actor = request.actor if isinstance(request.actor, dict) else None
|
||||
actor_id = actor.get("id") if actor else None
|
||||
parent_filter = request.args.get("parent")
|
||||
child_filter = request.args.get("child")
|
||||
if child_filter and not parent_filter:
|
||||
return {"error": "parent must be provided when child is specified"}, 400
|
||||
return (
|
||||
error_body("parent must be provided when child is specified", 400),
|
||||
400,
|
||||
)
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("page_size", "50"))
|
||||
page = int(request.args.get("_page", "1"))
|
||||
if page < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return {"error": "page and page_size must be integers"}, 400
|
||||
if page < 1:
|
||||
return {"error": "page must be >= 1"}, 400
|
||||
if page_size < 1:
|
||||
return {"error": "page_size must be >= 1"}, 400
|
||||
max_page_size = 200
|
||||
if page_size > max_page_size:
|
||||
page_size = max_page_size
|
||||
return error_body("_page must be a positive integer", 400), 400
|
||||
try:
|
||||
page_size = parse_size_limit(
|
||||
request.args.get("_size"), default=50, maximum=200
|
||||
)
|
||||
except ValueError as ex:
|
||||
return error_body(str(ex), 400), 400
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Use the simplified allowed_resources method
|
||||
|
|
@ -410,6 +424,7 @@ class AllowedResourcesView(BaseView):
|
|||
# If catalog tables don't exist yet, return empty results
|
||||
return (
|
||||
{
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"actor_id": actor_id,
|
||||
"page": page,
|
||||
|
|
@ -434,16 +449,17 @@ class AllowedResourcesView(BaseView):
|
|||
def build_page_url(page_number):
|
||||
pairs = []
|
||||
for key in request.args:
|
||||
if key in {"page", "page_size"}:
|
||||
if key in {"_page", "_size"}:
|
||||
continue
|
||||
for value in request.args.getlist(key):
|
||||
pairs.append((key, value))
|
||||
pairs.append(("page", str(page_number)))
|
||||
pairs.append(("page_size", str(page_size)))
|
||||
pairs.append(("_page", str(page_number)))
|
||||
pairs.append(("_size", str(page_size)))
|
||||
query = urllib.parse.urlencode(pairs)
|
||||
return f"{request.path}?{query}"
|
||||
|
||||
response = {
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"actor_id": actor_id,
|
||||
"page": page,
|
||||
|
|
@ -485,26 +501,24 @@ class PermissionRulesView(BaseView):
|
|||
# JSON API - action parameter is required
|
||||
action = request.args.get("action")
|
||||
if not action:
|
||||
return Response.json({"error": "action parameter is required"}, status=400)
|
||||
return Response.error("action parameter is required", 400)
|
||||
if action not in self.ds.actions:
|
||||
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
||||
return Response.error(f"Unknown action: {action}", 404)
|
||||
|
||||
actor = request.actor if isinstance(request.actor, dict) else None
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("page_size", "50"))
|
||||
page = int(request.args.get("_page", "1"))
|
||||
if page < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return Response.json(
|
||||
{"error": "page and page_size must be integers"}, status=400
|
||||
return Response.error("_page must be a positive integer", 400)
|
||||
try:
|
||||
page_size = parse_size_limit(
|
||||
request.args.get("_size"), default=50, maximum=200
|
||||
)
|
||||
if page < 1:
|
||||
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
|
||||
except ValueError as ex:
|
||||
return Response.error(str(ex), 400)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
from datasette.utils.actions_sql import build_permission_rules_sql
|
||||
|
|
@ -555,16 +569,17 @@ class PermissionRulesView(BaseView):
|
|||
def build_page_url(page_number):
|
||||
pairs = []
|
||||
for key in request.args:
|
||||
if key in {"page", "page_size"}:
|
||||
if key in {"_page", "_size"}:
|
||||
continue
|
||||
for value in request.args.getlist(key):
|
||||
pairs.append((key, value))
|
||||
pairs.append(("page", str(page_number)))
|
||||
pairs.append(("page_size", str(page_size)))
|
||||
pairs.append(("_page", str(page_number)))
|
||||
pairs.append(("_size", str(page_size)))
|
||||
query = urllib.parse.urlencode(pairs)
|
||||
return f"{request.path}?{query}"
|
||||
|
||||
response = {
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"actor_id": (actor or {}).get("id") if actor else None,
|
||||
"page": page,
|
||||
|
|
@ -585,17 +600,17 @@ class PermissionRulesView(BaseView):
|
|||
|
||||
|
||||
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 and explaining a permission decision."""
|
||||
if action not in ds.actions:
|
||||
return {"error": f"Unknown action: {action}"}, 404
|
||||
return error_body(f"Unknown action: {action}", 404), 404
|
||||
|
||||
if child and not parent:
|
||||
return {"error": "parent is required when child is provided"}, 400
|
||||
return error_body("parent is required when child is provided", 400), 400
|
||||
|
||||
# Use the action's properties to create the appropriate resource object
|
||||
action_obj = ds.actions.get(action)
|
||||
if not action_obj:
|
||||
return {"error": f"Unknown action: {action}"}, 400
|
||||
return error_body(f"Unknown action: {action}", 400), 400
|
||||
|
||||
# Global actions (no resource_class) don't have a resource
|
||||
if action_obj.resource_class is None:
|
||||
|
|
@ -610,18 +625,32 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
|
|||
resource_obj = action_obj.resource_class(parent)
|
||||
else:
|
||||
# This shouldn't happen given validation in Action.__post_init__
|
||||
return {"error": f"Invalid action configuration: {action}"}, 500
|
||||
return error_body(f"Invalid action configuration: {action}", 500), 500
|
||||
|
||||
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
|
||||
|
||||
from datasette.utils.actions_sql import explain_permission_for_resource
|
||||
|
||||
explanation = await explain_permission_for_resource(
|
||||
datasette=ds,
|
||||
actor=actor,
|
||||
action=action,
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
|
||||
response = {
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"action": action,
|
||||
"allowed": bool(allowed),
|
||||
"actor": actor,
|
||||
"resource": {
|
||||
"parent": parent,
|
||||
"child": child,
|
||||
"path": _resource_path(parent, child),
|
||||
},
|
||||
"explanation": explanation,
|
||||
}
|
||||
|
||||
if actor and "id" in actor:
|
||||
|
|
@ -639,11 +668,25 @@ class PermissionCheckView(BaseView):
|
|||
as_format = request.url_vars.get("format")
|
||||
|
||||
if not as_format:
|
||||
actions = [
|
||||
{
|
||||
"name": action.name,
|
||||
"description": action.description,
|
||||
"takes_parent": action.takes_parent,
|
||||
"takes_child": action.takes_child,
|
||||
"also_requires": action.also_requires,
|
||||
}
|
||||
for action in sorted(
|
||||
self.ds.actions.values(), key=lambda action: action.name
|
||||
)
|
||||
]
|
||||
return await self.render(
|
||||
["debug_check.html"],
|
||||
request,
|
||||
{
|
||||
"sorted_actions": sorted(self.ds.actions.keys()),
|
||||
"actions": actions,
|
||||
"actor_json": request.args.get("actor")
|
||||
or json.dumps(request.actor, indent=2),
|
||||
"has_debug_permission": True,
|
||||
},
|
||||
)
|
||||
|
|
@ -651,13 +694,22 @@ class PermissionCheckView(BaseView):
|
|||
# JSON API - action parameter is required
|
||||
action = request.args.get("action")
|
||||
if not action:
|
||||
return Response.json({"error": "action parameter is required"}, status=400)
|
||||
return Response.error("action parameter is required", 400)
|
||||
|
||||
parent = request.args.get("parent")
|
||||
child = request.args.get("child")
|
||||
actor = request.actor
|
||||
actor_json = request.args.get("actor")
|
||||
if actor_json is not None:
|
||||
try:
|
||||
actor = json.loads(actor_json)
|
||||
except json.JSONDecodeError as ex:
|
||||
return Response.error(f"Invalid actor JSON: {ex}", 400)
|
||||
if actor is not None and not isinstance(actor, dict):
|
||||
return Response.error("actor must be a JSON object or null", 400)
|
||||
|
||||
response, status = await _check_permission_for_actor(
|
||||
self.ds, action, parent, child, request.actor
|
||||
self.ds, action, parent, child, actor
|
||||
)
|
||||
return Response.json(response, status=status)
|
||||
|
||||
|
|
@ -1198,7 +1250,7 @@ class JumpView(BaseView):
|
|||
match["display_name"] = row["display_name"]
|
||||
matches.append(match)
|
||||
|
||||
return Response.json({"matches": matches, "truncated": truncated})
|
||||
return Response.json({"ok": True, "matches": matches, "truncated": truncated})
|
||||
|
||||
|
||||
class SchemaBaseView(BaseView):
|
||||
|
|
@ -1220,7 +1272,7 @@ class SchemaBaseView(BaseView):
|
|||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(data, headers=headers)
|
||||
return Response.json({"ok": True, **data}, headers=headers)
|
||||
|
||||
def format_error_response(self, error_message, format_, status=404):
|
||||
"""Format error response based on requested format."""
|
||||
|
|
@ -1229,7 +1281,7 @@ class SchemaBaseView(BaseView):
|
|||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(
|
||||
{"ok": False, "error": error_message}, status=status, headers=headers
|
||||
error_body(error_message, status), status=status, headers=headers
|
||||
)
|
||||
else:
|
||||
return Response.text(error_message, status=status)
|
||||
|
|
@ -1305,17 +1357,17 @@ class DatabaseSchemaView(SchemaBaseView):
|
|||
database_name = request.url_vars["database"]
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
||||
# Check if database exists
|
||||
if database_name not in self.ds.databases:
|
||||
return self.format_error_response("Database not found", format_)
|
||||
|
||||
# Check view-database permission
|
||||
# Permission check comes first, so actors without view-database
|
||||
# cannot distinguish existing databases from missing ones
|
||||
await self.ds.ensure_permission(
|
||||
action="view-database",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
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)
|
||||
|
||||
if format_ == "json":
|
||||
|
|
@ -1349,6 +1401,9 @@ class TableSchemaView(SchemaBaseView):
|
|||
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
|
||||
db = self.ds.databases[database_name]
|
||||
result = await db.execute(
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ 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 sqlite3, tilde_decode
|
||||
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3, tilde_decode
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
from .base import BaseView, _error
|
||||
from .base import BaseView
|
||||
from .query_helpers import (
|
||||
QueryValidationError,
|
||||
_as_bool,
|
||||
|
|
@ -34,12 +34,14 @@ class QueryParametersView(BaseView):
|
|||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
||||
return _block_framing(
|
||||
Response.error(["Permission denied: need execute-sql"], 403)
|
||||
)
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
_error(
|
||||
Response.error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
|
|
@ -47,8 +49,16 @@ class QueryParametersView(BaseView):
|
|||
try:
|
||||
parameters = _derived_query_parameters(request.args.get("sql") or "")
|
||||
except QueryValidationError as ex:
|
||||
return _block_framing(_error([ex.message], ex.status))
|
||||
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
|
||||
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):
|
||||
|
|
@ -82,11 +92,12 @@ class QueryListView(BaseView):
|
|||
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 _error([ex.message], ex.status)
|
||||
return Response.error([ex.message], ex.status)
|
||||
|
||||
page = await self.ds.list_queries(
|
||||
database,
|
||||
|
|
@ -111,9 +122,9 @@ class QueryListView(BaseView):
|
|||
if key != "_next"
|
||||
]
|
||||
pairs.append(("_next", page.next))
|
||||
next_url = "{}?{}".format(
|
||||
query_list_path,
|
||||
urlencode(pairs),
|
||||
next_url = self.ds.absolute_url(
|
||||
request,
|
||||
"{}?{}".format(request.path, urlencode(pairs)),
|
||||
)
|
||||
|
||||
current_filters = {
|
||||
|
|
@ -199,7 +210,6 @@ class QueryListView(BaseView):
|
|||
"queries": page.queries,
|
||||
"next": page.next,
|
||||
"next_url": next_url,
|
||||
"has_more": page.has_more,
|
||||
"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),
|
||||
|
|
@ -298,28 +308,30 @@ class QueryCreateAnalyzeView(BaseView):
|
|||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
||||
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(_error(["Permission denied: need store-query"], 403))
|
||||
return _block_framing(
|
||||
Response.error(["Permission denied: need store-query"], 403)
|
||||
)
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
_error(
|
||||
Response.error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
||||
)
|
||||
)
|
||||
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):
|
||||
|
|
@ -346,13 +358,13 @@ class QueryStoreView(QueryCreateView):
|
|||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need execute-sql"], 403)
|
||||
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 _error(["Permission denied: need store-query"], 403)
|
||||
return Response.error(["Permission denied: need store-query"], 403)
|
||||
|
||||
is_json = False
|
||||
query_data = {}
|
||||
|
|
@ -369,7 +381,7 @@ class QueryStoreView(QueryCreateView):
|
|||
return await self._error_response(
|
||||
request, db, query_data, ex.message, ex.status
|
||||
)
|
||||
return _error([ex.message], ex.status)
|
||||
return Response.error([ex.message], ex.status)
|
||||
|
||||
prepared.pop("analysis")
|
||||
name = prepared.pop("name")
|
||||
|
|
@ -378,13 +390,18 @@ class QueryStoreView(QueryCreateView):
|
|||
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 _error([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, "query": stored_query_to_dict(query)}, status=201
|
||||
{
|
||||
"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)))
|
||||
|
|
@ -398,14 +415,20 @@ class QueryDefinitionView(BaseView):
|
|||
query_name = tilde_decode(request.url_vars["query"])
|
||||
query = await self.ds.get_query(db.name, query_name)
|
||||
if query is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
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 _error(["Permission denied"], 403)
|
||||
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
|
||||
return Response.error(["Permission denied"], 403)
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"query": stored_query_to_dict(query),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class QueryUpdateView(BaseView):
|
||||
|
|
@ -416,15 +439,17 @@ class QueryUpdateView(BaseView):
|
|||
query_name = tilde_decode(request.url_vars["query"])
|
||||
existing = await self.ds.get_query(db.name, query_name)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
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 _error(["Permission denied: need update-query"], 403)
|
||||
return Response.error(["Permission denied: need update-query"], 403)
|
||||
if existing.is_trusted:
|
||||
return _error(["Trusted queries cannot be updated using the API"], 403)
|
||||
return Response.error(
|
||||
["Trusted queries cannot be updated using the API"], 403
|
||||
)
|
||||
|
||||
try:
|
||||
data, _ = await _json_or_form_payload(request)
|
||||
|
|
@ -450,7 +475,7 @@ class QueryUpdateView(BaseView):
|
|||
self.ds, request, db, existing, update
|
||||
)
|
||||
except QueryValidationError as ex:
|
||||
return _error([ex.message], ex.status)
|
||||
return Response.error([ex.message], ex.status)
|
||||
|
||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
||||
if data.get("return"):
|
||||
|
|
@ -507,32 +532,32 @@ class QueryEditView(BaseView):
|
|||
async def get(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
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 _error(["Trusted queries cannot be edited"], 403)
|
||||
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 _error(["Query not found: {}".format(query_name)], 404)
|
||||
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 _error(["Permission denied: need update-query"], 403)
|
||||
return Response.error(["Permission denied: need update-query"], 403)
|
||||
if existing.is_trusted:
|
||||
return _error(["Trusted queries cannot be edited"], 403)
|
||||
return Response.error(["Trusted queries cannot be edited"], 403)
|
||||
|
||||
data, _ = await _json_or_form_payload(request)
|
||||
if not isinstance(data, dict):
|
||||
return _error(["Invalid form submission"], 400)
|
||||
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 ""
|
||||
|
|
@ -604,12 +629,16 @@ class QueryDeleteView(BaseView):
|
|||
async def get(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
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,
|
||||
|
|
@ -624,13 +653,17 @@ class QueryDeleteView(BaseView):
|
|||
async def post(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
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 _error(["Permission denied: need delete-query"], 403)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ from datasette.utils import (
|
|||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
call_with_supported_arguments,
|
||||
CustomJSONEncoder,
|
||||
CustomRow,
|
||||
append_querystring,
|
||||
compound_keys_after_sql,
|
||||
decode_write_json_rows,
|
||||
format_bytes,
|
||||
make_slot_function,
|
||||
tilde_encode,
|
||||
|
|
@ -41,20 +43,193 @@ from datasette.utils import (
|
|||
urlsafe_components,
|
||||
value_as_boolean,
|
||||
InvalidSql,
|
||||
WriteJsonValueError,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
|
||||
from datasette.utils.asgi import (
|
||||
BadRequest,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PayloadTooLarge,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from datasette.filters import Filters
|
||||
import sqlite_utils
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from datasette.extras import ExtraScope
|
||||
from . import Context, from_extra
|
||||
from .base import BaseView, DatasetteError, stream_csv
|
||||
from .database import QueryView
|
||||
from .table_create_alter import (
|
||||
ALTER_TABLE_COLUMN_TYPES,
|
||||
ALTER_TABLE_TYPE_FOR_SQLITE_TYPE,
|
||||
_custom_column_type_options_for_create_table,
|
||||
default_expr_for_sql,
|
||||
default_expression_options,
|
||||
)
|
||||
from .table_extras import (
|
||||
TABLE_EXTRA_BUNDLES,
|
||||
TableExtraContext,
|
||||
count_is_truncated,
|
||||
precompute_database_action_permissions,
|
||||
precompute_table_action_permissions,
|
||||
resolve_table_extras,
|
||||
table_extra_registry,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableContext(Context):
|
||||
"The page showing the rows in a table or SQL view, e.g. /fixtures/facetable."
|
||||
|
||||
documented_template = "table.html"
|
||||
extras_scope = ExtraScope.TABLE
|
||||
|
||||
# Fields resolved by registered extras - their documentation comes
|
||||
# from the description on each Extra class in table_extras.py
|
||||
actions: callable = from_extra()
|
||||
all_columns: list = from_extra()
|
||||
columns: list = from_extra()
|
||||
count: int = from_extra()
|
||||
count_sql: str = from_extra()
|
||||
custom_table_templates: list = from_extra()
|
||||
database: str = from_extra()
|
||||
database_color: str = from_extra()
|
||||
display_columns: list = from_extra()
|
||||
display_rows: list = from_extra()
|
||||
expandable_columns: list = from_extra()
|
||||
facet_results: dict = from_extra()
|
||||
facets_timed_out: list = from_extra()
|
||||
filters: Filters = from_extra()
|
||||
form_hidden_args: list = from_extra()
|
||||
human_description_en: str = from_extra()
|
||||
is_view: bool = from_extra()
|
||||
metadata: dict = from_extra()
|
||||
primary_keys: list = from_extra()
|
||||
private: bool = from_extra()
|
||||
query: dict = from_extra()
|
||||
renderers: dict = from_extra()
|
||||
set_column_type_ui: dict = from_extra()
|
||||
sorted_facet_results: list = from_extra()
|
||||
suggested_facets: list = from_extra()
|
||||
table: str = from_extra()
|
||||
table_definition: str = from_extra()
|
||||
view_definition: 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"}
|
||||
)
|
||||
next: str = field(metadata={"help": "Pagination token for the next page, or None"})
|
||||
next_url: str = field(
|
||||
metadata={
|
||||
"help": "Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`."
|
||||
}
|
||||
)
|
||||
count_truncated: bool = field(
|
||||
metadata={
|
||||
"help": "True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit."
|
||||
}
|
||||
)
|
||||
rows: list = field(
|
||||
metadata={
|
||||
"help": "The rows for this page, as a list of dictionaries mapping column name to raw value."
|
||||
}
|
||||
)
|
||||
filter_columns: list = field(
|
||||
metadata={
|
||||
"help": "List of column names offered by the filter interface, including currently displayed columns and any hidden columns that can still be filtered."
|
||||
}
|
||||
)
|
||||
supports_search: bool = field(
|
||||
metadata={"help": "True if this table has full-text search configured"}
|
||||
)
|
||||
extra_wheres_for_ui: list = field(
|
||||
metadata={
|
||||
"help": "Extra where clauses from ``?_where=`` for display in the UI. Each item has ``text`` for the SQL fragment and ``remove_url`` for a URL that removes that fragment."
|
||||
}
|
||||
)
|
||||
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 filters while forcing ``_size=max``."
|
||||
}
|
||||
)
|
||||
sort: str = field(metadata={"help": "Column the page is sorted by, or None"})
|
||||
sort_desc: str = field(
|
||||
metadata={"help": "Column the page is sorted by in descending order, or None"}
|
||||
)
|
||||
append_querystring: callable = field(
|
||||
metadata={
|
||||
"help": "Function ``append_querystring(url, querystring)`` that appends additional query string arguments to a URL, using ``?`` or ``&`` as appropriate."
|
||||
}
|
||||
)
|
||||
path_with_replaced_args: callable = field(
|
||||
metadata={
|
||||
"help": "Function for building the current path with modified query string arguments. Pass the current ``request`` and a dictionary of argument names to replacement values, using ``None`` to remove an argument."
|
||||
}
|
||||
)
|
||||
fix_path: callable = field(
|
||||
metadata={
|
||||
"help": "Function that applies the configured ``base_url`` prefix to a path."
|
||||
}
|
||||
)
|
||||
settings: dict = field(
|
||||
metadata={
|
||||
"help": "Dictionary of Datasette's current settings, keyed by setting name."
|
||||
}
|
||||
)
|
||||
alternate_url_json: str = field(
|
||||
metadata={"help": "URL for the JSON version of this page"}
|
||||
)
|
||||
datasette_allow_facet: str = field(
|
||||
metadata={
|
||||
"help": 'The string "true" or "false" reflecting the allow_facet setting'
|
||||
}
|
||||
)
|
||||
is_sortable: bool = field(
|
||||
metadata={"help": "True if any of the displayed columns can be used to sort"}
|
||||
)
|
||||
allow_execute_sql: bool = field(
|
||||
metadata={
|
||||
"help": "True if the current actor can execute custom SQL against this database"
|
||||
}
|
||||
)
|
||||
query_ms: float = field(
|
||||
metadata={
|
||||
"help": "Time taken by the SQL queries for this page, in milliseconds"
|
||||
}
|
||||
)
|
||||
select_templates: list = field(
|
||||
metadata={
|
||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||
}
|
||||
)
|
||||
top_table: callable = field(
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_table`` plugin slot for this table or view and returns HTML."
|
||||
}
|
||||
)
|
||||
table_page_data: dict = field(
|
||||
metadata={
|
||||
"help": "JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data."
|
||||
}
|
||||
)
|
||||
table_insert_ui: dict = field(
|
||||
metadata={
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
}
|
||||
)
|
||||
table_alter_ui: dict = field(
|
||||
metadata={
|
||||
"help": "Information needed to enable the alter table UI, or ``None`` if altering this table is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``primaryKeys``, ``columnTypes``, ``defaultExpressions`` and ``foreignKeyTargetsPath`` keys, plus optional ``customColumnTypes`` and ``dropPath`` keys."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LINK_WITH_LABEL = (
|
||||
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a> <em>{id}</em>'
|
||||
)
|
||||
|
|
@ -280,7 +455,14 @@ async def _foreign_key_autocomplete_urls(
|
|||
|
||||
|
||||
async def _table_page_data(
|
||||
datasette, request, db, database_name, table_name, is_view, table_insert_ui
|
||||
datasette,
|
||||
request,
|
||||
db,
|
||||
database_name,
|
||||
table_name,
|
||||
is_view,
|
||||
table_insert_ui,
|
||||
table_alter_ui,
|
||||
):
|
||||
data = {
|
||||
"database": database_name,
|
||||
|
|
@ -289,6 +471,8 @@ async def _table_page_data(
|
|||
}
|
||||
if table_insert_ui:
|
||||
data["insertRow"] = table_insert_ui
|
||||
if table_alter_ui:
|
||||
data["alterTable"] = table_alter_ui
|
||||
if not is_view:
|
||||
foreign_keys = await _foreign_key_autocomplete_urls(
|
||||
datasette, request, db, database_name, table_name
|
||||
|
|
@ -311,8 +495,15 @@ async def _table_insert_ui(
|
|||
):
|
||||
return None
|
||||
|
||||
can_update = await datasette.allowed(
|
||||
action="update-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
column_types_map = await datasette.get_column_types(database_name, table_name)
|
||||
columns = []
|
||||
bulk_columns = []
|
||||
column_details = await db.table_column_details(table_name)
|
||||
for column in column_details:
|
||||
if column.hidden:
|
||||
|
|
@ -323,32 +514,126 @@ async def _table_insert_ui(
|
|||
and len(pks) == 1
|
||||
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
|
||||
)
|
||||
column_type = column_types_map.get(column.name)
|
||||
column_data = {
|
||||
"name": column.name,
|
||||
"sqlite_type": _column_sqlite_type_for_insert_form(column),
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"is_auto_pk": is_auto_pk,
|
||||
"value_kind": _column_value_kind_for_insert_form(column),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
bulk_columns.append(column_data)
|
||||
if is_auto_pk:
|
||||
continue
|
||||
column_type = column_types_map.get(column.name)
|
||||
columns.append(
|
||||
{
|
||||
"name": column.name,
|
||||
"sqlite_type": _column_sqlite_type_for_insert_form(column),
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"value_kind": _column_value_kind_for_insert_form(column),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
columns.append(column_data)
|
||||
|
||||
return {
|
||||
data = {
|
||||
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"bulkColumns": bulk_columns,
|
||||
"primaryKeys": pks,
|
||||
"maxInsertRows": datasette.setting("max_insert_rows"),
|
||||
}
|
||||
if can_update:
|
||||
data["upsertPath"] = "{}/-/upsert".format(
|
||||
datasette.urls.table(database_name, table_name)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _table_alter_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
):
|
||||
if is_view or not db.is_mutable:
|
||||
return None
|
||||
|
||||
if not await datasette.allowed(
|
||||
action="alter-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return None
|
||||
|
||||
column_types_map = await datasette.get_column_types(database_name, table_name)
|
||||
foreign_keys_by_column = {}
|
||||
for fk in await db.foreign_keys_for_table(table_name):
|
||||
other_column = fk["other_column"]
|
||||
if other_column is None and await db.table_exists(fk["other_table"]):
|
||||
other_pks = await db.primary_keys(fk["other_table"])
|
||||
if len(other_pks) == 1:
|
||||
other_column = other_pks[0]
|
||||
if other_column is None:
|
||||
continue
|
||||
foreign_keys_by_column[fk["column"]] = {
|
||||
"fk_table": fk["other_table"],
|
||||
"fk_column": other_column,
|
||||
}
|
||||
columns = []
|
||||
for column in await db.table_column_details(table_name):
|
||||
if column.hidden:
|
||||
continue
|
||||
sqlite_type = SQLiteType.from_declared_type(column.type)
|
||||
column_type = column_types_map.get(column.name)
|
||||
default_expr = default_expr_for_sql(column.default_value)
|
||||
column_data = {
|
||||
"name": column.name,
|
||||
"type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"),
|
||||
"sqlite_type": sqlite_type.value,
|
||||
"notnull": column.notnull,
|
||||
"default": None if default_expr else column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": column.name in pks,
|
||||
"foreign_key": foreign_keys_by_column.get(column.name),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
if default_expr:
|
||||
column_data["default_expr"] = default_expr
|
||||
columns.append(column_data)
|
||||
|
||||
data = {
|
||||
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"primaryKeys": pks,
|
||||
"columnTypes": ALTER_TABLE_COLUMN_TYPES,
|
||||
"defaultExpressions": default_expression_options(),
|
||||
"foreignKeyTargetsPath": "{}/-/foreign-key-targets?table={}".format(
|
||||
datasette.urls.database(database_name),
|
||||
urllib.parse.quote(table_name, safe=""),
|
||||
),
|
||||
}
|
||||
can_set_column_type = await datasette.allowed(
|
||||
action="set-column-type",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
if can_set_column_type:
|
||||
data["customColumnTypes"] = _custom_column_type_options_for_create_table(
|
||||
datasette
|
||||
)
|
||||
can_drop_table = await datasette.allowed(
|
||||
action="drop-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
if can_drop_table:
|
||||
data["dropPath"] = "{}/-/drop".format(
|
||||
datasette.urls.table(database_name, table_name)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def display_columns_and_rows(
|
||||
|
|
@ -455,8 +740,8 @@ async def display_columns_and_rows(
|
|||
'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">'
|
||||
'<path d="M12 20h9"></path>'
|
||||
'<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path>'
|
||||
'<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>'
|
||||
'<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>'
|
||||
"</svg>"
|
||||
)
|
||||
delete_icon = (
|
||||
|
|
@ -670,9 +955,7 @@ class TableInsertView(BaseView):
|
|||
def _errors(errors):
|
||||
return None, errors, {}
|
||||
|
||||
if not request.headers.get("content-type").startswith("application/json"):
|
||||
# TODO: handle form-encoded data
|
||||
return _errors(["Invalid content-type, must be application/json"])
|
||||
# The body is parsed as JSON regardless of the Content-Type header
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
|
|
@ -756,7 +1039,7 @@ class TableInsertView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return _error([e.args[0]], 404)
|
||||
return Response.error([e.args[0]], 404)
|
||||
db = resolved.db
|
||||
database_name = db.name
|
||||
table_name = resolved.table
|
||||
|
|
@ -764,7 +1047,7 @@ class TableInsertView(BaseView):
|
|||
# Table must exist (may handle table creation in the future)
|
||||
db = self.ds.get_database(database_name)
|
||||
if not await db.table_exists(table_name):
|
||||
return _error(["Table not found: {}".format(table_name)], 404)
|
||||
return Response.error(["Table not found: {}".format(table_name)], 404)
|
||||
|
||||
if upsert:
|
||||
# Must have insert-row AND upsert-row permissions
|
||||
|
|
@ -780,7 +1063,7 @@ class TableInsertView(BaseView):
|
|||
actor=request.actor,
|
||||
)
|
||||
):
|
||||
return _error(
|
||||
return Response.error(
|
||||
["Permission denied: need both insert-row and update-row"], 403
|
||||
)
|
||||
else:
|
||||
|
|
@ -790,25 +1073,32 @@ class TableInsertView(BaseView):
|
|||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
return Response.error(["Permission denied"], 403)
|
||||
|
||||
if not db.is_mutable:
|
||||
return _error(["Database is immutable"], 403)
|
||||
return Response.error(["Database is immutable"], 403)
|
||||
|
||||
pks = await db.primary_keys(table_name)
|
||||
|
||||
rows, errors, extras = await self._validate_data(
|
||||
request, db, table_name, pks, upsert
|
||||
)
|
||||
try:
|
||||
rows, errors, extras = await self._validate_data(
|
||||
request, db, table_name, pks, upsert
|
||||
)
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
if errors:
|
||||
return _error(errors, 400)
|
||||
return Response.error(errors, 400)
|
||||
try:
|
||||
rows = decode_write_json_rows(rows)
|
||||
except WriteJsonValueError as e:
|
||||
return Response.error([str(e)], 400)
|
||||
|
||||
# Validate column types
|
||||
ct_errors = await _validate_column_types(
|
||||
self.ds, database_name, table_name, rows
|
||||
)
|
||||
if ct_errors:
|
||||
return _error(ct_errors, 400)
|
||||
return Response.error(ct_errors, 400)
|
||||
|
||||
num_rows = len(rows)
|
||||
|
||||
|
|
@ -822,14 +1112,16 @@ class TableInsertView(BaseView):
|
|||
alter = extras.get("alter")
|
||||
|
||||
if upsert and (ignore or replace):
|
||||
return _error(["Upsert does not support ignore or replace"], 400)
|
||||
return Response.error(["Upsert does not support ignore or replace"], 400)
|
||||
|
||||
if replace and not await self.ds.allowed(
|
||||
action="update-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(['Permission denied: need update-row to use "replace"'], 403)
|
||||
return Response.error(
|
||||
['Permission denied: need update-row to use "replace"'], 403
|
||||
)
|
||||
|
||||
initial_schema = None
|
||||
if alter:
|
||||
|
|
@ -839,7 +1131,7 @@ class TableInsertView(BaseView):
|
|||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied for alter-table"], 403)
|
||||
return Response.error(["Permission denied for alter-table"], 403)
|
||||
# Track initial schema to check if it changed later
|
||||
initial_schema = await db.execute_fn(
|
||||
lambda conn: sqlite_utils.Database(conn)[table_name].schema
|
||||
|
|
@ -879,7 +1171,7 @@ class TableInsertView(BaseView):
|
|||
try:
|
||||
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
|
||||
except Exception as e:
|
||||
return _error([str(e)])
|
||||
return Response.error([str(e)])
|
||||
result = {"ok": True}
|
||||
if should_return:
|
||||
if upsert:
|
||||
|
|
@ -936,7 +1228,11 @@ class TableInsertView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
return Response.json(result, status=200 if upsert else 201)
|
||||
return Response.json(
|
||||
result,
|
||||
status=200 if upsert else 201,
|
||||
default=CustomJSONEncoder().default,
|
||||
)
|
||||
|
||||
|
||||
class TableUpsertView(TableInsertView):
|
||||
|
|
@ -956,7 +1252,7 @@ class TableSetColumnTypeView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return _error([e.args[0]], 404)
|
||||
return Response.error([e.args[0]], 404)
|
||||
|
||||
database_name = resolved.db.name
|
||||
table_name = resolved.table
|
||||
|
|
@ -966,41 +1262,39 @@ class TableSetColumnTypeView(BaseView):
|
|||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
|
||||
content_type = request.headers.get("content-type") or ""
|
||||
if not content_type.startswith("application/json"):
|
||||
return _error(["Invalid content-type, must be application/json"], 400)
|
||||
return Response.error(["Permission denied"], 403)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _error(["Invalid JSON: {}".format(e)], 400)
|
||||
return Response.error(["Invalid JSON: {}".format(e)], 400)
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _error(["JSON must be a dictionary"], 400)
|
||||
return Response.error(["JSON must be a dictionary"], 400)
|
||||
|
||||
invalid_keys = set(data.keys()) - {"column", "column_type"}
|
||||
if invalid_keys:
|
||||
return _error(
|
||||
return Response.error(
|
||||
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
|
||||
if "column" not in data:
|
||||
return _error(['"column" is required'], 400)
|
||||
return Response.error(['"column" is required'], 400)
|
||||
column = data["column"]
|
||||
if not isinstance(column, str):
|
||||
return _error(['"column" must be a string'], 400)
|
||||
return Response.error(['"column" must be a string'], 400)
|
||||
|
||||
if "column_type" not in data:
|
||||
return _error(['"column_type" is required'], 400)
|
||||
return Response.error(['"column_type" is required'], 400)
|
||||
|
||||
column_details = await self.ds._get_resource_column_details(
|
||||
database_name, table_name
|
||||
)
|
||||
if column not in column_details:
|
||||
return _error(["Column not found: {}".format(column)], 400)
|
||||
return Response.error(["Column not found: {}".format(column)], 400)
|
||||
|
||||
column_type_data = data["column_type"]
|
||||
if column_type_data is None:
|
||||
|
|
@ -1017,11 +1311,11 @@ class TableSetColumnTypeView(BaseView):
|
|||
)
|
||||
|
||||
if not isinstance(column_type_data, dict):
|
||||
return _error(['"column_type" must be an object or null'], 400)
|
||||
return Response.error(['"column_type" must be an object or null'], 400)
|
||||
|
||||
invalid_column_type_keys = set(column_type_data.keys()) - {"type", "config"}
|
||||
if invalid_column_type_keys:
|
||||
return _error(
|
||||
return Response.error(
|
||||
[
|
||||
'Invalid column_type parameter: "{}"'.format(
|
||||
'", "'.join(sorted(invalid_column_type_keys))
|
||||
|
|
@ -1031,24 +1325,24 @@ class TableSetColumnTypeView(BaseView):
|
|||
)
|
||||
|
||||
if "type" not in column_type_data:
|
||||
return _error(['"column_type.type" is required'], 400)
|
||||
return Response.error(['"column_type.type" is required'], 400)
|
||||
column_type = column_type_data["type"]
|
||||
if not isinstance(column_type, str):
|
||||
return _error(['"column_type.type" must be a string'], 400)
|
||||
return Response.error(['"column_type.type" must be a string'], 400)
|
||||
|
||||
config = column_type_data.get("config")
|
||||
if config is not None and not isinstance(config, dict):
|
||||
return _error(['"column_type.config" must be a dictionary'], 400)
|
||||
return Response.error(['"column_type.config" must be a dictionary'], 400)
|
||||
|
||||
if column_type not in self.ds._column_types:
|
||||
return _error(["Unknown column type: {}".format(column_type)], 400)
|
||||
return Response.error(["Unknown column type: {}".format(column_type)], 400)
|
||||
|
||||
try:
|
||||
await self.ds.set_column_type(
|
||||
database_name, table_name, column, column_type, config
|
||||
)
|
||||
except ValueError as e:
|
||||
return _error([str(e)], 400)
|
||||
return Response.error([str(e)], 400)
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
|
|
@ -1072,28 +1366,30 @@ class TableDropView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return _error([e.args[0]], 404)
|
||||
return Response.error([e.args[0]], 404)
|
||||
db = resolved.db
|
||||
database_name = db.name
|
||||
table_name = resolved.table
|
||||
# Table must exist
|
||||
db = self.ds.get_database(database_name)
|
||||
if not await db.table_exists(table_name):
|
||||
return _error(["Table not found: {}".format(table_name)], 404)
|
||||
return Response.error(["Table not found: {}".format(table_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="drop-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
return Response.error(["Permission denied"], 403)
|
||||
if not db.is_mutable:
|
||||
return _error(["Database is immutable"], 403)
|
||||
return Response.error(["Database is immutable"], 403)
|
||||
confirm = False
|
||||
try:
|
||||
data = await request.json()
|
||||
confirm = data.get("confirm")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
|
||||
if not confirm:
|
||||
return Response.json(
|
||||
|
|
@ -1119,6 +1415,11 @@ class TableDropView(BaseView):
|
|||
actor=request.actor, database=database_name, table=table_name
|
||||
)
|
||||
)
|
||||
self.ds.add_message(
|
||||
request,
|
||||
"Table {} dropped".format(table_name),
|
||||
self.ds.WARNING,
|
||||
)
|
||||
return Response.json({"ok": True}, status=200)
|
||||
|
||||
|
||||
|
|
@ -1151,7 +1452,6 @@ class TableFragmentView(BaseView):
|
|||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=self.ds.urls.path,
|
||||
settings=self.ds.settings_dict(),
|
||||
count_limit=resolved.db.count_limit,
|
||||
),
|
||||
request=request,
|
||||
view_name="table",
|
||||
|
|
@ -1266,7 +1566,7 @@ class TableAutocompleteView(BaseView):
|
|||
and value_as_boolean(initial_arg)
|
||||
)
|
||||
if not q and not initial:
|
||||
return Response.json({"rows": []})
|
||||
return Response.json({"ok": True, "rows": []})
|
||||
params = {
|
||||
"q": q,
|
||||
"like": "%{}%".format(_escape_like(q)),
|
||||
|
|
@ -1329,10 +1629,13 @@ class TableAutocompleteView(BaseView):
|
|||
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
|
||||
)
|
||||
except QueryInterrupted:
|
||||
return Response.json({"rows": []})
|
||||
return Response.json({"ok": True, "rows": []})
|
||||
|
||||
return Response.json(
|
||||
{"rows": _autocomplete_response_rows(results.rows, pks, label_column)}
|
||||
{
|
||||
"ok": True,
|
||||
"rows": _autocomplete_response_rows(results.rows, pks, label_column),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1347,8 +1650,8 @@ async def _columns_to_select(table_columns, pks, request):
|
|||
"_col={} - invalid columns".format(", ".join(bad_columns)),
|
||||
status=400,
|
||||
)
|
||||
# De-duplicate maintaining order:
|
||||
columns.extend(dict.fromkeys(_cols))
|
||||
# De-duplicate maintaining order, skipping columns already added (pks):
|
||||
columns.extend(c for c in dict.fromkeys(_cols) if c not in columns)
|
||||
if "_nocol" in request.args:
|
||||
# Return all columns EXCEPT these
|
||||
bad_columns = [
|
||||
|
|
@ -1566,40 +1869,82 @@ async def table_view_traced(datasette, request):
|
|||
)
|
||||
}
|
||||
)
|
||||
table_context = TableContext(
|
||||
actions=data["actions"],
|
||||
all_columns=data["all_columns"],
|
||||
columns=data["columns"],
|
||||
count=data["count"],
|
||||
count_sql=data["count_sql"],
|
||||
custom_table_templates=data["custom_table_templates"],
|
||||
database=data["database"],
|
||||
database_color=data["database_color"],
|
||||
display_columns=data["display_columns"],
|
||||
display_rows=data["display_rows"],
|
||||
expandable_columns=data["expandable_columns"],
|
||||
facet_results=data["facet_results"],
|
||||
facets_timed_out=data["facets_timed_out"],
|
||||
filters=data["filters"],
|
||||
form_hidden_args=data["form_hidden_args"],
|
||||
human_description_en=data["human_description_en"],
|
||||
is_view=data["is_view"],
|
||||
metadata=data["metadata"],
|
||||
next_url=data["next_url"],
|
||||
primary_keys=data["primary_keys"],
|
||||
private=data["private"],
|
||||
query=data["query"],
|
||||
renderers=data["renderers"],
|
||||
set_column_type_ui=data["set_column_type_ui"],
|
||||
sorted_facet_results=data["sorted_facet_results"],
|
||||
suggested_facets=data["suggested_facets"],
|
||||
table=data["table"],
|
||||
table_definition=data["table_definition"],
|
||||
view_definition=data["view_definition"],
|
||||
ok=data["ok"],
|
||||
next=data["next"],
|
||||
count_truncated=data["count_truncated"],
|
||||
rows=data["rows"],
|
||||
filter_columns=data["filter_columns"],
|
||||
supports_search=data["supports_search"],
|
||||
extra_wheres_for_ui=data["extra_wheres_for_ui"],
|
||||
url_csv=data["url_csv"],
|
||||
url_csv_path=data["url_csv_path"],
|
||||
url_csv_hidden_args=data["url_csv_hidden_args"],
|
||||
sort=data["sort"],
|
||||
sort_desc=data["sort_desc"],
|
||||
append_querystring=append_querystring,
|
||||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=datasette.urls.path,
|
||||
settings=datasette.settings_dict(),
|
||||
alternate_url_json=alternate_url_json,
|
||||
datasette_allow_facet=(
|
||||
"true" if datasette.setting("allow_facet") else "false"
|
||||
),
|
||||
is_sortable=any(c["sortable"] for c in data["display_columns"]),
|
||||
allow_execute_sql=await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=resolved.db.name),
|
||||
actor=request.actor,
|
||||
),
|
||||
query_ms=1.2,
|
||||
select_templates=[
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
],
|
||||
top_table=make_slot_function(
|
||||
"top_table",
|
||||
datasette,
|
||||
request,
|
||||
database=resolved.db.name,
|
||||
table=resolved.table,
|
||||
),
|
||||
table_page_data=data["table_page_data"],
|
||||
table_insert_ui=data["table_insert_ui"],
|
||||
table_alter_ui=data["table_alter_ui"],
|
||||
)
|
||||
r = Response.html(
|
||||
await datasette.render_template(
|
||||
template,
|
||||
dict(
|
||||
data,
|
||||
append_querystring=append_querystring,
|
||||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=datasette.urls.path,
|
||||
settings=datasette.settings_dict(),
|
||||
# TODO: review up all of these hacks:
|
||||
alternate_url_json=alternate_url_json,
|
||||
datasette_allow_facet=(
|
||||
"true" if datasette.setting("allow_facet") else "false"
|
||||
),
|
||||
is_sortable=any(c["sortable"] for c in data["display_columns"]),
|
||||
allow_execute_sql=await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=resolved.db.name),
|
||||
actor=request.actor,
|
||||
),
|
||||
query_ms=1.2,
|
||||
select_templates=[
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
],
|
||||
top_table=make_slot_function(
|
||||
"top_table",
|
||||
datasette,
|
||||
request,
|
||||
database=resolved.db.name,
|
||||
table=resolved.table,
|
||||
),
|
||||
count_limit=resolved.db.count_limit,
|
||||
),
|
||||
table_context,
|
||||
request=request,
|
||||
view_name="table",
|
||||
),
|
||||
|
|
@ -1642,6 +1987,15 @@ async def table_view_data(
|
|||
if redirect_response:
|
||||
return redirect_response
|
||||
|
||||
if context_for_html_hack:
|
||||
await precompute_database_action_permissions(
|
||||
datasette, request.actor, database_name
|
||||
)
|
||||
if not is_view:
|
||||
await precompute_table_action_permissions(
|
||||
datasette, request.actor, database_name, table_name
|
||||
)
|
||||
|
||||
# Introspect columns and primary keys for table
|
||||
pks = await db.primary_keys(table_name)
|
||||
table_columns = await db.table_columns(table_name)
|
||||
|
|
@ -1940,10 +2294,16 @@ async def table_view_data(
|
|||
|
||||
# Resolve extras
|
||||
extras = extra_names_from_request(request)
|
||||
if not extra_extras:
|
||||
# Data formats reject unknown extras; the HTML path (which passes
|
||||
# extra_extras={"_html"}) resolves internal extras of its own
|
||||
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
|
||||
if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")):
|
||||
extras.add("facet_results")
|
||||
if request.args.get("_shape") == "object":
|
||||
extras.add("primary_keys")
|
||||
if "count" in extras:
|
||||
extras.add("count_truncated")
|
||||
if extra_extras:
|
||||
extras.update(extra_extras)
|
||||
|
||||
|
|
@ -1998,6 +2358,7 @@ async def table_view_data(
|
|||
data = {
|
||||
"ok": True,
|
||||
"next": next_value and str(next_value) or None,
|
||||
"next_url": next_url,
|
||||
}
|
||||
data.update(
|
||||
await resolve_table_extras(
|
||||
|
|
@ -2022,6 +2383,9 @@ async def table_view_data(
|
|||
data["rows"] = transformed_rows
|
||||
|
||||
if context_for_html_hack:
|
||||
data["count_truncated"] = count_is_truncated(
|
||||
datasette, db, database_name, table_name, count_sql, data.get("count")
|
||||
)
|
||||
data.update(extra_context_from_filters)
|
||||
# filter_columns combine the columns we know are available
|
||||
# in the table with any additional columns (such as rowid)
|
||||
|
|
@ -2068,15 +2432,20 @@ async def table_view_data(
|
|||
table_insert_ui = await _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
)
|
||||
table_alter_ui = await _table_alter_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
)
|
||||
data["table_insert_ui"] = table_insert_ui
|
||||
data["table_alter_ui"] = table_alter_ui
|
||||
data["table_page_data"] = await _table_page_data(
|
||||
datasette,
|
||||
request,
|
||||
db,
|
||||
database_name,
|
||||
table_name,
|
||||
is_view,
|
||||
table_insert_ui,
|
||||
datasette=datasette,
|
||||
request=request,
|
||||
db=db,
|
||||
database_name=database_name,
|
||||
table_name=table_name,
|
||||
is_view=is_view,
|
||||
table_insert_ui=table_insert_ui,
|
||||
table_alter_ui=table_alter_ui,
|
||||
)
|
||||
|
||||
return data, rows[:page_size], columns, expanded_columns, sql, next_url
|
||||
|
|
|
|||
1366
datasette/views/table_create_alter.py
Normal file
1366
datasette/views/table_create_alter.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
import itertools
|
||||
from dataclasses import dataclass
|
||||
|
||||
from datasette.column_types import SQLiteType
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.extras import Extra, ExtraExample, ExtraRegistry, ExtraScope, Provider
|
||||
from datasette.plugins import pm
|
||||
|
|
@ -98,7 +99,7 @@ class QueryExtraContext:
|
|||
|
||||
|
||||
class CountSqlExtra(Extra):
|
||||
description = "SQL query used to calculate the total count"
|
||||
description = "SQL query string used to calculate the total count for the current table view, including active filters."
|
||||
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
|
|
@ -127,8 +128,8 @@ class CountExtra(Extra):
|
|||
pass
|
||||
|
||||
if context.count_sql and count is None and not context.nocount:
|
||||
count_sql_limited = (
|
||||
f"select count(*) from (select * {context.from_sql} limit 10001)"
|
||||
count_sql_limited = "select count(*) from (select * {} limit {})".format(
|
||||
context.from_sql, context.db.count_limit + 1
|
||||
)
|
||||
try:
|
||||
count_rows = list(
|
||||
|
|
@ -140,6 +141,39 @@ class CountExtra(Extra):
|
|||
return count
|
||||
|
||||
|
||||
def count_is_truncated(datasette, db, database_name, table_name, count_sql, count):
|
||||
if count != db.count_limit + 1:
|
||||
return False
|
||||
if (
|
||||
not db.is_mutable
|
||||
and datasette.inspect_data
|
||||
and count_sql == f"select count(*) from {table_name} "
|
||||
):
|
||||
try:
|
||||
datasette.inspect_data[database_name]["tables"][table_name]["count"]
|
||||
return False
|
||||
except KeyError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
class CountTruncatedExtra(Extra):
|
||||
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
|
||||
scopes = {ExtraScope.TABLE}
|
||||
expensive = True
|
||||
|
||||
async def resolve(self, context, count):
|
||||
return count_is_truncated(
|
||||
context.datasette,
|
||||
context.db,
|
||||
context.database_name,
|
||||
context.table_name,
|
||||
context.count_sql,
|
||||
count,
|
||||
)
|
||||
|
||||
|
||||
class FacetInstancesProvider(Provider):
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
|
|
@ -165,7 +199,7 @@ class FacetInstancesProvider(Provider):
|
|||
|
||||
|
||||
class FacetResultsExtra(Extra):
|
||||
description = "Results of facets calculated against this data"
|
||||
description = "Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``."
|
||||
example = ExtraExample(
|
||||
value={
|
||||
"results": {
|
||||
|
|
@ -214,7 +248,9 @@ class FacetResultsExtra(Extra):
|
|||
|
||||
|
||||
class FacetsTimedOutExtra(Extra):
|
||||
description = "Facet calculations that timed out"
|
||||
description = (
|
||||
"List of names of facet calculations that exceeded the facet time limit."
|
||||
)
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_facet=state&_extra=facets_timed_out",
|
||||
note=(
|
||||
|
|
@ -230,7 +266,7 @@ class FacetsTimedOutExtra(Extra):
|
|||
|
||||
|
||||
class SuggestedFacetsExtra(Extra):
|
||||
description = "Suggestions for facets that might return interesting results"
|
||||
description = "Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class."
|
||||
example = ExtraExample(
|
||||
value=[
|
||||
{
|
||||
|
|
@ -285,23 +321,8 @@ class HumanDescriptionEnExtra(Extra):
|
|||
return human_description_en
|
||||
|
||||
|
||||
class NextUrlExtra(Extra):
|
||||
description = "Full URL for the next page of results"
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_size=1&_extra=next_url",
|
||||
note=(
|
||||
"``null`` if there are no more pages of results. "
|
||||
"See :ref:`json_api_pagination`."
|
||||
),
|
||||
)
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.next_url
|
||||
|
||||
|
||||
class ColumnsExtra(Extra):
|
||||
description = "Column names returned by this query"
|
||||
description = "List of column names returned by this table, row or query."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -318,7 +339,7 @@ class ColumnsExtra(Extra):
|
|||
|
||||
|
||||
class AllColumnsExtra(Extra):
|
||||
description = "All columns in the table, regardless of _col/_nocol filtering"
|
||||
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
|
||||
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
|
|
@ -327,7 +348,7 @@ class AllColumnsExtra(Extra):
|
|||
|
||||
|
||||
class PrimaryKeysExtra(Extra):
|
||||
description = "Primary keys for this table"
|
||||
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -340,8 +361,57 @@ class PrimaryKeysExtra(Extra):
|
|||
return context.pks
|
||||
|
||||
|
||||
def column_detail_as_json(column):
|
||||
return {
|
||||
"type": column.type,
|
||||
"sqlite_type": SQLiteType.from_declared_type(column.type).value,
|
||||
"notnull": bool(column.notnull),
|
||||
"default": column.default_value,
|
||||
"is_pk": bool(column.is_pk),
|
||||
"pk_position": column.is_pk,
|
||||
"hidden": column.hidden,
|
||||
}
|
||||
|
||||
|
||||
class ColumnDetailsExtra(Extra):
|
||||
description = (
|
||||
"SQLite schema details for columns in this table. The dictionary maps "
|
||||
"column names to objects describing the schema for each column."
|
||||
)
|
||||
docs_note = (
|
||||
"Each object has ``type`` as the declared type string returned by "
|
||||
'SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the '
|
||||
"normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, "
|
||||
"``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` "
|
||||
'as the raw SQL default expression string, such as ``"42"``, '
|
||||
"``\"'hello'\"`` or ``\"datetime('now')\"``, or ``null`` if there is "
|
||||
"no default; ``is_pk`` as a boolean; ``pk_position`` as the integer "
|
||||
"primary key position reported by SQLite, or ``0`` for columns that "
|
||||
"are not part of the primary key; and ``hidden`` as the integer value "
|
||||
"reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for "
|
||||
"normal columns, ``1`` for hidden virtual table columns, ``2`` for "
|
||||
"virtual generated columns and ``3`` for stored generated columns."
|
||||
)
|
||||
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/binary_data/1.json?_extra=column_details"
|
||||
)
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
|
||||
async def resolve(self, context):
|
||||
column_details = await context.datasette._get_resource_column_details(
|
||||
context.database_name, context.table_name
|
||||
)
|
||||
return {
|
||||
column_name: column_detail_as_json(column)
|
||||
for column_name, column in column_details.items()
|
||||
}
|
||||
|
||||
|
||||
class ActionsExtra(Extra):
|
||||
description = "Table or view actions made available by plugin hooks"
|
||||
description = 'Async callable returning table or view 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`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Returns an async function for the HTML templates - not JSON serializable
|
||||
public = False
|
||||
|
|
@ -367,23 +437,14 @@ class ActionsExtra(Extra):
|
|||
# that allowed() calls made inside the plugin hooks below
|
||||
# are served from the cache
|
||||
datasette = context.datasette
|
||||
await datasette.allowed_many(
|
||||
actions=[
|
||||
name
|
||||
for name, action in datasette.actions.items()
|
||||
if action.resource_class is TableResource
|
||||
],
|
||||
resource=TableResource(context.database_name, context.table_name),
|
||||
actor=context.request.actor,
|
||||
await precompute_table_action_permissions(
|
||||
datasette,
|
||||
context.request.actor,
|
||||
context.database_name,
|
||||
context.table_name,
|
||||
)
|
||||
await datasette.allowed_many(
|
||||
actions=[
|
||||
name
|
||||
for name, action in datasette.actions.items()
|
||||
if action.resource_class is DatabaseResource
|
||||
],
|
||||
resource=DatabaseResource(context.database_name),
|
||||
actor=context.request.actor,
|
||||
await precompute_database_action_permissions(
|
||||
datasette, context.request.actor, context.database_name
|
||||
)
|
||||
for hook in method(**kwargs):
|
||||
extra_links = await await_me_maybe(hook)
|
||||
|
|
@ -394,6 +455,32 @@ class ActionsExtra(Extra):
|
|||
return actions
|
||||
|
||||
|
||||
async def precompute_table_action_permissions(
|
||||
datasette, actor, database_name, table_name
|
||||
):
|
||||
await datasette.allowed_many(
|
||||
actions=[
|
||||
name
|
||||
for name, action in datasette.actions.items()
|
||||
if action.resource_class is TableResource
|
||||
],
|
||||
resource=TableResource(database_name, table_name),
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
|
||||
async def precompute_database_action_permissions(datasette, actor, database_name):
|
||||
await datasette.allowed_many(
|
||||
actions=[
|
||||
name
|
||||
for name, action in datasette.actions.items()
|
||||
if action.resource_class is DatabaseResource
|
||||
],
|
||||
resource=DatabaseResource(database_name),
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
|
||||
class IsViewExtra(Extra):
|
||||
description = "Whether this resource is a view instead of a table"
|
||||
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
|
||||
|
|
@ -404,7 +491,7 @@ class IsViewExtra(Extra):
|
|||
|
||||
|
||||
class DebugExtra(Extra):
|
||||
description = "Extra debug information"
|
||||
description = "Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract."
|
||||
docs_note = (
|
||||
"The contents of this block are not a stable part of the Datasette "
|
||||
"API and may change without warning."
|
||||
|
|
@ -440,7 +527,7 @@ class DebugExtra(Extra):
|
|||
|
||||
|
||||
class RequestExtra(Extra):
|
||||
description = "Full information about the request"
|
||||
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=request")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -484,7 +571,7 @@ class DisplayColumnsAndRowsProvider(Provider):
|
|||
|
||||
|
||||
class DisplayColumnsExtra(Extra):
|
||||
description = "Column metadata used by the HTML table display"
|
||||
description = "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."
|
||||
example = ExtraExample(
|
||||
value=[
|
||||
{
|
||||
|
|
@ -514,7 +601,7 @@ class DisplayColumnsExtra(Extra):
|
|||
|
||||
|
||||
class DisplayRowsExtra(Extra):
|
||||
description = "Row data formatted for the HTML table display"
|
||||
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Contains markupsafe/sqlite3.Row values - not JSON serializable
|
||||
public = False
|
||||
|
|
@ -623,7 +710,7 @@ class RenderCellExtra(Extra):
|
|||
|
||||
|
||||
class QueryExtra(Extra):
|
||||
description = "Details of the underlying SQL query"
|
||||
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
|
||||
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -644,7 +731,7 @@ class QueryExtra(Extra):
|
|||
|
||||
|
||||
class ColumnTypesExtra(Extra):
|
||||
description = "Column type assignments for this table"
|
||||
description = 'Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries.'
|
||||
docs_note = (
|
||||
"An empty object if no column types have been assigned. Column types "
|
||||
"can be assigned in :ref:`configuration "
|
||||
|
|
@ -683,7 +770,7 @@ class ColumnTypesExtra(Extra):
|
|||
|
||||
|
||||
class SetColumnTypeUiExtra(Extra):
|
||||
description = "Information needed to build an interface for assigning column types"
|
||||
description = "Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values."
|
||||
docs_note = (
|
||||
"``null`` unless the current actor is allowed to use the :ref:`set "
|
||||
"column type API <TableSetColumnTypeView>` for this table."
|
||||
|
|
@ -767,7 +854,7 @@ class SetColumnTypeUiExtra(Extra):
|
|||
|
||||
|
||||
class MetadataExtra(Extra):
|
||||
description = "Metadata about the table, database or stored query"
|
||||
description = "Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration."
|
||||
docs_note = "See :ref:`metadata` for how to attach metadata to tables."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_extra=metadata",
|
||||
|
|
@ -874,7 +961,7 @@ class DatabaseColorExtra(Extra):
|
|||
|
||||
|
||||
class FormHiddenArgsExtra(Extra):
|
||||
description = "Hidden form arguments used by the HTML table interface"
|
||||
description = "List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
|
||||
)
|
||||
|
|
@ -894,7 +981,7 @@ class FormHiddenArgsExtra(Extra):
|
|||
|
||||
|
||||
class FiltersExtra(Extra):
|
||||
description = "Filters object used by the HTML table interface"
|
||||
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Returns a Filters instance for the HTML templates - not JSON serializable
|
||||
public = False
|
||||
|
|
@ -904,7 +991,7 @@ class FiltersExtra(Extra):
|
|||
|
||||
|
||||
class CustomTableTemplatesExtra(Extra):
|
||||
description = "Custom template names considered for this table"
|
||||
description = "List of custom template names considered for rendering table rows, in lookup order."
|
||||
docs_note = (
|
||||
"The first template in this list that exists will be used to render "
|
||||
"the table on the HTML version of this page. See "
|
||||
|
|
@ -922,7 +1009,7 @@ class CustomTableTemplatesExtra(Extra):
|
|||
|
||||
|
||||
class SortedFacetResultsExtra(Extra):
|
||||
description = "Facet results sorted for display"
|
||||
description = "Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``."
|
||||
docs_note = (
|
||||
"The same data as ``facet_results``, as a list in the order used by "
|
||||
"the HTML interface: facets from :ref:`facet configuration "
|
||||
|
|
@ -984,7 +1071,7 @@ class ViewDefinitionExtra(Extra):
|
|||
|
||||
|
||||
class RenderersExtra(Extra):
|
||||
description = "Alternative output renderers available for this table"
|
||||
description = "Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_extra=renderers",
|
||||
note=(
|
||||
|
|
@ -1051,7 +1138,7 @@ class PrivateExtra(Extra):
|
|||
|
||||
|
||||
class ExpandableColumnsExtra(Extra):
|
||||
description = "Foreign key columns that can be expanded with labels"
|
||||
description = "List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``."
|
||||
docs_note = "See :ref:`expand_foreign_keys` for how to expand these labels."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_extra=expandable_columns",
|
||||
|
|
@ -1073,7 +1160,7 @@ class ExpandableColumnsExtra(Extra):
|
|||
|
||||
|
||||
class ForeignKeyTablesExtra(Extra):
|
||||
description = "Tables that link to this row using foreign keys"
|
||||
description = "List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL."
|
||||
example = ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables",
|
||||
note=(
|
||||
|
|
@ -1091,7 +1178,7 @@ class ForeignKeyTablesExtra(Extra):
|
|||
|
||||
|
||||
class ExtrasExtra(Extra):
|
||||
description = "List of ?_extra= blocks that can be used on this page"
|
||||
description = "List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys."
|
||||
example = ExtraExample(
|
||||
value=[
|
||||
{
|
||||
|
|
@ -1148,7 +1235,6 @@ TABLE_EXTRA_BUNDLES = {
|
|||
"count",
|
||||
"count_sql",
|
||||
"human_description_en",
|
||||
"next_url",
|
||||
"metadata",
|
||||
"query",
|
||||
"columns",
|
||||
|
|
@ -1177,16 +1263,17 @@ TABLE_EXTRA_BUNDLES = {
|
|||
|
||||
TABLE_EXTRA_CLASSES = [
|
||||
CountExtra,
|
||||
CountTruncatedExtra,
|
||||
CountSqlExtra,
|
||||
FacetResultsExtra,
|
||||
FacetsTimedOutExtra,
|
||||
SuggestedFacetsExtra,
|
||||
FacetInstancesProvider,
|
||||
HumanDescriptionEnExtra,
|
||||
NextUrlExtra,
|
||||
ColumnsExtra,
|
||||
AllColumnsExtra,
|
||||
PrimaryKeysExtra,
|
||||
ColumnDetailsExtra,
|
||||
DisplayColumnsAndRowsProvider,
|
||||
DisplayColumnsExtra,
|
||||
DisplayRowsExtra,
|
||||
|
|
|
|||
|
|
@ -138,6 +138,33 @@ def decision_for_write_sql_operation(
|
|||
),
|
||||
)
|
||||
)
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ help:
|
|||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
livehtml:
|
||||
sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)
|
||||
sphinx-autobuild -b html --watch ../datasette "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ The actor dictionary can be any shape - the design of that data structure is lef
|
|||
|
||||
Plugins can use the :ref:`plugin_hook_actor_from_request` hook to implement custom logic for authenticating an actor based on the incoming HTTP request.
|
||||
|
||||
.. _authentication_actor_display:
|
||||
|
||||
How actors are displayed
|
||||
------------------------
|
||||
|
||||
In a number of places - such as the navigation menu and the ``/-/logout`` page - Datasette needs to display a short label representing the currently authenticated actor.
|
||||
|
||||
To decide what to show, Datasette looks through the following keys in the actor dictionary and uses the value of the first one that is present and not empty:
|
||||
|
||||
* ``display``
|
||||
* ``name``
|
||||
* ``username``
|
||||
* ``login``
|
||||
* ``id``
|
||||
|
||||
If none of those keys have a value the actor dictionary is displayed as a string instead.
|
||||
|
||||
.. _authentication_root:
|
||||
|
||||
Using the "root" actor
|
||||
|
|
@ -28,12 +45,12 @@ Using the "root" actor
|
|||
|
||||
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
|
||||
|
||||
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.
|
||||
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user starts with **all permissions**: Datasette contributes a global allow rule for every action. More specific deny rules can still override that global rule.
|
||||
|
||||
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
|
||||
|
||||
* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
|
||||
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``)
|
||||
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``)
|
||||
* Debug permissions (``permissions-debug``, ``debug-menu``)
|
||||
* Any custom permissions defined by plugins
|
||||
|
||||
|
|
@ -67,12 +84,12 @@ Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm t
|
|||
Permissions
|
||||
===========
|
||||
|
||||
Datasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access.
|
||||
|
||||
The key question the permissions system answers is this:
|
||||
|
||||
Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
|
||||
|
||||
Every permission decision can be understood in terms of those three values. Datasette implements the decisions using SQL, but you do not need to understand the generated SQL to configure or debug permissions.
|
||||
|
||||
**Actors** are :ref:`described above <authentication_actor>`.
|
||||
|
||||
An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.
|
||||
|
|
@ -121,7 +138,51 @@ This configuration will deny access to everyone except the user with ``id`` of `
|
|||
How permissions are resolved
|
||||
----------------------------
|
||||
|
||||
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
Permission rules describe an effect (``allow`` or ``deny``) at one of three levels:
|
||||
|
||||
``resource``
|
||||
A specific child resource, such as the ``analytics/sales`` table.
|
||||
|
||||
``parent``
|
||||
A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources.
|
||||
|
||||
``global``
|
||||
Every resource for that action.
|
||||
|
||||
Datasette resolves matching rules from most specific to least specific:
|
||||
|
||||
#. Resource rules take precedence over parent and global rules.
|
||||
#. Parent rules take precedence over global rules.
|
||||
#. If both allow and deny rules match at the same level, deny takes precedence.
|
||||
#. If no rule matches, access is denied.
|
||||
|
||||
This means a resource-level allow can provide an exception to a parent-level deny. It also means that two plugins which disagree at the same level resolve to deny.
|
||||
|
||||
.. list-table:: Permission rule examples
|
||||
:header-rows: 1
|
||||
|
||||
* - Matching rules
|
||||
- Result
|
||||
- Explanation
|
||||
* - Global allow
|
||||
- Allow
|
||||
- The global rule is the most specific matching rule.
|
||||
* - Global allow, parent deny
|
||||
- Deny
|
||||
- The parent rule is more specific.
|
||||
* - Parent deny, resource allow
|
||||
- Allow
|
||||
- The resource rule is more specific.
|
||||
* - Resource allow and resource deny
|
||||
- Deny
|
||||
- Deny takes precedence at the same level.
|
||||
* - No matching rules
|
||||
- Deny
|
||||
- Permissions default to deny when no rule applies.
|
||||
|
||||
The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules.
|
||||
|
||||
Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
|
||||
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
|
||||
|
||||
|
|
@ -132,12 +193,12 @@ resources were allowed or denied. The combined sources are:
|
|||
|
||||
* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.
|
||||
* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.
|
||||
* The "root" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level.
|
||||
* The "root" user rule when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active. This is a global allow rule, so a more specific configuration deny can override it.
|
||||
* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.
|
||||
|
||||
Datasette evaluates the SQL to determine if the requested ``resource`` is
|
||||
included. Explicit deny rules returned by configuration or plugins will block
|
||||
access even if other rules allowed it.
|
||||
Actor restrictions are applied after the allow/deny rules. They act as an additional allowlist: a restriction can remove access but cannot grant access that the actor did not already have. See :ref:`authentication_cli_create_token_restrict`.
|
||||
|
||||
Some actions have dependencies on other actions. These are evaluated as an ``AND`` condition. For example, ``execute-sql`` also requires ``view-database``: both decisions must be allowed for the final result to be allowed.
|
||||
|
||||
.. _authentication_permissions_allow:
|
||||
|
||||
|
|
@ -974,7 +1035,9 @@ The ``/-/create-token`` page cannot be accessed by actors that are authenticated
|
|||
|
||||
Datasette plugins that implement their own form of API token authentication should follow this convention.
|
||||
|
||||
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting.
|
||||
If a request presents a token that a token handler recognizes but rejects - an invalid signature, a malformed payload or an expired token - Datasette responds with a ``401`` status, the :ref:`standard JSON error format <json_api_errors>` and a ``WWW-Authenticate: Bearer error="invalid_token"`` header. This means API clients can distinguish "your token needs to be renewed" (``401``) from "your token does not grant this permission" (``403``). A ``Bearer`` token that no registered handler recognizes at all is ignored, since it may be intended for an authentication plugin.
|
||||
|
||||
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting. Requests presenting a ``dstok_`` token while the feature is disabled receive a ``401``.
|
||||
|
||||
.. _authentication_cli_create_token:
|
||||
|
||||
|
|
@ -1126,11 +1189,23 @@ The debug tool at ``/-/permissions`` is available to any actor with the ``permis
|
|||
|
||||
datasette -s permissions.permissions-debug true data.db
|
||||
|
||||
The page shows the permission checks that have been carried out by the Datasette instance.
|
||||
The permission debug tools answer four different questions:
|
||||
|
||||
It also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect.
|
||||
Why was this decision allowed or denied?
|
||||
Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions.
|
||||
|
||||
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
|
||||
Which resources can the current actor access?
|
||||
Use :ref:`AllowedResourcesView` to view an access map for a selected action.
|
||||
|
||||
Which raw rules did Datasette and its plugins contribute?
|
||||
Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions.
|
||||
|
||||
Which checks has this Datasette instance performed recently?
|
||||
Use ``/-/permissions`` to view recent permission activity.
|
||||
|
||||
These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration.
|
||||
|
||||
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
|
||||
|
||||
.. _AllowedResourcesView:
|
||||
|
||||
|
|
@ -1141,7 +1216,7 @@ The ``/-/allowed`` endpoint displays resources that the current actor can access
|
|||
|
||||
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead.
|
||||
|
||||
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair.
|
||||
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair. Results are paginated: ``?_size=`` sets the page size (default 50, maximum 200, ``max`` for the maximum) and ``?_page=`` selects a page.
|
||||
|
||||
This endpoint is publicly accessible to help users understand their own permissions. The potentially sensitive ``reason`` field is only shown to users with the ``permissions-debug`` permission - it shows the plugins and explanatory reasons that were responsible for each decision.
|
||||
|
||||
|
|
@ -1154,7 +1229,7 @@ The ``/-/rules`` endpoint displays all permission rules (both allow and deny) fo
|
|||
|
||||
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead.
|
||||
|
||||
Pass ``?action=`` as a query parameter to specify which action to check.
|
||||
Pass ``?action=`` as a query parameter to specify which action to check. The ``?_size=`` and ``?_page=`` pagination parameters work the same as on ``/-/allowed``.
|
||||
|
||||
This endpoint requires the ``permissions-debug`` permission.
|
||||
|
||||
|
|
@ -1163,11 +1238,20 @@ This endpoint requires the ``permissions-debug`` permission.
|
|||
Permission check view
|
||||
---------------------
|
||||
|
||||
The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information.
|
||||
The ``/-/check`` endpoint evaluates and explains a single actor, action and resource decision. The explanation includes:
|
||||
|
||||
* Every matching allow and deny rule, with its source and reason.
|
||||
* The winning resource, parent or global scope.
|
||||
* Rules ignored because a more specific rule matched, or because a deny won at the same scope.
|
||||
* Actor restriction allowlists that included or excluded the resource.
|
||||
* Additional actions required by the requested action.
|
||||
* An explicit default-deny explanation when no rule matched.
|
||||
|
||||
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.
|
||||
|
||||
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource.
|
||||
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. The interactive form also accepts actor JSON, allowing a hypothetical actor to be tested without signing in as that actor. The JSON endpoint accepts the same value using the ``actor`` query string parameter. Use ``actor=null`` to represent an anonymous actor.
|
||||
|
||||
This endpoint requires the ``permissions-debug`` permission. The hypothetical actor is used only for the decision being explained; access to the debug tool is checked against the actor who is actually signed in.
|
||||
|
||||
.. _authentication_ds_actor:
|
||||
|
||||
|
|
@ -1369,6 +1453,16 @@ create-table
|
|||
|
||||
Actor is allowed to create a database table.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
.. _actions_create_view:
|
||||
|
||||
create-view
|
||||
-----------
|
||||
|
||||
Actor is allowed to create a database view.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
|
|
@ -1408,6 +1502,18 @@ Actor is allowed to drop a database table.
|
|||
|
||||
``table`` is the name of the table (string)
|
||||
|
||||
.. _actions_drop_view:
|
||||
|
||||
drop-view
|
||||
---------
|
||||
|
||||
Actor is allowed to drop a database view.
|
||||
|
||||
``resource`` - ``datasette.resources.TableResource(database, table)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``table`` is the name of the view (string)
|
||||
|
||||
.. _actions_execute_sql:
|
||||
|
||||
execute-sql
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ Datasette includes special handling for these binary values. The Datasette inter
|
|||
:width: 311px
|
||||
:alt: Screenshot showing download links next to binary data in the table view
|
||||
|
||||
Binary data is represented in ``.json`` exports using Base64 encoding.
|
||||
.. _binary_json_format:
|
||||
|
||||
Binary values in JSON
|
||||
---------------------
|
||||
|
||||
Binary data is represented in ``.json`` exports using Base64 encoding. Datasette uses this representation for every ``BLOB`` value, including binary values that could also be decoded as UTF-8 text.
|
||||
|
||||
https://latest.datasette.io/fixtures/binary_data.json?_shape=array
|
||||
|
||||
|
|
@ -39,6 +44,48 @@ https://latest.datasette.io/fixtures/binary_data.json?_shape=array
|
|||
}
|
||||
]
|
||||
|
||||
The same format can be used with the :ref:`JSON write API <json_api_write>`.
|
||||
If a column value in a ``row``, ``rows`` or ``update`` object is a JSON object with exactly ``"$base64"`` set to ``true`` and an ``"encoded"`` string, Datasette will decode that Base64 string and store the resulting bytes:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"$base64": true,
|
||||
"encoded": "FRwCx60F/g=="
|
||||
}
|
||||
}
|
||||
|
||||
This works for inserts, upserts and updates. It also works when creating a table from example ``row`` or ``rows`` data: Datasette decodes the value before inferring the schema, allowing that column to be created as a ``BLOB`` column.
|
||||
|
||||
To store a JSON object with that exact shape literally, wrap it in a ``"$raw"`` object:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"$raw": {
|
||||
"$base64": true,
|
||||
"encoded": "FRwCx60F/g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
``"$raw"`` unwraps exactly one layer. To store a literal ``"$raw"`` object containing a Base64 object, wrap it again:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"$raw": {
|
||||
"$raw": {
|
||||
"$base64": true,
|
||||
"encoded": "FRwCx60F/g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.. _binary_linking:
|
||||
|
||||
Linking to binary downloads
|
||||
|
|
|
|||
|
|
@ -4,6 +4,96 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
.. _v1_0_a37:
|
||||
|
||||
1.0a37 (2026-07-14)
|
||||
-------------------
|
||||
|
||||
Performance improvement for SQL-backed permission checks, plus an improved permission debugging interface.
|
||||
|
||||
- SQL used to resolve permission checks now aggregates permission rules before joining them to resources, improving performance on instances with large schemas. (:issue:`2832`)
|
||||
- The :ref:`PermissionCheckView` permission debugger now explains why a decision was allowed or denied, including the matching rules. The interactive form can also test a hypothetical actor supplied as JSON, and the :ref:`permissions documentation <authentication_permissions_explained>` now describes resolution rules in more detail. (:issue:`2841`)
|
||||
- :ref:`db.execute_write(sql, ..., transaction=True) <database_execute_write>` has a new ``transaction=`` parameter, which can be set to ``False`` for statements such as ``VACUUM`` that cannot run inside a transaction. Write tasks now start their transactions using ``BEGIN IMMEDIATE``, which also ensures that writes are rolled back if the task fails. (:issue:`2831`)
|
||||
- Refreshing a database's schema in Datasette's internal catalog is now performed as a single atomic operation. (:issue:`2831`)
|
||||
- Fixed schema introspection, table pages, facets and table counts for tables with names containing a ``]`` character. Thanks, `TowyTowy <https://github.com/TowyTowy>`__. (:issue:`2431`, :pr:`2846`)
|
||||
- ``/-/plugins.json`` once again returns a top-level JSON array of plugin objects, reverting the object envelope introduced in 1.0a36. This should fix a large number of trivial test failures in existing plugins. (:issue:`2842`, :pr:`2843`)
|
||||
|
||||
.. _v1_0_a36:
|
||||
|
||||
1.0a36 (2026-07-07)
|
||||
-------------------
|
||||
|
||||
The signature features of this alpha are new UIs for **inserting multiple rows at once** (from TSV, CSV or JSON) and for **creating a table from rows**, plus a large number of small **JSON API consistency fixes** in preparation for a 1.0 stable release.
|
||||
|
||||
- Table pages now offer an "Insert multiple rows" mode in the row insertion dialog. This accepts pasted TSV, CSV or JSON, previews the parsed rows before inserting them, validates unknown columns as data is pasted and displays omitted auto integer primary keys as ``auto`` in the preview. (:pr:`2813`)
|
||||
- The bulk insert UI can skip rows with existing primary keys, or update existing rows and insert new rows using the existing ``/<database>/<table>/-/upsert`` API when the actor has both :ref:`insert-row <actions_insert_row>` and :ref:`update-row <actions_update_row>` permissions. (:pr:`2813`)
|
||||
- The "Create table" dialog now includes a "Create table from data" mode. Paste TSV, CSV or JSON rows to preview inferred columns and types, choose the table name and primary key, then create the table and insert those rows in one step. (:pr:`2813`)
|
||||
- Datasette's JSON APIs now consistently encode every ``BLOB`` value using the documented :ref:`binary value JSON format <binary_json_format>`, even when the bytes could be decoded as UTF-8 text. (:issue:`2806`, :pr:`2822`)
|
||||
- The insert and edit row dialogs now provide a dedicated control for ``BLOB`` values. Existing binary values are shown by byte size, image values under 10MB are previewed as thumbnails, and replacements can be attached, dropped or pasted into the control. (:issue:`2806`, :pr:`2822`)
|
||||
- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata.
|
||||
- POST bodies that Datasette reads fully into memory - such as JSON submitted to the write API - are now capped by the new :ref:`setting_max_post_body_bytes` setting, defaulting to 2MB. Oversized requests are rejected with an HTTP 413 error as soon as the limit is exceeded, protecting smaller servers from memory exhaustion. File uploads are unaffected - ``request.form()`` streams those to disk and has its own separate limits. (:issue:`2823`)
|
||||
- Row pages for tables with compound primary keys now return a ``400`` error instead of a ``500`` error when the URL row identifier does not contain the correct number of primary key values. Thanks, `Zain Dana Harper <https://github.com/HarperZ9>`__. (:issue:`2811`, :pr:`2815`)
|
||||
- The :ref:`execute-write-sql <actions_execute_write_sql>` interface now supports ``CREATE VIEW`` and ``DROP VIEW`` statements, gated by the new :ref:`create-view <actions_create_view>` and :ref:`drop-view <actions_drop_view>` permissions. (:issue:`2819`, :pr:`2818`)
|
||||
- Saved-query SQL analysis now handles recursive CTEs, fixing a bug where storing a valid read-only recursive query could be disabled by SQLite's internal ``SQLITE_RECURSIVE`` authorizer callback. (:issue:`2809`, :pr:`2812`)
|
||||
- ``named_parameters()`` now correctly ignores SQLite comment markers that appear inside string literals, so query forms no longer drop later ``:named`` parameters from SQL such as ``select '--' || :name``. Thanks, `JSap0914 <https://github.com/JSap0914>`__. (:pr:`2783`)
|
||||
- Datasette's internal database schema is now managed using `sqlite-utils migrations <https://sqlite-utils.datasette.io/en/stable/python-api.html#migrations>`__, using the new dependency on ``sqlite-utils>=4.0``. (:issue:`2827`)
|
||||
- ``datasette.utils.CustomJSONEncoder`` is now documented as a public API for plugins that need to serialize Datasette values to JSON. Thanks, `Chris Amico <https://github.com/eyeseast>`__. (:issue:`1983`, :pr:`1996`)
|
||||
|
||||
This release also includes the results of a `detailed consistency review <https://github.com/simonw/datasette/pull/2824>`__ of Datasette's JSON API in preparation for the 1.0 stable release. Several of these changes are backwards-incompatible with previous 1.0 alphas. The new :ref:`API stability documentation <json_api_stability>` describes exactly which parts of the JSON API are covered by the 1.0 stability promise.
|
||||
|
||||
JSON API: breaking changes
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- JSON error responses now use a single canonical format across every endpoint: ``{"ok": false, "error": "...", "errors": [...], "status": 400}``. The ``error`` key joins all error messages together, ``errors`` is the full list of messages and ``status`` always matches the HTTP status code. The legacy ``title`` key is no longer included in JSON errors (it remains available to the HTML error template), and endpoints that previously returned bare ``{"error": ...}`` objects have been updated. See :ref:`json_api_errors`.
|
||||
- Every JSON object success response now includes ``"ok": true``, including introspection endpoints such as ``/-/versions`` and ``/-/settings``.
|
||||
- ``/-/plugins.json``, ``/-/databases.json`` and ``/-/actions.json`` now return objects - ``{"ok": true, "plugins": [...]}`` and equivalents - instead of top-level JSON arrays, so these responses can gain additional keys in the future without a breaking change. The ``datasette plugins`` CLI command still outputs a plain array.
|
||||
- ``/-/databases`` now only lists databases the current actor is allowed to view. It previously listed every attached database, including their filesystem paths, to any actor with ``view-instance``.
|
||||
- Requests with an invalid or expired ``Authorization: Bearer`` token now receive a ``401`` status with the standard error body and a ``WWW-Authenticate: Bearer error="invalid_token"`` header, instead of being silently treated as unauthenticated. Bearer tokens that no registered token handler recognizes are still ignored, so authentication plugins with their own token formats keep working. Plugin :ref:`token handlers <plugin_hook_register_token_handler>` can raise the new ``datasette.TokenInvalid`` exception to trigger the same behavior.
|
||||
- Permission errors for JSON requests now return the standard JSON error format with a ``403`` status. The default forbidden handling previously rendered an HTML error page even for ``.json`` requests.
|
||||
- ``POST`` to a write canned query now returns a ``400`` error when the SQL fails to execute, instead of a ``200`` status with ``"ok": false`` in the body. The error response includes the standard error keys plus a ``"redirect"`` key.
|
||||
- The :ref:`row update API <RowUpdateView>` with ``"return": true`` now responds with a ``"rows"`` list, matching insert and upsert, instead of a singular ``"row"`` object.
|
||||
- Row delete write failures - such as a constraint violation raised by a trigger - now return ``400`` instead of ``500``, matching the other write endpoints.
|
||||
- ``/<database>/-/query.json`` with a missing or blank ``?sql=`` parameter now returns a ``400`` error, as the CSV format already did, instead of a ``200`` with empty rows.
|
||||
- Unknown ``?_extra=`` names now return a ``400`` error for JSON and other data formats, instead of being silently ignored. HTML pages continue to ignore unknown names.
|
||||
- Table JSON responses now include ``next_url`` alongside ``next`` by default - both are ``null`` on the final page. The now-redundant ``?_extra=next_url`` parameter has been removed.
|
||||
- The stored query list JSON no longer includes ``has_more`` - ``"next": null`` is the end-of-results signal across the whole API. This change also uncovered and fixed a bug where the query list ``next_url`` pointed at the HTML page and was a relative path; it is now an absolute URL that preserves the requested format.
|
||||
- Stored query JSON objects no longer duplicate the list of parameter names as both ``params`` and ``parameters`` - only ``parameters`` remains. The query create and update APIs no longer accept ``params`` as an input alias either; ``params`` is still the documented key for :ref:`queries defined in configuration <queries_named_parameters>`.
|
||||
- Page size parameters are now consistent across the API: the stored query lists accept ``?_size=max`` and return a ``400`` error for values over the maximum instead of silently clamping them, and the ``/-/allowed`` and ``/-/rules`` permission debug endpoints renamed their ``page`` and ``page_size`` parameters to ``_page`` and ``_size``, matching the underscore grammar used by every other Datasette system parameter.
|
||||
- ``/-/threads`` now requires the ``permissions-debug`` permission, since it exposes runtime internals such as file paths. It previously only required ``view-instance``.
|
||||
- Trusted stored queries - those defined in configuration - can no longer be deleted through the JSON API or web interface, matching the existing restriction on editing them.
|
||||
- The ``/<database>/-/schema`` endpoints now check the ``view-database`` permission before checking whether the database exists, so unauthorized actors can no longer probe for the existence of databases.
|
||||
- SQL time limit errors in JSON responses are now a plain text message. The error string previously embedded an HTML fragment.
|
||||
- The undocumented homepage JSON at ``/.json`` now returns ``databases`` as a list of objects rather than an object keyed by database name, matching every other collection in the API.
|
||||
- The legacy ``.jsono`` format extension, long since superseded by ``?_shape=``, has been removed.
|
||||
|
||||
JSON API: other improvements
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- The :ref:`write API <json_api_write>` endpoints now parse the request body as JSON regardless of the ``Content-Type`` header, so ``curl -d`` invocations work without remembering to set it. Invalid JSON is a ``400`` error. Cross-site request forgery remains prevented by Datasette's ``Origin`` and ``Sec-Fetch-Site`` checks. This also fixes a ``500`` error from the insert API when the ``Content-Type`` header was missing entirely.
|
||||
- New ``Response.error(messages, status=400)`` helper for plugins that need to return a JSON error in Datasette's standard format. See :ref:`internals_response`.
|
||||
- New ``count_truncated`` extra for table JSON, included automatically whenever ``count`` is requested. ``true`` means the count reached Datasette's counting limit and the real number of rows may be higher. See :ref:`json_api_extra`.
|
||||
- JSON endpoints that are not part of the documented stable API now declare themselves with an ``"unstable"`` key in their responses.
|
||||
- New documentation covering the grammar for :ref:`boolean query string arguments <json_api_table_arguments>`, the reason :ref:`upsert <TableUpsertView>` returns ``200`` where insert returns ``201``, and advice for plugin authors on :ref:`naming secret configuration keys <plugins_configuration_secret>` so that ``/-/config`` redacts them automatically.
|
||||
|
||||
.. _v1_0_a35:
|
||||
|
||||
1.0a35 (2026-06-23)
|
||||
-------------------
|
||||
|
||||
This release adds UI for **creating tables** and **altering tables**, to complement the insert and update row interfaces added in :ref:`v1_0_a34`.
|
||||
|
||||
- New "Create table" interface in the database actions menu, backed by the ``/<database>/-/create`` :ref:`JSON API <TableCreateView>`. It can define columns, primary keys, custom column types, ``NOT NULL`` constraints, literal defaults, expression defaults and single-column foreign keys. (:issue:`2787`)
|
||||
- New "Alter table" table action and ``/<database>/<table>/-/alter`` :ref:`JSON API <TableAlterView>` for changing existing tables: add, rename, reorder and drop columns; change column types, defaults, ``NOT NULL`` constraints, primary keys and foreign keys; and rename the table. The alter table dialog also includes a "Drop table" button. (:issue:`2788`)
|
||||
- New ``/<database>/-/foreign-key-targets`` and ``/<database>/<table>/-/foreign-key-suggestions`` JSON APIs for discovering valid single-column foreign key targets and suggested relationships.
|
||||
- New :ref:`template_context` documentation listing the variables available to custom templates for Datasette's core pages. Variables documented there are treated as a stable API for custom templates until Datasette 2.0. The documentation is generated from dataclass definitions next to the view code, with tests that compare the documented fields against the actual contexts rendered by the database, table, query and row pages. (:issue:`1510`, :issue:`2127`, :issue:`1477`, :pr:`2803`)
|
||||
- The "Write to this database" page now includes a Create table starter template, alongside the existing Insert, Update and Delete templates. (:pr:`2794`)
|
||||
- New ``static()`` template function and ``datasette.static()`` method for generating cache-busting static asset URLs based on the file contents. Static assets served with a matching ``?_hash=`` parameter now receive far-future immutable cache headers. This works for Datasette's bundled static assets, plugin static assets and directories mounted using ``--static``. See :ref:`customization_static_files`.
|
||||
- Database and table pages now use the ``count_truncated`` template context value to display capped row counts as ``>N rows``.
|
||||
- Significant visual improvements to the table filter form UI, plus working add/remove filter buttons. (:issue:`2798`)
|
||||
- Improved edit row icon on table pages. (:issue:`2796`)
|
||||
- Documentation covers how actors are displayed. Thanks, `Sebastian Cao <https://github.com/cycsmail>`__. (:issue:`2002`)
|
||||
- Fix for bug where appending ``?_col=pk`` resulted in duplicate primary key columns in the response. Thanks, `Ritesh Kewlani <https://github.com/riteshkew>`__. (:issue:`1975`)
|
||||
|
||||
.. _v1_0_a34:
|
||||
|
||||
1.0a34 (2026-06-16)
|
||||
|
|
|
|||
|
|
@ -244,6 +244,9 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam
|
|||
custom query (default=1000)
|
||||
max_insert_rows Maximum rows that can be inserted at a time using
|
||||
the bulk insert API (default=100)
|
||||
max_post_body_bytes Maximum size in bytes for a POST body read into
|
||||
memory, e.g. JSON API requests - set 0 to disable
|
||||
this limit (default=2097152)
|
||||
num_sql_threads Number of threads in the thread pool for
|
||||
executing SQLite queries (default=3)
|
||||
sql_time_limit_ms Time limit for a SQL query in milliseconds
|
||||
|
|
|
|||
|
|
@ -113,12 +113,23 @@ You can pass extra ``pytest`` options after the browser name:
|
|||
just playwright chromium -k permissions
|
||||
just playwright-all -x
|
||||
|
||||
If you are not using ``just``, the equivalent Chromium commands are:
|
||||
You can add the ``--headed`` option to have Playwright open a browser window that you can see while it runs the tests. This only works if you specify a browser, for example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
just playwright firefox --headed
|
||||
|
||||
Combine this with ``-k`` to watch a specific test:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
just playwright chromium --headed -k test_insert_row
|
||||
|
||||
If you are not using ``just``, the equivalent ``uv run`` commands are:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
uv run --group playwright playwright install chromium
|
||||
|
||||
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium
|
||||
|
||||
.. _contributing_using_fixtures:
|
||||
|
|
@ -146,6 +157,9 @@ If you want to change Datasette's Python code you can use the ``--reload`` optio
|
|||
|
||||
uv run datasette --reload fixtures.db
|
||||
|
||||
This also enables development mode for static asset cache busting, described in
|
||||
:ref:`customization_static_files`.
|
||||
|
||||
You can also use the ``fixtures.py`` script to recreate the testing version of ``metadata.json`` used by the unit tests. To do that::
|
||||
|
||||
uv run python tests/fixtures.py fixtures.db fixtures-metadata.json
|
||||
|
|
@ -298,6 +312,19 @@ To update these pages, run the following command::
|
|||
|
||||
uv run cog -r docs/*.rst
|
||||
|
||||
.. _contributing_template_contexts:
|
||||
|
||||
Documented template contexts
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Datasette's documented template contexts are part of the public API for custom templates. They are defined as dataclasses next to the view code that renders them, for example ``DatabaseContext`` and ``QueryContext`` in ``datasette/views/database.py``.
|
||||
|
||||
Every documented context class inherits from ``datasette.views.Context``. Fields that are added directly by view code should be declared as dataclass fields with ``help`` metadata, which is used to generate :ref:`template_context`. Fields resolved through the page extras system should use ``from_extra()`` so their documentation comes from the matching ``Extra`` class.
|
||||
|
||||
Use ``documented_template`` on each context class to record the canonical template named in the generated documentation. This should be a string such as ``"database.html"``. Runtime template selection still happens in the view code, since most pages consider more specific template names before falling back to the canonical one.
|
||||
|
||||
When a context field contains repeated structured data, prefer a small nested dataclass over an anonymous dictionary. For example, a field containing table summaries should be annotated as ``list[DatabaseTable]`` where ``DatabaseTable`` is a dataclass describing the keys and value types. This keeps the Python contract and generated documentation clear. JSON responses and ``?_context=1`` debug output will convert nested dataclasses back to JSON objects at the response boundary.
|
||||
|
||||
.. _contributing_continuous_deployment:
|
||||
|
||||
Continuously deployed demo instances
|
||||
|
|
|
|||
|
|
@ -94,11 +94,55 @@ The ``core`` class is particularly useful - you can apply this directly to a ``<
|
|||
|
||||
.. _customization_static_files:
|
||||
|
||||
Serving static files
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
Linking to static assets
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Datasette can serve static files for you, using the ``--static`` option.
|
||||
Consider the following directory structure::
|
||||
Use the ``static()`` template function to create cache-busting URLs to static
|
||||
assets from your custom templates. It returns a URL with a ``?_hash=`` parameter
|
||||
based on the file contents and takes the ``base_url`` setting into account.
|
||||
|
||||
When the hash in the URL matches the current file contents, Datasette will serve
|
||||
the static asset with a far-future immutable ``Cache-Control`` header.
|
||||
|
||||
When Datasette is run using ``--reload``, the file contents are hashed every time
|
||||
the template is rendered, so edits to static files will update their URLs
|
||||
without restarting Datasette. Without ``--reload``, the hash is cached for the
|
||||
lifetime of the Datasette process.
|
||||
|
||||
For Datasette's bundled static assets, pass the path to the asset:
|
||||
|
||||
.. code-block:: html+jinja
|
||||
|
||||
<link rel="stylesheet" href="{{ static('app.css') }}">
|
||||
|
||||
For plugin static assets, pass the plugin name using ``plugin=`` and a path
|
||||
relative to the plugin's ``static/`` directory:
|
||||
|
||||
.. code-block:: html+jinja
|
||||
|
||||
<script src="{{ static('plugin.js', plugin='datasette_plugin_name') }}" defer></script>
|
||||
|
||||
For files served from a directory mounted using ``--static assets:static-files/``,
|
||||
pass the mount point name using ``mount=`` and a path relative to that mounted
|
||||
directory:
|
||||
|
||||
.. code-block:: html+jinja
|
||||
|
||||
<link rel="stylesheet" href="{{ static('styles.css', mount='assets') }}">
|
||||
<script src="{{ static('app.js', mount='assets') }}" defer></script>
|
||||
|
||||
You can also use ``urls.path()`` if you want to link to a mounted file without
|
||||
adding a content hash:
|
||||
|
||||
.. code-block:: html+jinja
|
||||
|
||||
<link rel="stylesheet" href="{{ urls.path('/assets/styles.css') }}">
|
||||
|
||||
Serving files from a directory
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Datasette can serve static files from a directory for you, using the ``--static``
|
||||
option. Consider the following directory structure::
|
||||
|
||||
metadata.json
|
||||
static-files/styles.css
|
||||
|
|
@ -177,6 +221,11 @@ this::
|
|||
Datasette will now first look for templates in that directory, and fall back on
|
||||
the defaults if no matches are found.
|
||||
|
||||
The variables made available to each template are documented on the
|
||||
:ref:`template_context` page. Variables documented there are a stable API:
|
||||
custom templates that use them will keep working in future Datasette
|
||||
releases, up until the next major version.
|
||||
|
||||
It is also possible to over-ride templates on a per-database, per-row or per-
|
||||
table basis.
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ Contents
|
|||
settings
|
||||
introspection
|
||||
custom_templates
|
||||
template_context
|
||||
plugins
|
||||
writing_plugins
|
||||
javascript_plugins
|
||||
|
|
|
|||
|
|
@ -17,13 +17,6 @@ If you want to start making contributions to the Datasette project by installing
|
|||
Basic installation
|
||||
==================
|
||||
|
||||
.. _installation_datasette_desktop:
|
||||
|
||||
Datasette Desktop for Mac
|
||||
-------------------------
|
||||
|
||||
`Datasette Desktop <https://datasette.io/desktop>`__ is a packaged Mac application which bundles Datasette together with Python and allows you to install and run Datasette directly on your laptop. This is the best option for local installation if you are not comfortable using the command line.
|
||||
|
||||
.. _installation_homebrew:
|
||||
|
||||
Using Homebrew
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ The request object is passed to various plugin hooks. It represents an incoming
|
|||
``.actor`` - dictionary (str -> Any) or None
|
||||
The currently authenticated actor (see :ref:`actors <authentication_actor>`), or ``None`` if the request is unauthenticated.
|
||||
|
||||
``.max_post_body_bytes`` - integer
|
||||
The maximum number of bytes ``await request.post_body()`` will read into memory, or ``0`` for no limit. Set from the :ref:`setting_max_post_body_bytes` setting (default 2MB) for requests created by Datasette. Can be passed to the ``Request`` constructor as a keyword argument.
|
||||
|
||||
The object also has the following awaitable methods:
|
||||
|
||||
``await request.form(files=False, ...)`` - FormData
|
||||
|
|
@ -109,9 +112,11 @@ The object also has the following awaitable methods:
|
|||
``await request.json()`` - Any
|
||||
Returns the parsed JSON body of a request submitted by ``POST``.
|
||||
|
||||
``await request.post_body()`` - bytes
|
||||
``await request.post_body(max_bytes=None)`` - bytes
|
||||
Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.
|
||||
|
||||
The body is read fully into memory, capped at ``request.max_post_body_bytes`` - which Datasette sets from the :ref:`setting_max_post_body_bytes` setting (default 2MB). Bodies that exceed the limit raise a ``datasette.PayloadTooLarge`` exception, which Datasette turns into an HTTP 413 error response. Pass ``max_bytes=`` to override the limit for a specific call, or ``max_bytes=0`` to disable it. ``request.post_vars()`` and ``request.json()`` read the body through this method, so the same limit applies to them.
|
||||
|
||||
And a class method that can be used to create fake request objects for use in tests:
|
||||
|
||||
``fake(path_with_query_string, method="GET", scheme="http", url_vars=None)``
|
||||
|
|
@ -279,7 +284,7 @@ For example:
|
|||
content_type="application/xml; charset=utf-8",
|
||||
)
|
||||
|
||||
The quickest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)`` or ``Response.redirect(...)`` helper methods:
|
||||
The quickest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)``, ``Response.error(...)`` or ``Response.redirect(...)`` helper methods:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
|
|
@ -290,6 +295,8 @@ The quickest way to create responses is using the ``Response.text(...)``, ``Resp
|
|||
text_response = Response.text(
|
||||
"This will become utf-8 encoded text"
|
||||
)
|
||||
# A JSON error in Datasette's standard error format:
|
||||
error_response = Response.error("Cannot do that", 400)
|
||||
# Redirects are served as 302, unless you pass status=301:
|
||||
redirect_response = Response.redirect(
|
||||
"https://latest.datasette.io/"
|
||||
|
|
@ -299,6 +306,8 @@ Each of these responses will use the correct corresponding content-type - ``text
|
|||
|
||||
Each of the helper methods take optional ``status=`` and ``headers=`` arguments, documented above.
|
||||
|
||||
``Response.error(messages, status=400)`` returns a JSON error in the :ref:`standard Datasette error format <json_api_errors>`. ``messages`` can be a single string or a list of strings. Use this for JSON-only endpoints; if your error should content-negotiate between JSON and HTML, raise ``Forbidden``, ``NotFound``, ``BadRequest`` or ``DatasetteError`` instead and Datasette's error handling will build the appropriate response.
|
||||
|
||||
.. _internals_response_asgi_send:
|
||||
|
||||
Returning a response with .asgi_send(send)
|
||||
|
|
@ -451,6 +460,52 @@ await .render_template(template, context=None, request=None)
|
|||
|
||||
Renders a `Jinja template <https://jinja.palletsprojects.com/en/2.11.x/>`__ using Datasette's preconfigured instance of Jinja and returns the resulting string. The template will have access to Datasette's default template functions and any functions that have been made available by other plugins.
|
||||
|
||||
.. _datasette_static:
|
||||
|
||||
.static(path, plugin=None, mount=None)
|
||||
--------------------------------------
|
||||
|
||||
``path`` - string
|
||||
The path to the static asset, relative to the selected static directory.
|
||||
|
||||
``plugin`` - string, optional
|
||||
The plugin name, for linking to an asset in that plugin's ``static/``
|
||||
directory.
|
||||
|
||||
``mount`` - string, optional
|
||||
The ``--static`` mount name, for linking to an asset in a directory mounted
|
||||
using ``datasette --static mount_name:directory``.
|
||||
|
||||
Returns a URL for a static asset with a ``?_hash=`` parameter based on the file
|
||||
contents. That URL takes the ``base_url`` setting into account.
|
||||
|
||||
When the ``?_hash=`` parameter matches the current file contents, Datasette will
|
||||
serve the asset with ``Cache-Control: max-age=31536000, immutable, public``.
|
||||
|
||||
Call this with just ``path`` for one of Datasette's bundled static assets:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
datasette.static("app.css")
|
||||
|
||||
Use ``plugin=`` for plugin static assets:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
datasette.static(
|
||||
"plugin.js", plugin="datasette_plugin_name"
|
||||
)
|
||||
|
||||
Use ``mount=`` for static directories mounted using the ``--static`` option:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
datasette.static("styles.css", mount="assets")
|
||||
|
||||
``plugin`` and ``mount`` are mutually exclusive. The same feature is available
|
||||
to Jinja templates as the ``static()`` template function, described in
|
||||
:ref:`customization_static_files`.
|
||||
|
||||
.. _datasette_actors_from_ids:
|
||||
|
||||
await .actors_from_ids(actor_ids)
|
||||
|
|
@ -1968,8 +2023,8 @@ Example usage:
|
|||
|
||||
.. _database_execute_write:
|
||||
|
||||
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10)
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True)
|
||||
--------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
|
||||
|
||||
|
|
@ -2004,7 +2059,9 @@ If you need to retrieve every row returned by a statement, pass ``return_all=Tru
|
|||
|
||||
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
|
||||
|
||||
Each call to ``execute_write()`` will be executed inside a transaction.
|
||||
Each call to ``execute_write()`` will be executed inside a transaction. Pass
|
||||
``transaction=False`` for statements such as ``VACUUM`` that cannot run inside
|
||||
a transaction.
|
||||
|
||||
.. _database_execute_write_script:
|
||||
|
||||
|
|
@ -2308,6 +2365,14 @@ The internal database schema is as follows:
|
|||
|
||||
.. code-block:: sql
|
||||
|
||||
CREATE TABLE "_sqlite_migrations" (
|
||||
"id" INTEGER PRIMARY KEY,
|
||||
"migration_set" TEXT,
|
||||
"name" TEXT,
|
||||
"applied_at" TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX "idx__sqlite_migrations_migration_set_name"
|
||||
ON "_sqlite_migrations" ("migration_set", "name");
|
||||
CREATE TABLE catalog_databases (
|
||||
database_name TEXT PRIMARY KEY,
|
||||
path TEXT,
|
||||
|
|
@ -2533,6 +2598,13 @@ Async version of :ref:`call_with_supported_arguments <internals_utils_call_with_
|
|||
|
||||
.. _internals_tracer:
|
||||
|
||||
JSON encoding
|
||||
-------------
|
||||
|
||||
.. _internals_utils_CustomJSONEncoder:
|
||||
|
||||
.. autoclass:: datasette.utils.CustomJSONEncoder
|
||||
|
||||
datasette.tracer
|
||||
================
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ Datasette includes some pages and JSON API endpoints for introspecting the curre
|
|||
|
||||
Each of these pages can be viewed in your browser. Add ``.json`` to the URL to get back the contents as JSON.
|
||||
|
||||
JSON responses that return an object include an ``"ok": true`` key, consistent with the rest of the :ref:`JSON API <json_api>`.
|
||||
|
||||
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, with the exception of the debug endpoints ``/-/threads`` and ``/-/actions``, whose shapes may change in future releases.
|
||||
|
||||
.. _JsonDataView_metadata:
|
||||
|
||||
/-/metadata
|
||||
|
|
@ -37,6 +41,7 @@ Shows the version of Datasette, Python and SQLite. `Versions example <https://la
|
|||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"datasette": {
|
||||
"version": "0.60"
|
||||
},
|
||||
|
|
@ -97,6 +102,7 @@ Shows the :ref:`settings` for this instance of Datasette. `Settings example <htt
|
|||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"default_facet_size": 30,
|
||||
"default_page_size": 100,
|
||||
"facet_suggest_time_limit_ms": 50,
|
||||
|
|
@ -115,6 +121,7 @@ Shows the :ref:`configuration <configuration>` for this instance of Datasette. T
|
|||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"settings": {
|
||||
"template_debug": true,
|
||||
"trace_debug": true,
|
||||
|
|
@ -129,20 +136,47 @@ Any keys that include the one of the following substrings in their names will be
|
|||
/-/databases
|
||||
------------
|
||||
|
||||
Shows currently attached databases. `Databases example <https://latest.datasette.io/-/databases>`_:
|
||||
Shows currently attached databases that the current actor is allowed to view, based on the ``view-database`` permission. `Databases example <https://latest.datasette.io/-/databases>`_:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
[
|
||||
{
|
||||
"hash": null,
|
||||
"is_memory": false,
|
||||
"is_mutable": true,
|
||||
"name": "fixtures",
|
||||
"path": "fixtures.db",
|
||||
"size": 225280
|
||||
}
|
||||
]
|
||||
{
|
||||
"ok": true,
|
||||
"databases": [
|
||||
{
|
||||
"hash": null,
|
||||
"is_memory": false,
|
||||
"is_mutable": true,
|
||||
"name": "fixtures",
|
||||
"path": "fixtures.db",
|
||||
"size": 225280
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
.. _JsonDataView_actions:
|
||||
|
||||
/-/actions
|
||||
----------
|
||||
|
||||
Shows all actions registered with the permission system, including those added by plugins. Requires the ``permissions-debug`` permission.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"actions": [
|
||||
{
|
||||
"name": "view-instance",
|
||||
"abbr": "vi",
|
||||
"description": "View Datasette instance",
|
||||
"takes_parent": false,
|
||||
"takes_child": false,
|
||||
"resource_class": null,
|
||||
"also_requires": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
.. _JumpView:
|
||||
|
||||
|
|
@ -160,6 +194,7 @@ The endpoint supports a ``?q=`` query parameter for filtering items by name.
|
|||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures",
|
||||
|
|
@ -188,6 +223,7 @@ Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
|
|||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures: facetable",
|
||||
|
|
@ -215,11 +251,12 @@ Without those query string arguments, the page lists up to five tables with dete
|
|||
/-/threads
|
||||
----------
|
||||
|
||||
Shows details of threads and ``asyncio`` tasks. `Threads example <https://latest.datasette.io/-/threads>`_:
|
||||
Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``permissions-debug`` permission, since it exposes runtime internals. `Threads example <https://latest.datasette.io/-/threads>`_:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"num_threads": 2,
|
||||
"threads": [
|
||||
{
|
||||
|
|
@ -251,6 +288,7 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti
|
|||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"actor": {
|
||||
"id": 1,
|
||||
"username": "some-user"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,53 @@ through the Datasette user interface can also be accessed as JSON via the API.
|
|||
To access the API for a page, either click on the ``.json`` link on that page or
|
||||
edit the URL and add a ``.json`` extension to it.
|
||||
|
||||
.. _json_api_stability:
|
||||
|
||||
API stability
|
||||
-------------
|
||||
|
||||
Datasette 1.0 makes a stability promise for its JSON API: the endpoints,
|
||||
parameters and response keys documented here and on the pages this
|
||||
documentation links to will not change in backwards-incompatible ways for
|
||||
the duration of the 1.x release series.
|
||||
|
||||
Stability means:
|
||||
|
||||
- Documented endpoints will keep their URLs, methods, parameters and
|
||||
permission requirements.
|
||||
- Documented response keys will keep their names and types. New keys may be
|
||||
**added** in any release - clients should ignore keys they do not
|
||||
recognize.
|
||||
- The documented ``?_extra=`` names, ``?_shape=`` values and
|
||||
:ref:`column filter operators <table_arguments>` are stable.
|
||||
- Pagination tokens - the ``"next"`` key and ``?_next=`` parameter - are
|
||||
**opaque strings**. Pass them back exactly as you received them; their
|
||||
internal structure is not part of the API and can change at any time.
|
||||
- The :ref:`standard error format <json_api_errors>` and the
|
||||
:ref:`API token format and restriction semantics <CreateTokenView>` are
|
||||
stable, including the action abbreviations stored inside signed tokens.
|
||||
|
||||
Some JSON endpoints are **exempt** from this promise:
|
||||
|
||||
- Endpoints that are not documented include this marker key in their
|
||||
responses and can change at any time::
|
||||
|
||||
"unstable": "This API is not part of Datasette's stable interface and may change at any time"
|
||||
|
||||
This currently covers the instance homepage (``/.json``), the stored
|
||||
query ``analyze``/``store``/``definition`` endpoints, ``/-/query/parameters``,
|
||||
``/-/execute-write/analyze`` and the JSON returned by the ``/-/permissions``
|
||||
debug playground.
|
||||
- Debug and support endpoints are documented so you can use them, but their
|
||||
JSON shapes are not frozen: :ref:`/-/threads <JsonDataView_threads>`,
|
||||
:ref:`/-/actions <JsonDataView_actions>`,
|
||||
the :ref:`permission debug endpoints <PermissionsDebugView>`
|
||||
(``/-/allowed``, ``/-/rules``, ``/-/check``) and the
|
||||
:ref:`table autocomplete endpoint <TableAutocompleteView>`.
|
||||
- Response keys explicitly labeled as unstable in this documentation, such
|
||||
as the ``"analysis"`` block returned by :ref:`execute-write <ExecuteWriteView>`
|
||||
and the ``debug`` and ``request`` extras.
|
||||
|
||||
.. _json_api_default:
|
||||
|
||||
Default representation
|
||||
|
|
@ -42,13 +89,49 @@ looks like this:
|
|||
"truncated": false
|
||||
}
|
||||
|
||||
``"ok"`` is always ``true`` if an error did not occur.
|
||||
``"ok"`` is always ``true`` if an error did not occur. Every Datasette JSON endpoint that returns an object includes this key on success.
|
||||
|
||||
The ``"rows"`` key is a list of objects, each one representing a row.
|
||||
The ``"rows"`` key is a list of objects, each one representing a row.
|
||||
|
||||
The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting).
|
||||
|
||||
For table pages, an additional key ``"next"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``.
|
||||
For table pages, two additional keys are present: ``"next"``, an opaque token that can be used to retrieve the next page using ``?_next=TOKEN``, and ``"next_url"``, the full URL of that next page. Both are ``null`` on the final page. See :ref:`json_api_pagination`.
|
||||
|
||||
.. _json_api_errors:
|
||||
|
||||
Error responses
|
||||
---------------
|
||||
|
||||
Every JSON error response from Datasette uses the same format:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Table not found",
|
||||
"errors": [
|
||||
"Table not found"
|
||||
],
|
||||
"status": 404
|
||||
}
|
||||
|
||||
- ``"ok"`` is always ``false`` for an error.
|
||||
- ``"errors"`` is a list of one or more error message strings. Endpoints that
|
||||
validate multiple things at once - such as the :ref:`insert API <TableInsertView>` -
|
||||
may return several messages here.
|
||||
- ``"error"`` is all of those messages joined with ``"; "``, for
|
||||
convenience when displaying a single string.
|
||||
- ``"status"`` matches the HTTP status code of the response.
|
||||
|
||||
Some endpoints add extra context keys. For example, a SQL error from a
|
||||
:ref:`custom query <json_api_custom_sql>` also includes the empty
|
||||
``"rows"`` and ``"truncated"`` keys of the response it was unable to
|
||||
produce.
|
||||
|
||||
Permission errors use the same format: a request that fails a permission
|
||||
check receives a ``403`` with this JSON error body when the URL ends in
|
||||
``.json`` or the request sends an ``Accept: application/json`` or
|
||||
``Content-Type: application/json`` header.
|
||||
|
||||
.. _json_api_custom_sql:
|
||||
|
||||
|
|
@ -92,6 +175,7 @@ options:
|
|||
{
|
||||
"ok": true,
|
||||
"next": null,
|
||||
"next_url": null,
|
||||
"rows": [
|
||||
[3, "Detroit"],
|
||||
[2, "Los Angeles"],
|
||||
|
|
@ -192,6 +276,10 @@ Here is an example Python function built using `requests <https://requests.readt
|
|||
Special JSON arguments
|
||||
----------------------
|
||||
|
||||
Boolean query string arguments - such as ``?_labels=`` and
|
||||
``?_json_infinity=`` - accept ``on``, ``true`` or ``1`` for true and
|
||||
``off``, ``false`` or ``0`` for false.
|
||||
|
||||
Every Datasette endpoint that can return JSON also accepts the following
|
||||
query string arguments:
|
||||
|
||||
|
|
@ -245,7 +333,9 @@ These can be repeated or comma-separated:
|
|||
|
||||
::
|
||||
|
||||
?_extra=columns&_extra=count,next_url
|
||||
?_extra=columns&_extra=count,count_sql
|
||||
|
||||
Requesting an ``_extra`` name that does not exist returns a ``400`` error in the :ref:`standard error format <json_api_errors>`, for example ``{"ok": false, "error": "Unknown _extra: nope", ...}``.
|
||||
|
||||
.. [[[cog
|
||||
from json_api_doc import table_extras
|
||||
|
|
@ -266,8 +356,17 @@ The available table extras are listed below.
|
|||
|
||||
15
|
||||
|
||||
``count_truncated``
|
||||
True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count. (May execute additional queries.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=count,count_truncated``
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
false
|
||||
|
||||
``count_sql``
|
||||
SQL query used to calculate the total count
|
||||
SQL query string used to calculate the total count for the current table view, including active filters.
|
||||
|
||||
``GET /fixtures/facetable.json?_size=0&_extra=count_sql``
|
||||
|
||||
|
|
@ -276,7 +375,7 @@ The available table extras are listed below.
|
|||
"select count(*) from facetable "
|
||||
|
||||
``facet_results``
|
||||
Results of facets calculated against this data (May execute additional queries. See :ref:`facets` for details of how facets work.)
|
||||
Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``. (May execute additional queries. See :ref:`facets` for details of how facets work.)
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.
|
||||
|
||||
|
|
@ -305,7 +404,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``facets_timed_out``
|
||||
Facet calculations that timed out
|
||||
List of names of facet calculations that exceeded the facet time limit.
|
||||
|
||||
``GET /fixtures/facetable.json?_facet=state&_extra=facets_timed_out``
|
||||
|
||||
|
|
@ -316,7 +415,7 @@ The available table extras are listed below.
|
|||
[]
|
||||
|
||||
``suggested_facets``
|
||||
Suggestions for facets that might return interesting results (May execute additional queries. Suggestions are controlled by the :ref:`setting_suggest_facets` setting.)
|
||||
Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class. (May execute additional queries. Suggestions are controlled by the :ref:`setting_suggest_facets` setting.)
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.
|
||||
|
||||
|
|
@ -338,19 +437,8 @@ The available table extras are listed below.
|
|||
|
||||
"where state = \"CA\" sorted by pk"
|
||||
|
||||
``next_url``
|
||||
Full URL for the next page of results
|
||||
|
||||
``GET /fixtures/facetable.json?_size=1&_extra=next_url``
|
||||
|
||||
``null`` if there are no more pages of results. See :ref:`json_api_pagination`.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
"http://localhost/fixtures/facetable.json?_size=1&_extra=next_url&_next=1"
|
||||
|
||||
``columns``
|
||||
Column names returned by this query
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=columns``
|
||||
|
||||
|
|
@ -371,7 +459,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``all_columns``
|
||||
All columns in the table, regardless of _col/_nocol filtering
|
||||
List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering.
|
||||
|
||||
``GET /fixtures/facetable.json?_col=pk&_extra=all_columns``
|
||||
|
||||
|
|
@ -392,7 +480,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``primary_keys``
|
||||
Primary keys for this table
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=primary_keys``
|
||||
|
||||
|
|
@ -402,8 +490,27 @@ The available table extras are listed below.
|
|||
"pk"
|
||||
]
|
||||
|
||||
``column_details``
|
||||
SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.)
|
||||
|
||||
``GET /fixtures/binary_data.json?_size=0&_extra=column_details``
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"type": "BLOB",
|
||||
"sqlite_type": "BLOB",
|
||||
"notnull": false,
|
||||
"default": null,
|
||||
"is_pk": false,
|
||||
"pk_position": 0,
|
||||
"hidden": 0
|
||||
}
|
||||
}
|
||||
|
||||
``display_columns``
|
||||
Column metadata used by the HTML table display
|
||||
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.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.
|
||||
|
||||
|
|
@ -456,7 +563,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``debug``
|
||||
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=debug``
|
||||
|
||||
|
|
@ -474,7 +581,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``request``
|
||||
Full information about the request
|
||||
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=request``
|
||||
|
||||
|
|
@ -493,7 +600,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``query``
|
||||
Details of the underlying SQL query
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``GET /fixtures/facetable.json?_size=1&_extra=query``
|
||||
|
||||
|
|
@ -505,7 +612,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``column_types``
|
||||
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries. (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
|
||||
``GET /fixtures/facetable.json?_size=0&_extra=column_types``
|
||||
|
||||
|
|
@ -521,7 +628,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``set_column_type_ui``
|
||||
Information needed to build an interface for assigning column types (``null`` unless the current actor is allowed to use the :ref:`set column type API <TableSetColumnTypeView>` for this table.)
|
||||
Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values. (``null`` unless the current actor is allowed to use the :ref:`set column type API <TableSetColumnTypeView>` for this table.)
|
||||
|
||||
Shape abbreviated to two columns, as seen by an actor with ``set-column-type`` permission. ``current`` is the column type currently assigned to each column and ``options`` lists the types that could be assigned to it.
|
||||
|
||||
|
|
@ -571,7 +678,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``metadata``
|
||||
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=metadata``
|
||||
|
||||
|
|
@ -587,7 +694,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``extras``
|
||||
List of ?_extra= blocks that can be used on this page
|
||||
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
|
||||
|
||||
|
|
@ -636,7 +743,7 @@ The available table extras are listed below.
|
|||
"9403e5"
|
||||
|
||||
``renderers``
|
||||
Alternative output renderers available for this table
|
||||
Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=renderers``
|
||||
|
||||
|
|
@ -649,7 +756,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``custom_table_templates``
|
||||
Custom template names considered for this table (The first template in this list that exists will be used to render the table on the HTML version of this page. See :ref:`customization_custom_templates`.)
|
||||
List of custom template names considered for rendering table rows, in lookup order. (The first template in this list that exists will be used to render the table on the HTML version of this page. See :ref:`customization_custom_templates`.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=custom_table_templates``
|
||||
|
||||
|
|
@ -662,7 +769,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``sorted_facet_results``
|
||||
Facet results sorted for display (The same data as ``facet_results``, as a list in the order used by the HTML interface: facets from :ref:`facet configuration <facets_metadata>` first, then other facets ordered by their number of results.)
|
||||
Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``. (The same data as ``facet_results``, as a list in the order used by the HTML interface: facets from :ref:`facet configuration <facets_metadata>` first, then other facets ordered by their number of results.)
|
||||
|
||||
``GET /fixtures/facetable.json?_facet=state&_extra=sorted_facet_results``
|
||||
|
||||
|
|
@ -738,7 +845,7 @@ The available table extras are listed below.
|
|||
false
|
||||
|
||||
``expandable_columns``
|
||||
Foreign key columns that can be expanded with labels (See :ref:`expand_foreign_keys` for how to expand these labels.)
|
||||
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``. (See :ref:`expand_foreign_keys` for how to expand these labels.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=expandable_columns``
|
||||
|
||||
|
|
@ -758,7 +865,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``form_hidden_args``
|
||||
Hidden form arguments used by the HTML table interface
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options.
|
||||
|
||||
``GET /fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args``
|
||||
|
||||
|
|
@ -785,7 +892,7 @@ Row JSON responses
|
|||
The following extras are available for row JSON responses.
|
||||
|
||||
``columns``
|
||||
Column names returned by this query
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=columns``
|
||||
|
||||
|
|
@ -797,7 +904,7 @@ The following extras are available for row JSON responses.
|
|||
]
|
||||
|
||||
``primary_keys``
|
||||
Primary keys for this table
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=primary_keys``
|
||||
|
||||
|
|
@ -807,6 +914,25 @@ The following extras are available for row JSON responses.
|
|||
"id"
|
||||
]
|
||||
|
||||
``column_details``
|
||||
SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.)
|
||||
|
||||
``GET /fixtures/binary_data/1.json?_extra=column_details``
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"type": "BLOB",
|
||||
"sqlite_type": "BLOB",
|
||||
"notnull": false,
|
||||
"default": null,
|
||||
"is_pk": false,
|
||||
"pk_position": 0,
|
||||
"hidden": 0
|
||||
}
|
||||
}
|
||||
|
||||
``render_cell``
|
||||
Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook <plugin_hook_render_cell>` documentation.)
|
||||
|
||||
|
|
@ -829,7 +955,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``debug``
|
||||
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=debug``
|
||||
|
||||
|
|
@ -858,7 +984,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``request``
|
||||
Full information about the request
|
||||
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=request``
|
||||
|
||||
|
|
@ -877,7 +1003,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``query``
|
||||
Details of the underlying SQL query
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=query``
|
||||
|
||||
|
|
@ -891,7 +1017,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``column_types``
|
||||
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries. (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
|
||||
``GET /fixtures/facetable/1.json?_extra=column_types``
|
||||
|
||||
|
|
@ -907,7 +1033,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``metadata``
|
||||
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=metadata``
|
||||
|
||||
|
|
@ -920,7 +1046,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``extras``
|
||||
List of ?_extra= blocks that can be used on this page
|
||||
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
|
||||
|
||||
|
|
@ -978,7 +1104,7 @@ The following extras are available for row JSON responses.
|
|||
false
|
||||
|
||||
``foreign_key_tables``
|
||||
Tables that link to this row using foreign keys (May execute additional queries.)
|
||||
List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL. (May execute additional queries.)
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=foreign_key_tables``
|
||||
|
||||
|
|
@ -1030,7 +1156,7 @@ Query JSON responses
|
|||
The following extras are available for arbitrary SQL query responses and stored, named query responses.
|
||||
|
||||
``columns``
|
||||
Column names returned by this query
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=columns``
|
||||
|
||||
|
|
@ -1061,7 +1187,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``debug``
|
||||
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=debug``
|
||||
|
||||
|
|
@ -1075,7 +1201,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``request``
|
||||
Full information about the request
|
||||
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=request``
|
||||
|
||||
|
|
@ -1097,7 +1223,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``query``
|
||||
Details of the underlying SQL query
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=query``
|
||||
|
||||
|
|
@ -1120,7 +1246,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``metadata``
|
||||
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
|
||||
``GET /fixtures/neighborhood_search.json?text=town&_extra=metadata``
|
||||
|
||||
|
|
@ -1137,7 +1263,6 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
"description_html": null,
|
||||
"hide_sql": false,
|
||||
"fragment": null,
|
||||
"params": [],
|
||||
"parameters": [],
|
||||
"is_write": false,
|
||||
"is_private": false,
|
||||
|
|
@ -1151,7 +1276,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``extras``
|
||||
List of ?_extra= blocks that can be used on this page
|
||||
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
|
||||
|
||||
|
|
@ -1532,6 +1657,10 @@ The JSON write API
|
|||
|
||||
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
|
||||
|
||||
The request body is always parsed as JSON, regardless of the request's ``Content-Type`` header - a body that is not valid JSON returns a ``400`` error. Cross-site request forgery is prevented by Datasette's ``Origin`` and ``Sec-Fetch-Site`` header checks rather than by content type requirements.
|
||||
|
||||
The row-based write APIs can write :ref:`binary values in JSON <binary_json_format>` using Datasette's Base64 representation for BLOB data.
|
||||
|
||||
.. _ExecuteWriteView:
|
||||
|
||||
Executing write SQL
|
||||
|
|
@ -1565,7 +1694,7 @@ Unsupported SQL operations are rejected by default. ``VACUUM`` is not allowed in
|
|||
A successful response includes a message, the SQLite ``rowcount``, a ``"rows"``
|
||||
list, a ``"truncated"`` flag and a summary of the operations that were executed:
|
||||
|
||||
The shape of the ``"analysis"`` block is not yet considered a stable API and may change in future Datasette releases.
|
||||
The shape of the ``"analysis"`` block is not part of the :ref:`stable API <json_api_stability>` and may change in future Datasette releases.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
|
|
@ -1625,15 +1754,17 @@ the execute-write returning row limit, which defaults to 10:
|
|||
]
|
||||
}
|
||||
|
||||
Errors use the standard Datasette error format:
|
||||
Errors use the :ref:`standard Datasette error format <json_api_errors>`:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Permission denied: need execute-write-sql",
|
||||
"errors": [
|
||||
"Permission denied: need execute-write-sql"
|
||||
]
|
||||
],
|
||||
"status": 403
|
||||
}
|
||||
|
||||
.. _TableInsertView:
|
||||
|
|
@ -1660,6 +1791,8 @@ A single row can be inserted using the ``"row"`` key:
|
|||
}
|
||||
}
|
||||
|
||||
Column values can use the :ref:`binary value JSON format <binary_json_format>` to write BLOB data.
|
||||
|
||||
If successful, this will return a ``201`` status code and the newly inserted row, for example:
|
||||
|
||||
.. code-block:: json
|
||||
|
|
@ -1727,9 +1860,11 @@ If any of your rows have a primary key that is already in use, you will get an e
|
|||
|
||||
{
|
||||
"ok": false,
|
||||
"error": "UNIQUE constraint failed: new_table.id",
|
||||
"errors": [
|
||||
"UNIQUE constraint failed: new_table.id"
|
||||
]
|
||||
],
|
||||
"status": 400
|
||||
}
|
||||
|
||||
Pass ``"ignore": true`` to ignore these errors and insert the other rows:
|
||||
|
|
@ -1765,6 +1900,8 @@ An upsert is an insert or update operation. If a row with a matching primary key
|
|||
|
||||
The upsert API is mostly the same shape as the :ref:`insert API <TableInsertView>`. It requires both the :ref:`actions_insert_row` and :ref:`actions_update_row` permissions.
|
||||
|
||||
It also accepts the same :ref:`binary value JSON format <binary_json_format>`.
|
||||
|
||||
::
|
||||
|
||||
POST /<database>/<table>/-/upsert
|
||||
|
|
@ -1802,7 +1939,7 @@ The above example will:
|
|||
|
||||
Similar to ``/-/insert``, a ``row`` key with an object can be used instead of a ``rows`` array to upsert a single row.
|
||||
|
||||
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
|
||||
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body. This is deliberately different from the ``201`` returned by :ref:`insert <TableInsertView>`: an upsert may update existing rows without creating anything, so it does not claim resource creation.
|
||||
|
||||
Add ``"return": true`` to the request body to return full copies of the affected rows after they have been inserted or updated:
|
||||
|
||||
|
|
@ -1859,9 +1996,11 @@ When using upsert you must provide the primary key column (or columns if the tab
|
|||
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Row 0 is missing primary key column(s): \"id\"",
|
||||
"errors": [
|
||||
"Row 0 is missing primary key column(s): \"id\""
|
||||
]
|
||||
],
|
||||
"status": 400
|
||||
}
|
||||
|
||||
If your table does not have an explicit primary key you should pass the SQLite ``rowid`` key instead.
|
||||
|
|
@ -1895,6 +2034,8 @@ To update a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/update``.
|
|||
|
||||
You only need to pass the columns you want to update. Any other columns will be left unchanged.
|
||||
|
||||
Updated values can use the :ref:`binary value JSON format <binary_json_format>`.
|
||||
|
||||
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
|
||||
|
||||
Add ``"return": true`` to the request body to return the updated row:
|
||||
|
|
@ -1914,14 +2055,16 @@ The returned JSON will look like this:
|
|||
|
||||
{
|
||||
"ok": true,
|
||||
"row": {
|
||||
"id": 1,
|
||||
"title": "New title",
|
||||
"other_column": "Will be present here too"
|
||||
}
|
||||
"rows": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "New title",
|
||||
"other_column": "Will be present here too"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
|
||||
Pass ``"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.
|
||||
|
||||
|
|
@ -1942,7 +2085,7 @@ To delete a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/delete``.
|
|||
|
||||
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
|
||||
|
||||
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
|
||||
.. _TableCreateView:
|
||||
|
||||
|
|
@ -1968,7 +2111,14 @@ To create a table, make a ``POST`` to ``/<database>/-/create``. This requires th
|
|||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "text"
|
||||
"type": "text",
|
||||
"not_null": true,
|
||||
"default": "Untitled"
|
||||
},
|
||||
{
|
||||
"name": "created",
|
||||
"type": "text",
|
||||
"default_expr": "current_timestamp"
|
||||
}
|
||||
],
|
||||
"pk": "id"
|
||||
|
|
@ -1981,6 +2131,10 @@ The JSON here describes the table that will be created:
|
|||
|
||||
- ``name`` is the name of the column. This is required.
|
||||
- ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``.
|
||||
- ``not_null`` can be set to ``true`` to create this column with a ``NOT NULL`` constraint.
|
||||
- ``default`` can be used to set a literal default value for this column.
|
||||
- ``default_expr`` can be used instead of ``default`` to set a SQLite default expression. See :ref:`default_expr values <json_api_default_expr_values>`.
|
||||
- ``fk_table`` can be used to create a single-column foreign key constraint referencing another table. ``fk_column`` is optional and can be used to specify the referenced column - if omitted, Datasette will use the single primary key of ``fk_table``.
|
||||
|
||||
* ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column.
|
||||
|
||||
|
|
@ -1993,6 +2147,56 @@ The JSON here describes the table that will be created:
|
|||
* ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`actions_update_row` permission.
|
||||
* ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.
|
||||
|
||||
.. _json_api_default_expr_values:
|
||||
|
||||
``default_expr`` accepts these values:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Value
|
||||
- Recommended column type
|
||||
- Example inserted value
|
||||
* - ``current_timestamp``
|
||||
- ``text``
|
||||
- ``2026-05-01 13:34:00``
|
||||
* - ``current_date``
|
||||
- ``text``
|
||||
- ``2026-05-01``
|
||||
* - ``current_time``
|
||||
- ``text``
|
||||
- ``13:34:00``
|
||||
* - ``current_unixtime``
|
||||
- ``integer``
|
||||
- ``1777642440``
|
||||
* - ``current_unixtime_ms``
|
||||
- ``integer``
|
||||
- ``1777642440000``
|
||||
|
||||
This example creates a foreign key from ``projects.owner_id`` to the single primary key of ``owners``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"table": "projects",
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"name": "owner_id",
|
||||
"type": "integer",
|
||||
"fk_table": "owners"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"pk": "id"
|
||||
}
|
||||
|
||||
If the table is successfully created this will return a ``201`` status code and the following response:
|
||||
|
||||
.. code-block:: json
|
||||
|
|
@ -2003,7 +2207,7 @@ If the table is successfully created this will return a ``201`` status code and
|
|||
"table": "name_of_new_table",
|
||||
"table_url": "http://127.0.0.1:8001/data/name_of_new_table",
|
||||
"table_api_url": "http://127.0.0.1:8001/data/name_of_new_table.json",
|
||||
"schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT\n)"
|
||||
"schema": "CREATE TABLE [name_of_new_table] (\n [id] INTEGER PRIMARY KEY,\n [title] TEXT NOT NULL DEFAULT 'Untitled',\n [created] TEXT DEFAULT CURRENT_TIMESTAMP\n)"
|
||||
}
|
||||
|
||||
.. _TableCreateView_example:
|
||||
|
|
@ -2037,6 +2241,8 @@ Datasette will create a table with a schema that matches those rows and insert t
|
|||
"pk": "id"
|
||||
}
|
||||
|
||||
Example rows can use the :ref:`binary value JSON format <binary_json_format>`, allowing Datasette to infer ``BLOB`` columns.
|
||||
|
||||
Doing this requires both the :ref:`actions_create_table` and :ref:`actions_insert_row` permissions.
|
||||
|
||||
The ``201`` response here will be similar to the ``columns`` form, but will also include the number of rows that were inserted as ``row_count``:
|
||||
|
|
@ -2061,9 +2267,11 @@ If you pass a row to the create endpoint with a primary key that already exists
|
|||
|
||||
{
|
||||
"ok": false,
|
||||
"error": "UNIQUE constraint failed: creatures.id",
|
||||
"errors": [
|
||||
"UNIQUE constraint failed: creatures.id"
|
||||
]
|
||||
],
|
||||
"status": 400
|
||||
}
|
||||
|
||||
You can avoid this error by passing the same ``"ignore": true`` or ``"replace": true`` options to the create endpoint as you can to the :ref:`insert endpoint <TableInsertView>`.
|
||||
|
|
@ -2072,6 +2280,235 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat
|
|||
|
||||
Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission.
|
||||
|
||||
.. _DatabaseForeignKeyTargetsView:
|
||||
|
||||
Database foreign key targets
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The ``/<database>/-/foreign-key-targets`` endpoint returns the list of tables in a database that can be referenced by a single-column foreign key. This requires the :ref:`actions_create_table` permission.
|
||||
|
||||
::
|
||||
|
||||
GET /<database>/-/foreign-key-targets
|
||||
|
||||
The response includes only tables with exactly one primary key column. Hidden tables, tables with compound primary keys and tables with no explicit primary key are omitted.
|
||||
|
||||
Each target includes the normalized SQLite type affinity for the primary key column in ``type``. The type is calculated using SQLite's documented affinity rules: ``INT`` maps to ``integer``; ``CHAR``, ``CLOB`` or ``TEXT`` maps to ``text``; ``BLOB`` or no type maps to ``blob``; ``REAL`` and floating-point declared types map to ``real``; everything else maps to ``numeric``.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"database": "data",
|
||||
"targets": [
|
||||
{
|
||||
"fk_table": "owners",
|
||||
"fk_column": "id",
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"fk_table": "categories",
|
||||
"fk_column": "slug",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
.. _TableForeignKeySuggestionsView:
|
||||
|
||||
Table foreign key suggestions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The ``/<database>/<table>/-/foreign-key-suggestions`` endpoint suggests possible single-column foreign key relationships for a table. This requires the :ref:`actions_alter_table` permission.
|
||||
|
||||
::
|
||||
|
||||
GET /<database>/<table>/-/foreign-key-suggestions
|
||||
|
||||
The response includes every type-compatible single-column primary key target for each column in ``options``. Datasette also performs a bounded data check against up to 500 rows in the table: if the sampled non-null values for a column all exist in a target primary key, that target is included in ``suggestions``.
|
||||
|
||||
If the bounded check takes too long, the endpoint fails open. It still returns the type-compatible ``options`` for each column, but ``row_check.status`` will be ``"timed_out"`` and there may be no ``suggestions``.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"database": "data",
|
||||
"table": "projects",
|
||||
"row_check": {
|
||||
"attempted": true,
|
||||
"status": "completed",
|
||||
"row_limit": 500,
|
||||
"sampled_rows": 3,
|
||||
"checked_options": 4
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
"column": "owner_id",
|
||||
"type": "INTEGER",
|
||||
"affinity": "integer",
|
||||
"current": null,
|
||||
"suggestions": [
|
||||
{
|
||||
"fk_table": "owners",
|
||||
"fk_column": "id",
|
||||
"confidence": "sampled",
|
||||
"sampled_values": 3,
|
||||
"reasons": [
|
||||
"type_match",
|
||||
"sample_values_exist",
|
||||
"name_match"
|
||||
]
|
||||
}
|
||||
],
|
||||
"options": [
|
||||
{
|
||||
"fk_table": "owners",
|
||||
"fk_column": "id",
|
||||
"type": "INTEGER"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
.. _TableAlterView:
|
||||
|
||||
Altering tables
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
To alter an existing table, make a ``POST`` to ``/<database>/<table>/-/alter``. This requires the :ref:`actions_alter_table` permission.
|
||||
|
||||
::
|
||||
|
||||
POST /<database>/<table>/-/alter
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer dstok_<rest-of-token>
|
||||
|
||||
The request body should include an ``operations`` array. Each operation has the same top-level shape: an ``op`` string and an ``args`` object.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"operations": [
|
||||
{
|
||||
"op": "add_column",
|
||||
"args": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"not_null": true,
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "add_column",
|
||||
"args": {
|
||||
"name": "created",
|
||||
"type": "text",
|
||||
"default_expr": "current_timestamp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "rename_column",
|
||||
"args": {
|
||||
"name": "title",
|
||||
"to": "headline"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "rename_table",
|
||||
"args": {
|
||||
"to": "published_posts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "alter_column",
|
||||
"args": {
|
||||
"name": "score",
|
||||
"type": "float"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "drop_column",
|
||||
"args": {
|
||||
"name": "draft_notes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "set_primary_key",
|
||||
"args": {
|
||||
"columns": ["id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "add_foreign_key",
|
||||
"args": {
|
||||
"column": "owner_id",
|
||||
"fk_table": "owners"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "drop_foreign_key",
|
||||
"args": {
|
||||
"column": "old_owner_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "set_foreign_keys",
|
||||
"args": {
|
||||
"foreign_keys": [
|
||||
{
|
||||
"column": "owner_id",
|
||||
"fk_table": "owners",
|
||||
"fk_column": "id"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "reorder_columns",
|
||||
"args": {
|
||||
"columns": ["id", "headline", "slug", "created", "score"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Supported operations:
|
||||
|
||||
* ``add_column`` adds a new column. ``args`` accepts ``name``, optional ``type`` of ``text``, ``integer``, ``float`` or ``blob``, optional ``not_null``, optional literal ``default`` and optional ``default_expr``. If ``not_null`` is ``true`` either a non-null ``default`` or ``default_expr`` is required.
|
||||
* ``rename_column`` renames a column. ``args`` accepts ``name`` and ``to``.
|
||||
* ``rename_table`` renames the table. ``args`` accepts ``to``, the new table name. If combined with other operations, Datasette applies the column, primary key, foreign key and column order changes before renaming the table.
|
||||
* ``alter_column`` changes column properties. ``args`` accepts ``name`` and at least one of ``type``, ``not_null``, literal ``default`` or ``default_expr``. Passing ``"default": null`` removes an existing default.
|
||||
* ``drop_column`` drops a column. ``args`` accepts ``name``.
|
||||
* ``set_primary_key`` changes the table primary key. ``args`` accepts ``columns``, a list of one or more column names.
|
||||
* ``add_foreign_key`` adds a single-column foreign key constraint. ``args`` accepts ``column``, ``fk_table`` and optional ``fk_column``. If ``fk_column`` is omitted, Datasette will use the single primary key of ``fk_table``.
|
||||
* ``drop_foreign_key`` removes the foreign key constraint for a column. ``args`` accepts ``column``.
|
||||
* ``set_foreign_keys`` replaces all foreign key constraints on the table. ``args`` accepts ``foreign_keys``, a list of objects that each have ``column``, ``fk_table`` and optional ``fk_column``. An empty list removes all foreign key constraints.
|
||||
* ``reorder_columns`` reorders columns. ``args`` accepts ``columns``, a list of one or more column names. Columns omitted from this list will appear afterwards in their existing order.
|
||||
|
||||
``default`` is always treated as a literal value. ``default_expr`` accepts the values shown in :ref:`default_expr values <json_api_default_expr_values>` and is rendered as the corresponding SQLite default expression.
|
||||
|
||||
For foreign key operations that omit ``fk_column``, the referenced ``fk_table`` must have a single-column primary key. Datasette will return an error if it cannot identify a single primary key column for that table.
|
||||
|
||||
A successful response returns the new schema and the previous schema. If the request used ``rename_table``, ``table``, ``table_url`` and ``table_api_url`` will use the new table name. Renaming a table through this endpoint triggers the :class:`~datasette.events.RenameTableEvent` event.
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"database": "data",
|
||||
"table": "published_posts",
|
||||
"table_url": "http://127.0.0.1:8001/data/published_posts",
|
||||
"table_api_url": "http://127.0.0.1:8001/data/published_posts.json",
|
||||
"altered": true,
|
||||
"schema": "CREATE TABLE ...",
|
||||
"before_schema": "CREATE TABLE ...",
|
||||
"operations_applied": 11
|
||||
}
|
||||
|
||||
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
|
||||
.. _TableSetColumnTypeView:
|
||||
|
||||
Setting a column type
|
||||
|
|
@ -2134,7 +2571,7 @@ To clear an existing column type assignment, set ``column_type`` to ``null``:
|
|||
|
||||
This API stores the assignment in Datasette's internal database, so it can be used with immutable databases as well as mutable ones.
|
||||
|
||||
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
|
||||
.. _TableDropView:
|
||||
|
||||
|
|
@ -2171,4 +2608,4 @@ If you pass the following POST body:
|
|||
|
||||
Then the table will be dropped and a status ``200`` response of ``{"ok": true}`` will be returned.
|
||||
|
||||
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ Use the :ref:`ExecuteWriteView` JSON API to execute writable SQL programmaticall
|
|||
Stored query browsers
|
||||
---------------------
|
||||
|
||||
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database.
|
||||
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database. The JSON versions accept ``?_size=`` (default 50, ``max`` for the :ref:`setting_max_returned_rows` limit) and a ``?_next=`` pagination token.
|
||||
|
||||
These pages support search, pagination and filters for read-only or writable queries and private or public queries. Adding a ``.json`` extension to either URL returns the same list as JSON.
|
||||
|
||||
|
|
@ -169,11 +169,13 @@ Use ``/-/schema.json`` to get the same information as JSON, which looks like thi
|
|||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": true,
|
||||
"schemas": [
|
||||
{
|
||||
"database": "content",
|
||||
"schema": "create table posts ..."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
.. _DatabaseSchemaView:
|
||||
|
|
@ -181,11 +183,11 @@ Use ``/-/schema.json`` to get the same information as JSON, which looks like thi
|
|||
Database schema
|
||||
---------------
|
||||
|
||||
Use ``/database-name/-/schema`` to see the complete schema for a specific database. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"database"`` and ``"schema"`` keys.
|
||||
Use ``/database-name/-/schema`` to see the complete schema for a specific database. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"ok"``, ``"database"`` and ``"schema"`` keys.
|
||||
|
||||
.. _TableSchemaView:
|
||||
|
||||
Table schema
|
||||
------------
|
||||
|
||||
Use ``/database-name/table-name/-/schema`` to see the schema for a specific table. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"database"``, ``"table"``, and ``"schema"`` keys.
|
||||
Use ``/database-name/table-name/-/schema`` to see the schema for a specific table. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"ok"``, ``"database"``, ``"table"``, and ``"schema"`` keys.
|
||||
|
|
|
|||
|
|
@ -230,6 +230,10 @@ Function that returns an awaitable function that returns a dictionary
|
|||
|
||||
Datasette runs Jinja2 in `async mode <https://jinja.palletsprojects.com/en/2.10.x/api/#async-support>`__, which means you can add awaitable functions to the template scope and they will be automatically awaited when they are rendered by the template.
|
||||
|
||||
.. warning::
|
||||
|
||||
Be careful not to accidentally define a variable that conflicts with one that Datasette is already using for something else. Check :ref:`the template context documentation <template_context>` to see the variables defined by Datasette core.
|
||||
|
||||
Here's an example plugin that adds a ``"user_agent"`` variable to the template context containing the current request's User-Agent header:
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -1681,6 +1685,8 @@ forbidden(datasette, request, message)
|
|||
|
||||
Plugins can use this to customize how Datasette responds when a 403 Forbidden error occurs - usually because a page failed a permission check, see :ref:`authentication_permissions`.
|
||||
|
||||
Datasette's default behavior returns the :ref:`standard JSON error format <json_api_errors>` with a 403 status when the request path ends in ``.json`` or the request has an ``Accept: application/json`` or ``Content-Type: application/json`` header; other requests get an HTML error page.
|
||||
|
||||
If a plugin hook wishes to react to the error, it should return a :ref:`Response object <internals_response>`.
|
||||
|
||||
This example returns a redirect to a ``/-/login`` page:
|
||||
|
|
@ -1964,9 +1970,8 @@ Here is a minimal plugin example that adds a button to a table page and loads Ja
|
|||
@hookimpl
|
||||
def extra_js_urls(datasette):
|
||||
return [
|
||||
datasette.urls.static_plugins(
|
||||
"datasette_show_table",
|
||||
"show-table.js",
|
||||
datasette.static(
|
||||
"show-table.js", plugin="datasette_show_table"
|
||||
)
|
||||
]
|
||||
|
||||
|
|
@ -2541,6 +2546,10 @@ The default ``SignedTokenHandler`` uses itsdangerous signed tokens (``dstok_`` p
|
|||
|
||||
async def verify_token(self, datasette, token):
|
||||
# Look up token in database, return actor dict or None
|
||||
# if this handler does not recognize the token. Raise
|
||||
# datasette.TokenInvalid for a token this handler
|
||||
# recognizes but rejects (revoked, expired) - Datasette
|
||||
# will respond with a 401 error.
|
||||
...
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -280,6 +280,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
|
|||
"query_actions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "datasette.default_table_actions",
|
||||
"static": false,
|
||||
"templates": false,
|
||||
"version": null,
|
||||
"hooks": [
|
||||
"table_actions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "datasette.events",
|
||||
"static": false,
|
||||
|
|
@ -450,6 +459,8 @@ Secret configuration values
|
|||
|
||||
Some plugins may need configuration that should stay secret - API keys for example. There are two ways in which you can store secret configuration values.
|
||||
|
||||
The :ref:`/-/config <JsonDataView_config>` introspection endpoint redacts the values of any configuration keys whose names contain one of these substrings: ``secret``, ``key``, ``password``, ``token``, ``hash`` or ``dsn``. Name your plugin's secret configuration keys accordingly - for example ``api_key`` or ``client_secret`` - so they are automatically redacted there.
|
||||
|
||||
**As environment variables**. If your secret lives in an environment variable that is available to the Datasette process, you can indicate that the configuration value should be read from that environment variable like so:
|
||||
|
||||
.. [[[cog
|
||||
|
|
|
|||
|
|
@ -125,6 +125,23 @@ You can increase or decrease this limit like so::
|
|||
|
||||
datasette mydatabase.db --setting max_insert_rows 1000
|
||||
|
||||
.. _setting_max_post_body_bytes:
|
||||
|
||||
max_post_body_bytes
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Maximum size in bytes for a POST body that Datasette reads fully into memory, such as JSON submitted to the :ref:`write API <json_api_write>`. Requests with larger bodies are rejected with an HTTP 413 error. Defaults to 2,097,152 (2MB).
|
||||
|
||||
This limit exists to protect against memory exhaustion: unlike file uploads handled by ``request.form()``, which stream to disk, these bodies are held entirely in memory and parsing them as JSON can multiply their memory footprint several times over.
|
||||
|
||||
If you increase :ref:`setting_max_insert_rows` to support larger bulk inserts you may need to increase this limit as well::
|
||||
|
||||
datasette mydatabase.db --setting max_post_body_bytes 10485760
|
||||
|
||||
Set it to 0 to disable the limit entirely::
|
||||
|
||||
datasette mydatabase.db --setting max_post_body_bytes 0
|
||||
|
||||
.. _setting_num_sql_threads:
|
||||
|
||||
num_sql_threads
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ There are three options for specifying that you would like the response to your
|
|||
- Include ``?_json=1`` in the URL that you POST to
|
||||
- Include ``"_json": 1`` in your JSON body, or ``&_json=1`` in your form encoded body
|
||||
|
||||
The JSON response will look like this:
|
||||
A successful JSON response will look like this:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
|
|
@ -667,7 +667,21 @@ The JSON response will look like this:
|
|||
"redirect": "/data/add_name"
|
||||
}
|
||||
|
||||
The ``"message"`` and ``"redirect"`` values here will take into account ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set.
|
||||
If the SQL fails to execute - for example a constraint violation - the response uses the :ref:`standard error format <json_api_errors>` with a ``400`` status, plus the ``"redirect"`` key from the query configuration:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"ok": false,
|
||||
"error": "UNIQUE constraint failed: docs.id",
|
||||
"errors": [
|
||||
"UNIQUE constraint failed: docs.id"
|
||||
],
|
||||
"status": 400,
|
||||
"redirect": null
|
||||
}
|
||||
|
||||
The ``"message"``, ``"error"`` and ``"redirect"`` values here take into account ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set.
|
||||
|
||||
.. _pagination:
|
||||
|
||||
|
|
|
|||
497
docs/template_context.rst
Normal file
497
docs/template_context.rst
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
.. _template_context:
|
||||
|
||||
Template context
|
||||
================
|
||||
|
||||
This page documents the variables that are available to custom templates
|
||||
for each of Datasette's core pages. See :ref:`customization_custom_templates`
|
||||
for how to provide your own templates.
|
||||
|
||||
The variables documented here are a stable contract: custom templates that
|
||||
use them will continue to work across Datasette releases, up until the next
|
||||
major version (Datasette 2.0). Anything present in the template context but
|
||||
not documented on this page is not part of that contract and may change or
|
||||
be removed in any release.
|
||||
|
||||
You can inspect the full context for any page by starting Datasette with
|
||||
``--setting template_debug 1`` and adding ``?_context=1`` to the page URL.
|
||||
|
||||
.. [[[cog
|
||||
from template_context_doc import template_context
|
||||
template_context(cog)
|
||||
.. ]]]
|
||||
|
||||
Base context
|
||||
------------
|
||||
|
||||
These variables are available on every page rendered by Datasette, including pages rendered by plugins that use :ref:`datasette.render_template() <datasette_render_template>`. Plugins can add additional variables using the :ref:`plugin_hook_extra_template_vars` hook.
|
||||
|
||||
``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
|
||||
|
||||
Database page
|
||||
-------------
|
||||
|
||||
The page listing the tables, views and queries in a database, e.g. /fixtures. Rendered using the ``database.html`` template.
|
||||
|
||||
``allow_download`` - ``bool``
|
||||
Boolean indicating if database download is allowed
|
||||
|
||||
``allow_execute_sql`` - ``bool``
|
||||
Boolean indicating if custom SQL can be executed
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for the alternate JSON version of this page
|
||||
|
||||
``attached_databases`` - ``list``
|
||||
List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`.
|
||||
|
||||
``database`` - ``str``
|
||||
The name of the database
|
||||
|
||||
``database_actions`` - ``callable``
|
||||
Async callable returning action items for the database menu. 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_database_actions`.
|
||||
|
||||
``database_color`` - ``str``
|
||||
The color assigned to the database
|
||||
|
||||
``database_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.
|
||||
|
||||
``editable`` - ``bool``
|
||||
Boolean indicating if the database is editable
|
||||
|
||||
``hidden_count`` - ``int``
|
||||
Count of hidden tables
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata.
|
||||
|
||||
``path`` - ``str``
|
||||
The URL path to this database
|
||||
|
||||
``private`` - ``bool``
|
||||
Boolean indicating if this is a private database
|
||||
|
||||
``queries`` - ``list[StoredQuery]``
|
||||
List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``.
|
||||
|
||||
``queries_count`` - ``int``
|
||||
Count of visible stored queries
|
||||
|
||||
``queries_more`` - ``bool``
|
||||
Boolean indicating if more stored queries are available
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``show_hidden`` - ``str``
|
||||
Value of _show_hidden query parameter
|
||||
|
||||
``size`` - ``int``
|
||||
The size of the database in bytes
|
||||
|
||||
``table_columns`` - ``dict``
|
||||
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
|
||||
|
||||
``tables`` - ``list[DatabaseTable]``
|
||||
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
|
||||
``top_database`` - ``callable``
|
||||
Async callable that renders the ``top_database`` plugin slot for this database and returns HTML.
|
||||
|
||||
``views`` - ``list[DatabaseViewInfo]``
|
||||
List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes.
|
||||
|
||||
Query page
|
||||
----------
|
||||
|
||||
The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name). Rendered using the ``query.html`` template.
|
||||
|
||||
``allow_execute_sql`` - ``bool``
|
||||
Boolean indicating if custom SQL can be executed
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for alternate JSON version of this page
|
||||
|
||||
``columns`` - ``list``
|
||||
List of result column names in the order they appear in ``display_rows`` and ``rows``.
|
||||
|
||||
``database`` - ``str``
|
||||
The name of the database being queried
|
||||
|
||||
``database_color`` - ``str``
|
||||
The color of the database
|
||||
|
||||
``db_is_immutable`` - ``bool``
|
||||
Boolean indicating if this database is immutable
|
||||
|
||||
``display_rows`` - ``list``
|
||||
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.
|
||||
|
||||
``edit_sql_url`` - ``str``
|
||||
URL to edit the SQL for a stored query
|
||||
|
||||
``editable`` - ``bool``
|
||||
Boolean indicating if the SQL can be edited
|
||||
|
||||
``error`` - ``str``
|
||||
Any query error message
|
||||
|
||||
``hide_sql`` - ``bool``
|
||||
Boolean indicating if the SQL should be hidden
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``.
|
||||
|
||||
``named_parameter_values`` - ``dict``
|
||||
Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``.
|
||||
|
||||
``private`` - ``bool``
|
||||
Boolean indicating if this is a private database
|
||||
|
||||
``query`` - ``dict``
|
||||
Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys.
|
||||
|
||||
``query_actions`` - ``callable``
|
||||
Async callable returning action items for the query menu. 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_query_actions`.
|
||||
|
||||
``renderers`` - ``dict``
|
||||
Dictionary mapping output format names such as ``json`` to URLs for this query in that format.
|
||||
|
||||
``save_query_url`` - ``str``
|
||||
URL to save the current arbitrary SQL as a query
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``show_hide_hidden`` - ``str``
|
||||
Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state.
|
||||
|
||||
``show_hide_link`` - ``str``
|
||||
The URL to toggle showing/hiding the SQL
|
||||
|
||||
``show_hide_text`` - ``str``
|
||||
The text for the show/hide SQL link
|
||||
|
||||
``stored_query`` - ``str``
|
||||
The name of the stored query if this is a stored query
|
||||
|
||||
``stored_query_write`` - ``bool``
|
||||
Boolean indicating if this is a stored query that allows writes
|
||||
|
||||
``table_columns`` - ``dict``
|
||||
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
|
||||
|
||||
``tables`` - ``list[DatabaseTable]``
|
||||
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
|
||||
``top_query`` - ``callable``
|
||||
Async callable that renders the ``top_query`` plugin slot for this query and returns HTML.
|
||||
|
||||
``top_stored_query`` - ``callable``
|
||||
Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML.
|
||||
|
||||
``url_csv`` - ``str``
|
||||
URL for CSV export
|
||||
|
||||
Table page
|
||||
----------
|
||||
|
||||
The page showing the rows in a table or SQL view, e.g. /fixtures/facetable. Rendered using the ``table.html`` template.
|
||||
|
||||
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
|
||||
|
||||
``actions`` - ``callable``
|
||||
Async callable returning table or view 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`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.
|
||||
|
||||
``all_columns`` - ``list``
|
||||
List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering.
|
||||
|
||||
``allow_execute_sql`` - ``bool``
|
||||
True if the current actor can execute custom SQL against this database
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for the JSON version of this page
|
||||
|
||||
``append_querystring`` - ``callable``
|
||||
Function ``append_querystring(url, querystring)`` that appends additional query string arguments to a URL, using ``?`` or ``&`` as appropriate.
|
||||
|
||||
``columns`` - ``list``
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``count`` - ``int``
|
||||
Total count of rows matching these filters
|
||||
|
||||
``count_sql`` - ``str``
|
||||
SQL query string used to calculate the total count for the current table view, including active filters.
|
||||
|
||||
``count_truncated`` - ``bool``
|
||||
True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit.
|
||||
|
||||
``custom_table_templates`` - ``list``
|
||||
List of custom template names considered for rendering table rows, in lookup order.
|
||||
|
||||
``database`` - ``str``
|
||||
Database name
|
||||
|
||||
``database_color`` - ``str``
|
||||
Color assigned to the database
|
||||
|
||||
``datasette_allow_facet`` - ``str``
|
||||
The string "true" or "false" reflecting the allow_facet setting
|
||||
|
||||
``display_columns`` - ``list``
|
||||
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``
|
||||
Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object.
|
||||
|
||||
``expandable_columns`` - ``list``
|
||||
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``.
|
||||
|
||||
``extra_wheres_for_ui`` - ``list``
|
||||
Extra where clauses from ``?_where=`` for display in the UI. Each item has ``text`` for the SQL fragment and ``remove_url`` for a URL that removes that fragment.
|
||||
|
||||
``facet_results`` - ``dict``
|
||||
Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``.
|
||||
|
||||
``facets_timed_out`` - ``list``
|
||||
List of names of facet calculations that exceeded the facet time limit.
|
||||
|
||||
``filter_columns`` - ``list``
|
||||
List of column names offered by the filter interface, including currently displayed columns and any hidden columns that can still be filtered.
|
||||
|
||||
``filters`` - ``Filters``
|
||||
``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable.
|
||||
|
||||
``fix_path`` - ``callable``
|
||||
Function that applies the configured ``base_url`` prefix to a path.
|
||||
|
||||
``form_hidden_args`` - ``list``
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options.
|
||||
|
||||
``human_description_en`` - ``str``
|
||||
Human-readable description of the filters
|
||||
|
||||
``is_sortable`` - ``bool``
|
||||
True if any of the displayed columns can be used to sort
|
||||
|
||||
``is_view`` - ``bool``
|
||||
Whether this resource is a view instead of a table
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration.
|
||||
|
||||
``next`` - ``str``
|
||||
Pagination token for the next page, or None
|
||||
|
||||
``next_url`` - ``str``
|
||||
Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`.
|
||||
|
||||
``ok`` - ``bool``
|
||||
True if the data for this page was retrieved without errors
|
||||
|
||||
``path_with_replaced_args`` - ``callable``
|
||||
Function for building the current path with modified query string arguments. Pass the current ``request`` and a dictionary of argument names to replacement values, using ``None`` to remove an argument.
|
||||
|
||||
``primary_keys`` - ``list``
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``private`` - ``bool``
|
||||
Whether this resource is private to the current actor
|
||||
|
||||
``query`` - ``dict``
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``query_ms`` - ``float``
|
||||
Time taken by the SQL queries for this page, in milliseconds
|
||||
|
||||
``renderers`` - ``dict``
|
||||
Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format.
|
||||
|
||||
``rows`` - ``list``
|
||||
The rows for this page, as a list of dictionaries mapping column name to raw value.
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``set_column_type_ui`` - ``dict``
|
||||
Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values.
|
||||
|
||||
``settings`` - ``dict``
|
||||
Dictionary of Datasette's current settings, keyed by setting name.
|
||||
|
||||
``sort`` - ``str``
|
||||
Column the page is sorted by, or None
|
||||
|
||||
``sort_desc`` - ``str``
|
||||
Column the page is sorted by in descending order, or None
|
||||
|
||||
``sorted_facet_results`` - ``list``
|
||||
Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``.
|
||||
|
||||
``suggested_facets`` - ``list``
|
||||
Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class.
|
||||
|
||||
``supports_search`` - ``bool``
|
||||
True if this table has full-text search configured
|
||||
|
||||
``table`` - ``str``
|
||||
Table name
|
||||
|
||||
``table_alter_ui`` - ``dict``
|
||||
Information needed to enable the alter table UI, or ``None`` if altering this table is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``primaryKeys``, ``columnTypes``, ``defaultExpressions`` and ``foreignKeyTargetsPath`` keys, plus optional ``customColumnTypes`` and ``dropPath`` keys.
|
||||
|
||||
``table_definition`` - ``str``
|
||||
SQL definition for this table
|
||||
|
||||
``table_insert_ui`` - ``dict``
|
||||
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys.
|
||||
|
||||
``table_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.
|
||||
|
||||
``top_table`` - ``callable``
|
||||
Async callable that renders the ``top_table`` plugin slot for this table or view and returns HTML.
|
||||
|
||||
``url_csv`` - ``str``
|
||||
URL for the CSV export of this page
|
||||
|
||||
``url_csv_hidden_args`` - ``list``
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current filters while forcing ``_size=max``.
|
||||
|
||||
``url_csv_path`` - ``str``
|
||||
Path portion of the CSV export URL
|
||||
|
||||
``view_definition`` - ``str``
|
||||
SQL definition for this view
|
||||
|
||||
Row page
|
||||
--------
|
||||
|
||||
The page showing an individual row, e.g. /fixtures/facetable/1. Rendered using the ``row.html`` template.
|
||||
|
||||
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for the JSON version of this page
|
||||
|
||||
``columns`` - ``list``
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``custom_table_templates`` - ``list``
|
||||
Custom template names that were considered for displaying this row's table, in lookup order.
|
||||
|
||||
``database`` - ``str``
|
||||
Database name
|
||||
|
||||
``database_color`` - ``str``
|
||||
Color assigned to the database
|
||||
|
||||
``display_columns`` - ``list``
|
||||
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``
|
||||
Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys.
|
||||
|
||||
``foreign_key_tables`` - ``list``
|
||||
List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL.
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration.
|
||||
|
||||
``ok`` - ``bool``
|
||||
True if the data for this page was retrieved without errors
|
||||
|
||||
``primary_key_values`` - ``list``
|
||||
Values of the primary keys for this row, from the URL
|
||||
|
||||
``primary_keys`` - ``list``
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``private`` - ``bool``
|
||||
Whether this resource is private to the current actor
|
||||
|
||||
``query_ms`` - ``float``
|
||||
Time taken by the SQL queries for this page, in milliseconds
|
||||
|
||||
``renderers`` - ``dict``
|
||||
Dictionary mapping output format names such as ``json`` to URLs for this row in that format.
|
||||
|
||||
``row_actions`` - ``list``
|
||||
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``
|
||||
True if the row edit/delete JavaScript UI should be enabled
|
||||
|
||||
``rows`` - ``list``
|
||||
A single-item list containing this row as a dictionary mapping column name to raw value.
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``settings`` - ``dict``
|
||||
Dictionary of Datasette's current settings, keyed by setting name.
|
||||
|
||||
``table`` - ``str``
|
||||
Table name
|
||||
|
||||
``table_page_data`` - ``dict``
|
||||
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``
|
||||
Async callable that renders the ``top_row`` plugin slot for this row and returns HTML.
|
||||
|
||||
``url_csv`` - ``str``
|
||||
URL for the CSV export of this page
|
||||
|
||||
``url_csv_hidden_args`` - ``list``
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``.
|
||||
|
||||
``url_csv_path`` - ``str``
|
||||
Path portion of the CSV export URL
|
||||
|
||||
.. [[[end]]]
|
||||
45
docs/template_context_doc.py
Normal file
45
docs/template_context_doc.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""
|
||||
Cog helpers for generating docs/template_context.rst from the Context
|
||||
dataclasses and TEMPLATE_BASE_CONTEXT - same pattern as json_api_doc.py.
|
||||
"""
|
||||
|
||||
|
||||
def template_context(cog):
|
||||
from datasette.app import TEMPLATE_BASE_CONTEXT
|
||||
from datasette.template_contexts import PAGES
|
||||
|
||||
cog.out("\n")
|
||||
_section(
|
||||
cog,
|
||||
"Base context",
|
||||
(
|
||||
"These variables are available on every page rendered by "
|
||||
"Datasette, including pages rendered by plugins that use "
|
||||
":ref:`datasette.render_template() <datasette_render_template>`. "
|
||||
"Plugins can add additional variables using the "
|
||||
":ref:`plugin_hook_extra_template_vars` hook."
|
||||
),
|
||||
)
|
||||
for name, doc in TEMPLATE_BASE_CONTEXT.items():
|
||||
cog.out("``{}``\n".format(name))
|
||||
cog.out(" {}\n\n".format(doc))
|
||||
|
||||
for klass in PAGES.values():
|
||||
title = "{} page".format(klass.__name__.removesuffix("Context"))
|
||||
intro = "{} Rendered using the ``{}`` template.".format(
|
||||
klass.__doc__, klass.documented_template
|
||||
)
|
||||
_section(cog, title, intro)
|
||||
if klass.extras_scope is not None:
|
||||
cog.out(
|
||||
"Many of these keys are shared with the :ref:`JSON API "
|
||||
"<json_api>` for this page.\n\n"
|
||||
)
|
||||
for f in sorted(klass.documented_fields(), key=lambda f: f.name):
|
||||
cog.out("``{}`` - ``{}``\n".format(f.name, f.type_name))
|
||||
cog.out(" {}\n\n".format(f.help))
|
||||
|
||||
|
||||
def _section(cog, title, intro):
|
||||
cog.out("{}\n{}\n\n".format(title, "-" * len(title)))
|
||||
cog.out("{}\n\n".format(intro))
|
||||
|
|
@ -145,7 +145,16 @@ If your plugin has a ``static/`` directory, Datasette will automatically configu
|
|||
|
||||
/-/static-plugins/NAME_OF_PLUGIN_PACKAGE/yourfile.js
|
||||
|
||||
Use the ``datasette.urls.static_plugins(plugin_name, path)`` method to generate URLs to that asset that take the ``base_url`` setting into account, see :ref:`internals_datasette_urls`.
|
||||
Use the ``datasette.static(path, plugin=plugin_name)`` method to generate
|
||||
cache-busting URLs to those assets that take the ``base_url`` setting into
|
||||
account, see :ref:`datasette_static`.
|
||||
|
||||
This can also be used from plugin templates as the ``static()`` template
|
||||
function:
|
||||
|
||||
.. code-block:: html+jinja
|
||||
|
||||
<script src="{{ static('plugin.js', plugin='datasette_plugin_name') }}" defer></script>
|
||||
|
||||
To bundle the static assets for a plugin in the package that you publish to PyPI, add the following to the plugin's ``setup.py``:
|
||||
|
||||
|
|
|
|||
|
|
@ -35,10 +35,11 @@ dependencies = [
|
|||
"PyYAML>=5.3",
|
||||
"mergedeep>=1.1.1",
|
||||
"itsdangerous>=1.1",
|
||||
"sqlite-utils>=3.30",
|
||||
"sqlite-utils>=4.0",
|
||||
"asyncinject>=0.7",
|
||||
"setuptools",
|
||||
"pip",
|
||||
"pydantic>=2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
|
|
|||
|
|
@ -1,40 +1,37 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
# So the script fails if there are any errors
|
||||
set -euo pipefail
|
||||
|
||||
read -r -a PYTHON_CMD <<< "${PYTHON:-python3}"
|
||||
read -r -a SHOT_SCRAPER_CMD <<< "${SHOT_SCRAPER:-shot-scraper}"
|
||||
|
||||
# Build the wheel
|
||||
python3 -m build
|
||||
"${PYTHON_CMD[@]}" -m build
|
||||
|
||||
# Find name of wheel, strip off the dist/
|
||||
wheel=$(basename $(ls dist/*.whl) | head -n 1)
|
||||
# Find name of most recently built wheel, strip off the dist/
|
||||
wheel=$(basename "$(ls -t dist/*.whl | head -n 1)")
|
||||
|
||||
# Create a blank index page
|
||||
echo '
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/full/pyodide.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v314.0.0/full/pyodide.js"></script>
|
||||
' > dist/index.html
|
||||
|
||||
# Run a server for that dist/ folder
|
||||
cd dist
|
||||
python3 -m http.server 8529 &
|
||||
cd ..
|
||||
"${PYTHON_CMD[@]}" -m http.server 8529 --directory dist &
|
||||
server_pid=$!
|
||||
|
||||
# Register the kill_server function to be called on script exit
|
||||
kill_server() {
|
||||
pkill -f 'http.server 8529'
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
}
|
||||
trap kill_server EXIT
|
||||
|
||||
|
||||
shot-scraper javascript http://localhost:8529/ "
|
||||
"${SHOT_SCRAPER_CMD[@]}" javascript http://localhost:8529/ "
|
||||
async () => {
|
||||
let pyodide = await loadPyodide();
|
||||
await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']);
|
||||
await pyodide.loadPackage(['micropip', 'setuptools']);
|
||||
let output = await pyodide.runPythonAsync(\`
|
||||
import micropip
|
||||
await micropip.install('h11==0.12.0')
|
||||
await micropip.install('httpx==0.23')
|
||||
# To avoid 'from typing_extensions import deprecated' error:
|
||||
await micropip.install('typing-extensions>=4.12.2')
|
||||
await micropip.install('http://localhost:8529/$wheel')
|
||||
import ssl
|
||||
import setuptools
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import httpx
|
||||
import importlib.metadata
|
||||
import os
|
||||
import pathlib
|
||||
import pytest
|
||||
|
|
@ -93,7 +94,11 @@ def pytest_report_header(config):
|
|||
conn = sqlite3.connect(":memory:")
|
||||
version = conn.execute("select sqlite_version()").fetchone()[0]
|
||||
conn.close()
|
||||
headers = ["SQLite: {}".format(version)]
|
||||
sqlite_utils_version = importlib.metadata.version("sqlite-utils")
|
||||
headers = [
|
||||
"SQLite: {}".format(version),
|
||||
"sqlite-utils: {}".format(sqlite_utils_version),
|
||||
]
|
||||
if config.getoption("--playwright"):
|
||||
try:
|
||||
browsers = config.getoption("--browser")
|
||||
|
|
@ -255,8 +260,12 @@ def ds_unix_domain_socket_server(tmp_path_factory):
|
|||
# This used to use tmp_path_factory.mktemp("uds") but that turned out to
|
||||
# produce paths that were too long to use as UDS on macOS, see
|
||||
# https://github.com/simonw/datasette/issues/1407 - so I switched to
|
||||
# using tempfile.gettempdir()
|
||||
uds = str(pathlib.Path(tempfile.gettempdir()) / "datasette.sock")
|
||||
# using tempfile.gettempdir() with a per-process filename.
|
||||
uds = str(pathlib.Path(tempfile.gettempdir()) / f"datasette-{os.getpid()}.sock")
|
||||
try:
|
||||
os.unlink(uds)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
ds_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "datasette", "--memory", "--uds", uds],
|
||||
stdout=subprocess.PIPE,
|
||||
|
|
@ -266,12 +275,26 @@ def ds_unix_domain_socket_server(tmp_path_factory):
|
|||
# Poll until available
|
||||
transport = httpx.HTTPTransport(uds=uds)
|
||||
client = httpx.Client(transport=transport)
|
||||
wait_until_responds("http://localhost/_memory.json", client=client)
|
||||
# Check it started successfully
|
||||
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
||||
yield ds_proc, uds
|
||||
# Shut it down at the end of the pytest session
|
||||
ds_proc.terminate()
|
||||
try:
|
||||
wait_until_responds(
|
||||
"http://localhost/_memory.json", timeout=30.0, client=client
|
||||
)
|
||||
# Check it started successfully
|
||||
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
||||
yield ds_proc, uds
|
||||
finally:
|
||||
client.close()
|
||||
# Shut it down at the end of the pytest session
|
||||
ds_proc.terminate()
|
||||
try:
|
||||
ds_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
ds_proc.kill()
|
||||
ds_proc.wait()
|
||||
try:
|
||||
os.unlink(uds)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
# Import fixtures from fixtures.py to make them available
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from datasette.app import Datasette
|
||||
from datasette.plugins import DEFAULT_PLUGINS
|
||||
from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode
|
||||
from datasette.utils.sqlite import sqlite_version
|
||||
from datasette.version import __version__
|
||||
from .fixtures import make_app_client, EXPECTED_PLUGINS
|
||||
|
|
@ -26,8 +27,9 @@ async def test_homepage(ds_client):
|
|||
"title",
|
||||
]
|
||||
databases = data.get("databases")
|
||||
assert databases.keys() == {"fixtures": 0}.keys()
|
||||
d = databases["fixtures"]
|
||||
assert isinstance(databases, list)
|
||||
assert [d["name"] for d in databases] == ["fixtures"]
|
||||
d = databases[0]
|
||||
assert d["name"] == "fixtures"
|
||||
assert isinstance(d["tables_count"], int)
|
||||
assert isinstance(len(d["tables_and_views_truncated"]), int)
|
||||
|
|
@ -42,8 +44,7 @@ async def test_homepage_sort_by_relationships(ds_client):
|
|||
response = await ds_client.get("/.json?_sort=relationships")
|
||||
assert response.status_code == 200
|
||||
tables = [
|
||||
t["name"]
|
||||
for t in response.json()["databases"]["fixtures"]["tables_and_views_truncated"]
|
||||
t["name"] for t in response.json()["databases"][0]["tables_and_views_truncated"]
|
||||
]
|
||||
assert tables == [
|
||||
"simple_primary_key",
|
||||
|
|
@ -250,8 +251,10 @@ def test_no_files_uses_memory_database(app_client_no_files):
|
|||
response = app_client_no_files.get("/.json")
|
||||
assert response.status == 200
|
||||
assert {
|
||||
"databases": {
|
||||
"_memory": {
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"databases": [
|
||||
{
|
||||
"name": "_memory",
|
||||
"hash": None,
|
||||
"color": "a6c7b9",
|
||||
|
|
@ -266,7 +269,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
|
|||
"views_count": 0,
|
||||
"private": False,
|
||||
},
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
} == response.json
|
||||
# Try that SQL query
|
||||
|
|
@ -323,20 +326,15 @@ def test_sql_time_limit(app_client_shorter_time_limit):
|
|||
"/fixtures/-/query.json?sql=select+sleep(0.5)",
|
||||
)
|
||||
assert 400 == response.status
|
||||
expected_message = (
|
||||
"SQL query took too long. The time limit is"
|
||||
" controlled by the sql_time_limit_ms setting."
|
||||
)
|
||||
assert response.json == {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"<p>SQL query took too long. The time limit is controlled by the\n"
|
||||
'<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>\n'
|
||||
"configuration option.</p>\n"
|
||||
'<textarea style="width: 90%">select sleep(0.5)</textarea>\n'
|
||||
"<script>\n"
|
||||
'let ta = document.querySelector("textarea");\n'
|
||||
'ta.style.height = ta.scrollHeight + "px";\n'
|
||||
"</script>"
|
||||
),
|
||||
"error": expected_message,
|
||||
"errors": [expected_message],
|
||||
"status": 400,
|
||||
"title": "SQL Interrupted",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -350,7 +348,7 @@ async def test_custom_sql_time_limit(ds_client):
|
|||
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["title"] == "SQL Interrupted"
|
||||
assert response.json()["error"].startswith("SQL query took too long.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -371,6 +369,40 @@ async def test_row(ds_client):
|
|||
assert response.json()["rows"] == [{"id": 1, "content": "hello"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("suffix", ("", ".json"))
|
||||
@pytest.mark.parametrize(
|
||||
"row_path",
|
||||
(
|
||||
"a", # too few components for a two-column primary key
|
||||
"a,b,c", # too many components for a two-column primary key
|
||||
),
|
||||
)
|
||||
async def test_row_pk_arity_mismatch_returns_400(ds_client, row_path, suffix):
|
||||
# A row URL with the wrong number of comma-separated primary key
|
||||
# components used to raise an uncaught sqlite3.ProgrammingError (HTTP 500)
|
||||
# because the SQL had one bind placeholder per PK column but params were
|
||||
# only bound for the supplied components. It should be a 400 instead,
|
||||
# mirroring the existing guard in datasette/views/table.py.
|
||||
response = await ds_client.get(
|
||||
"/fixtures/compound_primary_key/{}{}".format(row_path, suffix)
|
||||
)
|
||||
assert response.status_code == 400
|
||||
if suffix == ".json":
|
||||
assert response.json()["ok"] is False
|
||||
assert response.json()["status"] == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_compound_pk_correct_arity(ds_client):
|
||||
# The valid two-component URL still resolves the row.
|
||||
response = await ds_client.get(
|
||||
"/fixtures/compound_primary_key/a,b.json?_shape=objects"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rows"] == [{"pk1": "a", "pk2": "b", "content": "c"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_strange_table_name(ds_client):
|
||||
response = await ds_client.get(
|
||||
|
|
@ -429,7 +461,7 @@ async def test_row_foreign_key_tables(ds_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_row_extras(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables"
|
||||
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables,column_details"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
|
@ -446,6 +478,45 @@ async def test_row_extras(ds_client):
|
|||
"format": "json",
|
||||
}
|
||||
assert len(data["foreign_key_tables"]) == 5
|
||||
id_detail = data["column_details"]["id"]
|
||||
assert id_detail["type"].lower() == "integer"
|
||||
assert id_detail == {
|
||||
"type": id_detail["type"],
|
||||
"sqlite_type": "INTEGER",
|
||||
"notnull": False,
|
||||
"default": None,
|
||||
"is_pk": True,
|
||||
"pk_position": 1,
|
||||
"hidden": 0,
|
||||
}
|
||||
content_detail = data["column_details"]["content"]
|
||||
assert content_detail["type"].lower() == "text"
|
||||
assert content_detail == {
|
||||
"type": content_detail["type"],
|
||||
"sqlite_type": "TEXT",
|
||||
"notnull": False,
|
||||
"default": None,
|
||||
"is_pk": False,
|
||||
"pk_position": 0,
|
||||
"hidden": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_column_details_extra_row_for_null_blob(ds_client):
|
||||
response = await ds_client.get("/fixtures/binary_data/3.json?_extra=column_details")
|
||||
assert response.status_code == 200
|
||||
data_detail = response.json()["column_details"]["data"]
|
||||
assert data_detail["type"].lower() == "blob"
|
||||
assert data_detail == {
|
||||
"type": data_detail["type"],
|
||||
"sqlite_type": "BLOB",
|
||||
"notnull": False,
|
||||
"default": None,
|
||||
"is_pk": False,
|
||||
"pk_position": 0,
|
||||
"hidden": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -507,7 +578,7 @@ async def test_row_extra_render_cell():
|
|||
|
||||
def test_databases_json(app_client_two_attached_databases_one_immutable):
|
||||
response = app_client_two_attached_databases_one_immutable.get("/-/databases.json")
|
||||
databases = response.json
|
||||
databases = response.json["databases"]
|
||||
assert 2 == len(databases)
|
||||
extra_database, fixtures_database = databases
|
||||
assert "extra database" == extra_database["name"]
|
||||
|
|
@ -523,8 +594,12 @@ def test_databases_json(app_client_two_attached_databases_one_immutable):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_threads_json(ds_client):
|
||||
response = await ds_client.get("/-/threads.json")
|
||||
expected_keys = {"threads", "num_threads"}
|
||||
ds_client.ds.root_enabled = True
|
||||
try:
|
||||
response = await ds_client.get("/-/threads.json", actor={"id": "root"})
|
||||
finally:
|
||||
ds_client.ds.root_enabled = False
|
||||
expected_keys = {"ok", "threads", "num_threads"}
|
||||
if sys.version_info >= (3, 7, 0):
|
||||
expected_keys.update({"tasks", "num_tasks"})
|
||||
data = response.json()
|
||||
|
|
@ -577,7 +652,7 @@ async def test_actions_json(ds_client):
|
|||
try:
|
||||
ds_client.ds.root_enabled = True
|
||||
response = await ds_client.get("/-/actions.json", actor={"id": "root"})
|
||||
data = response.json()
|
||||
data = response.json()["actions"]
|
||||
finally:
|
||||
ds_client.ds.root_enabled = original_root_enabled
|
||||
assert isinstance(data, list)
|
||||
|
|
@ -609,6 +684,7 @@ async def test_actions_json(ds_client):
|
|||
async def test_settings_json(ds_client):
|
||||
response = await ds_client.get("/-/settings.json")
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"default_page_size": 50,
|
||||
"default_facet_size": 30,
|
||||
"default_allow_sql": True,
|
||||
|
|
@ -616,6 +692,7 @@ async def test_settings_json(ds_client):
|
|||
"facet_time_limit_ms": 200,
|
||||
"max_returned_rows": 100,
|
||||
"max_insert_rows": 100,
|
||||
"max_post_body_bytes": 2 * 1024 * 1024,
|
||||
"sql_time_limit_ms": 200,
|
||||
"allow_download": True,
|
||||
"allow_signed_tokens": True,
|
||||
|
|
@ -677,7 +754,7 @@ def test_config_cache_size(app_client_larger_cache_size):
|
|||
def test_config_force_https_urls():
|
||||
with make_app_client(settings={"force_https_urls": True}) as client:
|
||||
response = client.get(
|
||||
"/fixtures/facetable.json?_size=3&_facet=state&_extra=next_url,suggested_facets"
|
||||
"/fixtures/facetable.json?_size=3&_facet=state&_extra=suggested_facets"
|
||||
)
|
||||
assert response.json["next_url"].startswith("https://")
|
||||
assert response.json["facet_results"]["results"]["state"]["results"][0][
|
||||
|
|
@ -772,7 +849,9 @@ def test_common_prefix_database_names(app_client_conflicting_database_names):
|
|||
# https://github.com/simonw/datasette/issues/597
|
||||
assert ["foo-bar", "foo", "fixtures"] == [
|
||||
d["name"]
|
||||
for d in app_client_conflicting_database_names.get("/-/databases.json").json
|
||||
for d in app_client_conflicting_database_names.get("/-/databases.json").json[
|
||||
"databases"
|
||||
]
|
||||
]
|
||||
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
|
||||
data = app_client_conflicting_database_names.get(path).json
|
||||
|
|
@ -843,13 +922,45 @@ async def test_tilde_encoded_database_names(db_name):
|
|||
ds = Datasette()
|
||||
ds.add_memory_database(db_name)
|
||||
response = await ds.client.get("/.json")
|
||||
assert db_name in response.json()["databases"].keys()
|
||||
path = response.json()["databases"][db_name]["path"]
|
||||
databases_by_name = {d["name"]: d for d in response.json()["databases"]}
|
||||
assert db_name in databases_by_name
|
||||
path = databases_by_name[db_name]["path"]
|
||||
# And the JSON for that database
|
||||
response2 = await ds.client.get(path + ".json")
|
||||
assert response2.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("table_name", ("[foo]", "foo]", "[foo]/bar"))
|
||||
async def test_table_with_reserved_characters_in_name(table_name):
|
||||
# Table names containing characters such as "]" that cannot be escaped
|
||||
# using SQLite [bracket] quoting used to break schema introspection and
|
||||
# the table page - https://github.com/simonw/datasette/issues/2431
|
||||
ds = Datasette()
|
||||
db = ds.add_memory_database("test_reserved_table_names")
|
||||
await db.execute_write(
|
||||
"create table {} (id integer primary key, name text)".format(
|
||||
escape_sqlite(table_name)
|
||||
)
|
||||
)
|
||||
await db.execute_write(
|
||||
"insert into {} (id, name) values (1, 'one')".format(escape_sqlite(table_name))
|
||||
)
|
||||
# Schema introspection (populate_schema_tables) must not crash:
|
||||
db_response = await ds.client.get("/test_reserved_table_names.json")
|
||||
assert db_response.status_code == 200
|
||||
tables = {t["name"]: t for t in db_response.json()["tables"]}
|
||||
assert tables[table_name]["count"] == 1
|
||||
# And the table page itself must load and return the row:
|
||||
table_response = await ds.client.get(
|
||||
"/test_reserved_table_names/{}.json?_shape=array".format(
|
||||
tilde_encode(table_name)
|
||||
)
|
||||
)
|
||||
assert table_response.status_code == 200
|
||||
assert table_response.json() == [{"id": 1, "name": "one"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"config,expected",
|
||||
|
|
@ -883,7 +994,7 @@ async def test_config_json(config, expected):
|
|||
"/-/config.json should return redacted configuration"
|
||||
ds = Datasette(config=config)
|
||||
response = await ds.client.get("/-/config.json")
|
||||
assert response.json() == expected
|
||||
assert response.json() == {"ok": True, **expected}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -979,7 +1090,7 @@ async def test_config_json(config, expected):
|
|||
async def test_upgrade_metadata(metadata, expected_config, expected_metadata):
|
||||
ds = Datasette(metadata=metadata)
|
||||
response = await ds.client.get("/-/config.json")
|
||||
assert response.json() == expected_config
|
||||
assert response.json() == {"ok": True, **expected_config}
|
||||
response2 = await ds.client.get("/-/metadata.json")
|
||||
assert response2.json() == expected_metadata
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -236,7 +236,9 @@ def test_auth_create_token(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_create_token_not_allowed_for_tokens(ds_client):
|
||||
ds_tok = ds_client.ds.sign({"a": "test", "token": "dstok"}, "token")
|
||||
ds_tok = ds_client.ds.sign(
|
||||
{"a": "test", "token": "dstok", "t": int(time.time())}, "token"
|
||||
)
|
||||
response = await ds_client.get(
|
||||
"/-/create-token",
|
||||
headers={"Authorization": "Bearer dstok_{}".format(ds_tok)},
|
||||
|
|
@ -294,7 +296,7 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
|
|||
try:
|
||||
if should_work:
|
||||
data = response.json()
|
||||
assert data.keys() == {"actor"}
|
||||
assert data.keys() == {"ok", "actor"}
|
||||
actor = data["actor"]
|
||||
expected_keys = {"id", "token"}
|
||||
if scenario != "valid_unlimited_token":
|
||||
|
|
@ -304,8 +306,16 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
|
|||
assert actor["token"] == "dstok"
|
||||
if scenario != "valid_unlimited_token":
|
||||
assert isinstance(actor["token_expires"], int)
|
||||
elif scenario == "no_token":
|
||||
# No credentials presented - request proceeds as anonymous
|
||||
assert response.json() == {"ok": True, "actor": None}
|
||||
else:
|
||||
assert response.json() == {"actor": None}
|
||||
# Invalid credentials presented - hard 401
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["ok"] is False
|
||||
assert data["status"] == 401
|
||||
assert response.headers["www-authenticate"].startswith("Bearer")
|
||||
finally:
|
||||
ds_client.ds._settings["allow_signed_tokens"] = True
|
||||
|
||||
|
|
@ -337,10 +347,11 @@ def test_cli_create_token(app_client, expires):
|
|||
}
|
||||
if expires and expires > 0:
|
||||
expected_actor["token_expires"] = details["t"] + expires
|
||||
assert response.json == {"actor": expected_actor}
|
||||
assert response.json == {"ok": True, "actor": expected_actor}
|
||||
else:
|
||||
expected_actor = None
|
||||
assert response.json == {"actor": expected_actor}
|
||||
# Expired token - hard 401
|
||||
assert response.status == 401
|
||||
assert response.json["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -25,13 +25,14 @@ async def test_autocomplete_single_pk_exact_match_and_label_order():
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{"pks": {"id": 2}, "label": "Longer non-label pk match"},
|
||||
{"pks": {"id": 20}, "label": "2"},
|
||||
{"pks": {"id": 21}, "label": "22"},
|
||||
{"pks": {"id": 3}, "label": "A label containing 2"},
|
||||
{"pks": {"id": 200}, "label": "A"},
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -52,12 +53,12 @@ async def test_autocomplete_blank_q_returns_no_results():
|
|||
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete?q=")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"rows": []}
|
||||
assert response.json() == {"ok": True, "rows": []}
|
||||
|
||||
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"rows": []}
|
||||
assert response.json() == {"ok": True, "rows": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -81,11 +82,12 @@ async def test_autocomplete_initial_returns_latest_rows():
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{"pks": {"id": 3}, "label": "Cleo"},
|
||||
{"pks": {"id": 2}, "label": "Bob"},
|
||||
{"pks": {"id": 1}, "label": "Alice"},
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
response = await ds.client.get(
|
||||
|
|
@ -94,11 +96,12 @@ async def test_autocomplete_initial_returns_latest_rows():
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{"pks": {"id": 3}, "label": "Cleo"},
|
||||
{"pks": {"id": 2}, "label": "Bob"},
|
||||
{"pks": {"id": 1}, "label": "Alice"},
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -121,9 +124,10 @@ async def test_autocomplete_escapes_like_characters():
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{"pks": {"id": 1}, "label": "100% real"},
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -149,11 +153,12 @@ async def test_autocomplete_compound_pk_searches_all_pk_columns():
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{"pks": {"country": "mx", "code": "ca"}, "label": "Campeche"},
|
||||
{"pks": {"country": "us", "code": "ca"}, "label": "California"},
|
||||
{"pks": {"country": "ca", "code": "bc"}, "label": "British Columbia"},
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -184,9 +189,10 @@ async def test_autocomplete_primary_key_called_label():
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{"pks": {"label": "abc"}, "label": "Display value"},
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -246,8 +252,9 @@ async def test_autocomplete_timeout_uses_prefix_fallback(monkeypatch):
|
|||
assert timeout_was_simulated
|
||||
data = response.json()
|
||||
assert data == {
|
||||
"ok": True,
|
||||
"rows": [
|
||||
{"pks": {"id": f"item-1999{i:02d}"}, "label": f"name 1999{i:02d}"}
|
||||
for i in range(10)
|
||||
]
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ async def test_get_view():
|
|||
assert json.loads(post_json_response.body) == {
|
||||
"ok": False,
|
||||
"error": "Method not allowed",
|
||||
"errors": ["Method not allowed"],
|
||||
"status": 405,
|
||||
}
|
||||
assert post_json_response.status == 405
|
||||
|
||||
|
|
|
|||
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