mirror of
https://github.com/simonw/datasette.git
synced 2026-07-13 02:54:44 +02:00
Compare commits
403 commits
claude/war
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1ae55007f2 | ||
|
|
8c700f5337 | ||
|
|
39a397e5b7 |
||
|
|
3edc98a871 | ||
|
|
a8124172dd | ||
|
|
2f27b08405 | ||
|
|
77fa49cf17 | ||
|
|
2a887e5853 | ||
|
|
b5ba41c9db | ||
|
|
0a0491568a | ||
|
|
dc6f207d37 | ||
|
|
49362a9b20 | ||
|
|
0748b561d5 | ||
|
|
387e309b3b | ||
|
|
3cfdca026a | ||
|
|
b5fa485a9f | ||
|
|
6bbd33d81d | ||
|
|
047b69e87f | ||
|
|
6cd65cf4fb | ||
|
|
bba7e0b027 | ||
|
|
f1af216852 |
||
|
|
f157df7f07 | ||
|
|
82c95a1a13 | ||
|
|
4ce2888e79 | ||
|
|
3f7d389caf | ||
|
|
c083e44561 | ||
|
|
841a2536ea | ||
|
|
b2de8b5d2e | ||
|
|
c405b56223 | ||
|
|
35d7e3cab8 | ||
|
|
574290fb23 | ||
|
|
aa5fb7be3d | ||
|
|
b868f7d4c3 | ||
|
|
5490c7b794 | ||
|
|
2b61c916d0 | ||
|
|
e1e67e912a | ||
|
|
a7cd746613 | ||
|
|
e91c646ee6 | ||
|
|
5bf4cf8860 | ||
|
|
e50d176722 | ||
|
|
ad3456dc4a | ||
|
|
20824bd707 | ||
|
|
de5f72dd88 | ||
|
|
e3a1f19057 | ||
|
|
f2927a1647 | ||
|
|
d473dc565f |
||
|
|
ab19b0382b | ||
|
|
86334d233d | ||
|
|
d4cb8b464b | ||
|
|
bb59c61c9f | ||
|
|
88878b4184 | ||
|
|
fa86ac7b11 |
||
|
|
1d4212122e | ||
|
|
993169ae49 | ||
|
|
8e01542fe9 | ||
|
|
3cc0fc07b4 | ||
|
|
8b89a3aca8 | ||
|
|
63995ce823 | ||
|
|
3ea7ed8606 | ||
|
|
a55ae2adfc | ||
|
|
6a1b237b39 | ||
|
|
435ff7fa88 | ||
|
|
4e9556cc24 |
||
|
|
26f3b20e58 | ||
|
|
648a34ce81 | ||
|
|
9adb541674 | ||
|
|
92848c06b8 | ||
|
|
154ea483ea | ||
|
|
d5141a5778 | ||
|
|
c31bb55011 | ||
|
|
1c514d69f6 | ||
|
|
9622662132 | ||
|
|
4edea3ad26 | ||
|
|
cfafa5b37f | ||
|
|
a1b6a6976d | ||
|
|
6babd23cec | ||
|
|
bbf0424c45 | ||
|
|
b635dc53f4 | ||
|
|
8f888515b6 | ||
|
|
ab62ec96d1 | ||
|
|
df8a61450b | ||
|
|
d825d8c4f3 | ||
|
|
f4b4506035 | ||
|
|
3c1012dcc2 |
||
|
|
d8605ef4c2 | ||
|
|
4d6daa175a | ||
|
|
0fa872d438 | ||
|
|
22f80b8196 | ||
|
|
79c8aff31d | ||
|
|
111eeaf370 | ||
|
|
17bbe6855c | ||
|
|
03f1ffdf8f | ||
|
|
6eaa9e3199 |
||
|
|
911954347e | ||
|
|
f9f3465582 | ||
|
|
b1f3e4368c |
||
|
|
1558ab7989 | ||
|
|
d657fb4315 | ||
|
|
81a4df8a3e | ||
|
|
c1476a48d8 | ||
|
|
72cf476d1d | ||
|
|
9e377e8b90 | ||
|
|
52729faa54 | ||
|
|
e5b6166fa3 | ||
|
|
6a998610ee |
||
|
|
74324cb849 | ||
|
|
b6e9b18990 | ||
|
|
dd73eb018d |
||
|
|
cd838daef4 | ||
|
|
0b7c26c6c8 | ||
|
|
17f45b884b | ||
|
|
cbe9594a3d | ||
|
|
b2b20b36c5 | ||
|
|
51dab16149 | ||
|
|
8bd7e165f4 | ||
|
|
2785fd29de | ||
|
|
aaf00e9ec2 | ||
|
|
bcd989f4f8 | ||
|
|
0c5053cdf6 | ||
|
|
11bddc8919 | ||
|
|
951f5a9f30 | ||
|
|
1932f8429f | ||
|
|
03b2c66f63 | ||
|
|
86d0e7335f | ||
|
|
737ff03efb | ||
|
|
9f66cf72c1 | ||
|
|
316daf9a43 |
||
|
|
b1289a73f9 | ||
|
|
2fde692a3e | ||
|
|
90e19a7d58 | ||
|
|
ec438496a9 | ||
|
|
56160e44fc | ||
|
|
2eb307b8c6 | ||
|
|
3c29b002ca | ||
|
|
cef52b1ffc | ||
|
|
7214cc3761 | ||
|
|
d6de8e7520 | ||
|
|
c3ceabae03 | ||
|
|
58e2e3a8ab | ||
|
|
1bcd99df90 | ||
|
|
e89ffa0e06 | ||
|
|
ca4907ab6b | ||
|
|
e2864fc895 | ||
|
|
cafb6b9dbd | ||
|
|
02a1468f1b | ||
|
|
56b14f37d5 | ||
|
|
2f73869c09 | ||
|
|
b1029acc68 | ||
|
|
4bf1c4b065 | ||
|
|
0cadd07187 | ||
|
|
24887004cf | ||
|
|
180a6a86fd | ||
|
|
ac6ee097dd | ||
|
|
024b911772 | ||
|
|
f7e9dbc27e | ||
|
|
5dca2dc9be | ||
|
|
6033bf8e40 | ||
|
|
eb7c25c57c | ||
|
|
70b23ff4a5 | ||
|
|
0fcaa5792b | ||
|
|
71c76e3853 | ||
|
|
866852eff6 | ||
|
|
1ac4265ffd | ||
|
|
1cd162e9da | ||
|
|
4a1a4d7807 | ||
|
|
f1dd86ebfb | ||
|
|
8ab8999ba9 | ||
|
|
4208ded249 | ||
|
|
1f7c26ffea | ||
|
|
de55a76d40 |
||
|
|
e1261442c0 | ||
|
|
abb17ba773 | ||
|
|
66bbbbc947 | ||
|
|
1bce34a338 | ||
|
|
2b5b4ed66b | ||
|
|
f0b59971f7 | ||
|
|
6eee6c81e8 | ||
|
|
310c36ae94 | ||
|
|
4a70b89355 | ||
|
|
539ff9ddfc | ||
|
|
2d07c3b99e | ||
|
|
e62a5ea337 | ||
|
|
e0d39ba69f | ||
|
|
b7505a9fc2 | ||
|
|
ef43c10388 | ||
|
|
2d77e3334b | ||
|
|
3b26b7aff0 | ||
|
|
040e42ddca | ||
|
|
4b5fac9cf7 | ||
|
|
221be2632e | ||
|
|
b4c63966f8 | ||
|
|
7e1abd0da4 | ||
|
|
daeeca6c6b | ||
|
|
a855a1acec | ||
|
|
6cafdcb6fa |
||
|
|
f403ea4e53 | ||
|
|
f3a34c5012 | ||
|
|
6aaed2d9b5 |
||
|
|
857af9293c | ||
|
|
312740b97c |
||
|
|
d11326b250 |
||
|
|
b013aa1f7f |
||
|
|
b9cb8e9a30 | ||
|
|
a75c9f2401 |
||
|
|
c1525cb467 | ||
|
|
c980234c41 | ||
|
|
cef6aa85b6 | ||
|
|
c73ed1ee4e | ||
|
|
21a79b34b8 | ||
|
|
0f7e4410c1 | ||
|
|
9c1f8621eb | ||
|
|
be1b5b2b5c | ||
|
|
1590444fa3 | ||
|
|
09ccab97cc |
||
|
|
865f35ff10 | ||
|
|
9e7419db8d | ||
|
|
f46c245563 | ||
|
|
fba67250d1 | ||
|
|
d44cfc3a55 | ||
|
|
0eb78dec9a | ||
|
|
8568320a23 | ||
|
|
6057c76165 | ||
|
|
9909bd654b | ||
|
|
1000d50220 | ||
|
|
fae847ac10 | ||
|
|
d3330695fa | ||
|
|
54b272baf6 | ||
|
|
bbbc1cd596 | ||
|
|
5d6de0154d | ||
|
|
7a914f8c65 | ||
|
|
40e78e0927 |
||
|
|
c1b3081863 | ||
|
|
10a1caac53 | ||
|
|
3110faa0ba |
||
|
|
46d90a0b88 | ||
|
|
036aa6aa2e | ||
|
|
db16003865 | ||
|
|
345f910043 |
186 changed files with 42001 additions and 3664 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
|
||||
35
.github/workflows/deploy-branch-preview.yml
vendored
35
.github/workflows/deploy-branch-preview.yml
vendored
|
|
@ -1,35 +0,0 @@
|
|||
name: Deploy a Datasette branch preview to Vercel
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to deploy"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy-branch-preview:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install datasette-publish-vercel
|
||||
- name: Deploy the preview
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }}
|
||||
run: |
|
||||
export BRANCH="${{ github.event.inputs.branch }}"
|
||||
wget https://latest.datasette.io/fixtures.db
|
||||
datasette publish vercel fixtures.db \
|
||||
--branch $BRANCH \
|
||||
--project "datasette-preview-$BRANCH" \
|
||||
--token $VERCEL_TOKEN \
|
||||
--scope datasette \
|
||||
--about "Preview of $BRANCH" \
|
||||
--about_url "https://github.com/simonw/datasette/tree/$BRANCH"
|
||||
39
.github/workflows/deploy-latest.yml
vendored
39
.github/workflows/deploy-latest.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
db.route = "alternative-route"
|
||||
' > plugins/alternative_route.py
|
||||
cp fixtures.db fixtures2.db
|
||||
- name: And the counters writable canned query demo
|
||||
- name: And the counters writable stored query demo
|
||||
run: |
|
||||
cat > plugins/counters.py <<EOF
|
||||
from datasette import hookimpl
|
||||
|
|
@ -69,23 +69,24 @@ jobs:
|
|||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
||||
return inner
|
||||
@hookimpl
|
||||
def canned_queries(database):
|
||||
if database == "counters":
|
||||
queries = {}
|
||||
for name in ("counter_a", "counter_b", "counter_c"):
|
||||
queries["increment_{}".format(name)] = {
|
||||
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
|
||||
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
"write": True,
|
||||
}
|
||||
queries["decrement_{}".format(name)] = {
|
||||
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
|
||||
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
"write": True,
|
||||
}
|
||||
return queries
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"increment_{}".format(name),
|
||||
"update counters set value = value + 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
is_trusted=True,
|
||||
)
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"decrement_{}".format(name),
|
||||
"update counters set value = value - 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
is_trusted=True,
|
||||
)
|
||||
return inner
|
||||
EOF
|
||||
# - name: Make some modifications to metadata.json
|
||||
# run: |
|
||||
|
|
@ -116,7 +117,7 @@ jobs:
|
|||
--plugins-dir=plugins \
|
||||
--branch=$GITHUB_SHA \
|
||||
--version-note=$GITHUB_SHA \
|
||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
|
||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||
--service "datasette-latest$SUFFIX" \
|
||||
--secret $LATEST_DATASETTE_SECRET
|
||||
|
|
|
|||
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_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
documentation-links:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: readthedocs/actions/preview@v1
|
||||
with:
|
||||
project-slug: "datasette"
|
||||
48
.github/workflows/playwright.yml
vendored
Normal file
48
.github/workflows/playwright.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: Playwright
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium, firefox, webkit]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python 3.14
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Cache uv
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py3.14-uv-
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-${{ matrix.browser }}-
|
||||
- name: Install uv
|
||||
run: python -m pip install uv
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev --group playwright
|
||||
- name: Install ${{ matrix.browser }}
|
||||
run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }}
|
||||
- name: Run Playwright tests
|
||||
run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }}
|
||||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
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@v4
|
||||
- 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@v4
|
||||
- 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@v4
|
||||
- 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@v4
|
||||
- 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@v2
|
||||
- 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@v4
|
||||
- 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@v5
|
||||
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@v4
|
||||
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@v4
|
||||
- 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@v4
|
||||
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@v4
|
||||
- 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@v4
|
||||
- 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@v2
|
||||
- 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@v2
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,8 +1,12 @@
|
|||
build-metadata.json
|
||||
datasets.json
|
||||
|
||||
.playwright-mcp
|
||||
|
||||
scratchpad
|
||||
|
||||
ignored/
|
||||
|
||||
.vscode
|
||||
|
||||
uv.lock
|
||||
|
|
@ -131,4 +135,4 @@ tests/*.dylib
|
|||
tests/*.so
|
||||
tests/*.dll
|
||||
|
||||
.idea
|
||||
.idea
|
||||
|
|
|
|||
19
Justfile
19
Justfile
|
|
@ -11,16 +11,33 @@ export DATASETTE_SECRET := "not_a_secret"
|
|||
@test *options: init
|
||||
uv run pytest -n auto {{options}}
|
||||
|
||||
# Install Playwright browser support, Chromium by default
|
||||
@playwright-install browser="chromium":
|
||||
uv run --group playwright playwright install {{browser}}
|
||||
|
||||
# Install all Playwright browsers used by the test suite
|
||||
@playwright-install-all:
|
||||
uv run --group playwright playwright install chromium firefox webkit
|
||||
|
||||
# Run Playwright tests, Chromium by default
|
||||
@playwright browser="chromium" *options:
|
||||
uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}}
|
||||
|
||||
# Run Playwright tests against all supported browsers
|
||||
@playwright-all *options:
|
||||
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}}
|
||||
|
||||
@codespell:
|
||||
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Run linters: black, ruff, 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
|
||||
|
|
|
|||
|
|
@ -19,23 +19,38 @@ import weakref
|
|||
|
||||
import pytest
|
||||
|
||||
from datasette.app import Datasette
|
||||
|
||||
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
|
||||
"datasette_active_instances", default=None
|
||||
)
|
||||
|
||||
_original_init = Datasette.__init__
|
||||
_original_init = None
|
||||
|
||||
|
||||
def _tracking_init(self, *args, **kwargs):
|
||||
_original_init(self, *args, **kwargs)
|
||||
instances = _active_instances.get()
|
||||
if instances is not None:
|
||||
instances.append(weakref.ref(self))
|
||||
def _install_tracking():
|
||||
# datasette.app is imported lazily here rather than at module level:
|
||||
# as a pytest11 entry point this module is imported during pytest
|
||||
# startup, before pytest-cov starts measuring, so a module-level
|
||||
# import would drag in all of datasette and make every import-time
|
||||
# line in the package invisible to coverage
|
||||
global _original_init
|
||||
if _original_init is not None:
|
||||
return
|
||||
from datasette.app import Datasette
|
||||
|
||||
_original_init = Datasette.__init__
|
||||
|
||||
def _tracking_init(self, *args, **kwargs):
|
||||
_original_init(self, *args, **kwargs)
|
||||
instances = _active_instances.get()
|
||||
if instances is not None:
|
||||
instances.append(weakref.ref(self))
|
||||
|
||||
Datasette.__init__ = _tracking_init
|
||||
|
||||
|
||||
Datasette.__init__ = _tracking_init
|
||||
def pytest_configure(config):
|
||||
if _enabled(config):
|
||||
_install_tracking()
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
|
|
|
|||
731
datasette/app.py
731
datasette/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,7 @@ from .app import (
|
|||
SQLITE_LIMIT_ATTACHED,
|
||||
pm,
|
||||
)
|
||||
from .inspect import inspect_tables
|
||||
from .utils import (
|
||||
LoadExtension,
|
||||
StartupError,
|
||||
|
|
@ -154,14 +155,14 @@ async def inspect_(files, sqlite_extensions):
|
|||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
data = {}
|
||||
for name, database in app.databases.items():
|
||||
counts = await database.table_counts(limit=3600 * 1000)
|
||||
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
|
||||
data[name] = {
|
||||
"hash": database.hash,
|
||||
"size": database.size,
|
||||
"file": database.path,
|
||||
"tables": {
|
||||
table_name: {"count": table_count}
|
||||
for table_name, table_count in counts.items()
|
||||
table_name: {"count": table["count"]}
|
||||
for table_name, table in tables.items()
|
||||
},
|
||||
}
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -6,19 +6,17 @@ class SQLiteType(Enum):
|
|||
INTEGER = "INTEGER"
|
||||
REAL = "REAL"
|
||||
BLOB = "BLOB"
|
||||
NULL = "NULL"
|
||||
NUMERIC = "NUMERIC"
|
||||
|
||||
@classmethod
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType":
|
||||
if declared_type is None:
|
||||
return cls.NULL
|
||||
return cls.BLOB
|
||||
|
||||
normalized = declared_type.strip().upper()
|
||||
if not normalized:
|
||||
return cls.NULL
|
||||
return cls.BLOB
|
||||
|
||||
if normalized == cls.NULL.value:
|
||||
return cls.NULL
|
||||
if "INT" in normalized:
|
||||
return cls.INTEGER
|
||||
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
||||
|
|
@ -31,7 +29,7 @@ class SQLiteType(Enum):
|
|||
):
|
||||
return cls.REAL
|
||||
|
||||
return None
|
||||
return cls.NUMERIC
|
||||
|
||||
|
||||
class ColumnType:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from collections import namedtuple
|
|||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import janus
|
||||
import queue
|
||||
import sqlite_utils
|
||||
import sys
|
||||
|
|
@ -26,11 +25,14 @@ from .utils import (
|
|||
table_columns,
|
||||
table_column_details,
|
||||
)
|
||||
from .utils.sqlite import sqlite_version
|
||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||
from .utils.sqlite import sqlite_hidden_table_names
|
||||
from .inspect import inspect_hash
|
||||
|
||||
connections = threading.local()
|
||||
|
||||
EXECUTE_WRITE_RETURNING_LIMIT = 10
|
||||
|
||||
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
|
||||
|
||||
|
||||
|
|
@ -84,6 +86,8 @@ class Database:
|
|||
self._write_thread = None
|
||||
self._write_queue = None
|
||||
self._closed = False
|
||||
self._pending_execute_futures = set()
|
||||
self._pending_execute_futures_lock = threading.Lock()
|
||||
# These are used when in non-threaded mode:
|
||||
self._read_connection = None
|
||||
self._write_connection = None
|
||||
|
|
@ -98,6 +102,10 @@ class Database:
|
|||
"Database {!r} has been closed".format(self.name)
|
||||
)
|
||||
|
||||
def _remove_pending_execute_future(self, future):
|
||||
with self._pending_execute_futures_lock:
|
||||
self._pending_execute_futures.discard(future)
|
||||
|
||||
@property
|
||||
def cached_table_counts(self):
|
||||
if self._cached_table_counts is not None:
|
||||
|
|
@ -170,7 +178,11 @@ class Database:
|
|||
"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
with self._pending_execute_futures_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
pending_execute_futures = tuple(self._pending_execute_futures)
|
||||
# Shut down the write thread, if any, via a sentinel. The thread
|
||||
# drains any writes already queued before the sentinel and then
|
||||
# closes its own write connection and returns.
|
||||
|
|
@ -185,6 +197,11 @@ class Database:
|
|||
)
|
||||
)
|
||||
sys.stderr.flush()
|
||||
for future in pending_execute_futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
pass
|
||||
# Close anything still tracked in _all_file_connections
|
||||
for connection in self._all_file_connections:
|
||||
try:
|
||||
|
|
@ -221,11 +238,24 @@ class Database:
|
|||
except OSError:
|
||||
pass
|
||||
|
||||
async def execute_write(self, sql, params=None, block=True, request=None):
|
||||
async def execute_write(
|
||||
self,
|
||||
sql,
|
||||
params=None,
|
||||
block=True,
|
||||
request=None,
|
||||
return_all=False,
|
||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||
):
|
||||
self._check_not_closed()
|
||||
if returning_limit < 0:
|
||||
raise ValueError("returning_limit must be >= 0")
|
||||
|
||||
def _inner(conn):
|
||||
return conn.execute(sql, params or [])
|
||||
cursor = conn.execute(sql, params or [])
|
||||
return ExecuteWriteResult.from_cursor(
|
||||
cursor, return_all=return_all, returning_limit=returning_limit
|
||||
)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||
|
|
@ -268,13 +298,14 @@ class Database:
|
|||
|
||||
async def execute_isolated_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
# Open a new connection just for the duration of this function
|
||||
# Open a new connection just for the duration of this function,
|
||||
# blocking the write queue to avoid any writes occurring during it
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
isolated_connection = self.connect(write=True)
|
||||
write = self.is_mutable
|
||||
|
||||
def _run():
|
||||
isolated_connection = self.connect(write=write)
|
||||
try:
|
||||
result = fn(isolated_connection)
|
||||
return fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
|
|
@ -282,10 +313,25 @@ class Database:
|
|||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
return result
|
||||
else:
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
return _run()
|
||||
if not write:
|
||||
# Immutable database - no writes can ever occur, so there is no
|
||||
# write queue to block; run against a fresh read-only connection
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
self.ds.executor, _run
|
||||
)
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
|
||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
||||
self._check_not_closed()
|
||||
|
||||
return await self.execute_isolated_fn(
|
||||
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
||||
)
|
||||
|
||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
|
@ -315,13 +361,16 @@ class Database:
|
|||
else:
|
||||
# For non-blocking writes, spawn a background task to
|
||||
# dispatch events after the write thread completes
|
||||
task_id, reply_queue = result
|
||||
task_id, reply_future = result
|
||||
|
||||
async def _dispatch_events_after_write():
|
||||
write_result = await reply_queue.async_q.get()
|
||||
if not isinstance(write_result, Exception):
|
||||
for event in pending_events:
|
||||
await self.ds.track_event(event)
|
||||
try:
|
||||
await reply_future
|
||||
except Exception:
|
||||
# if the write failed, don't emit success events
|
||||
return
|
||||
for event in pending_events:
|
||||
await self.ds.track_event(event)
|
||||
|
||||
asyncio.ensure_future(_dispatch_events_after_write())
|
||||
result = task_id
|
||||
|
|
@ -375,18 +424,15 @@ class Database:
|
|||
)
|
||||
self._write_thread.start()
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
reply_queue = janus.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
reply_future = loop.create_future()
|
||||
self._write_queue.put(
|
||||
WriteTask(fn, task_id, reply_queue, isolated_connection, transaction)
|
||||
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
|
||||
)
|
||||
if block:
|
||||
result = await reply_queue.async_q.get()
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
else:
|
||||
return result
|
||||
return await reply_future
|
||||
else:
|
||||
return task_id, reply_queue
|
||||
return task_id, reply_future
|
||||
|
||||
def _execute_writes(self):
|
||||
# Infinite looping thread that protects the single write connection
|
||||
|
|
@ -407,17 +453,15 @@ class Database:
|
|||
except Exception:
|
||||
pass
|
||||
return
|
||||
exception = None
|
||||
result = None
|
||||
if conn_exception is not None:
|
||||
result = conn_exception
|
||||
else:
|
||||
if task.isolated_connection:
|
||||
exception = conn_exception
|
||||
elif task.isolated_connection:
|
||||
try:
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = task.fn(isolated_connection)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
result = e
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
|
|
@ -425,18 +469,22 @@ class Database:
|
|||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
result = task.fn(conn)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
result = e
|
||||
task.reply_queue.sync_q.put(result)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
_deliver_write_result(task, result, exception)
|
||||
|
||||
async def execute_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
|
|
@ -456,9 +504,12 @@ class Database:
|
|||
setattr(connections, self._thread_local_id, conn)
|
||||
return fn(conn)
|
||||
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
self.ds.executor, in_thread
|
||||
)
|
||||
with self._pending_execute_futures_lock:
|
||||
self._check_not_closed()
|
||||
future = self.ds.executor.submit(in_thread)
|
||||
self._pending_execute_futures.add(future)
|
||||
future.add_done_callback(self._remove_pending_execute_future)
|
||||
return await asyncio.wrap_future(future)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
|
|
@ -676,83 +727,7 @@ class Database:
|
|||
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
||||
]
|
||||
|
||||
if sqlite_version()[1] >= 37:
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
with shadow_tables as (
|
||||
select name
|
||||
from pragma_table_list
|
||||
where [type] = 'shadow'
|
||||
order by name
|
||||
),
|
||||
core_tables as (
|
||||
select name
|
||||
from sqlite_master
|
||||
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
combined as (
|
||||
select name from shadow_tables
|
||||
union all
|
||||
select name from core_tables
|
||||
)
|
||||
select name from combined order by 1
|
||||
""")]
|
||||
else:
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
WITH base AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
fts_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
|
||||
),
|
||||
fts5_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
||||
),
|
||||
fts5_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
|
||||
FROM fts5_names
|
||||
JOIN fts_suffixes
|
||||
),
|
||||
fts3_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
|
||||
),
|
||||
fts3_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
|
||||
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
|
||||
),
|
||||
fts3_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
|
||||
FROM fts3_names
|
||||
JOIN fts3_suffixes
|
||||
),
|
||||
final AS (
|
||||
SELECT name FROM base
|
||||
UNION ALL
|
||||
SELECT name FROM fts5_shadow_tables
|
||||
UNION ALL
|
||||
SELECT name FROM fts3_shadow_tables
|
||||
)
|
||||
SELECT name FROM final ORDER BY 1
|
||||
""")]
|
||||
# Also hide any FTS tables that have a content= argument
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%'
|
||||
AND sql LIKE '%USING FTS%'
|
||||
AND sql LIKE '%content=%'
|
||||
""")]
|
||||
hidden_tables += await self.execute_fn(sqlite_hidden_table_names)
|
||||
|
||||
has_spatialite = await self.execute_fn(detect_spatialite)
|
||||
if has_spatialite:
|
||||
|
|
@ -854,10 +829,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
|||
# Execute the actual write
|
||||
try:
|
||||
result = fn(conn)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# Throw exception into generator so it can handle it
|
||||
try:
|
||||
gen.throw(*sys.exc_info())
|
||||
gen.throw(e)
|
||||
except StopIteration:
|
||||
pass
|
||||
# Re-raise the original exception
|
||||
|
|
@ -874,16 +849,45 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
|||
|
||||
|
||||
class WriteTask:
|
||||
__slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction")
|
||||
__slots__ = (
|
||||
"fn",
|
||||
"task_id",
|
||||
"loop",
|
||||
"reply_future",
|
||||
"isolated_connection",
|
||||
"transaction",
|
||||
)
|
||||
|
||||
def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):
|
||||
def __init__(
|
||||
self, fn, task_id, loop, reply_future, isolated_connection, transaction
|
||||
):
|
||||
self.fn = fn
|
||||
self.task_id = task_id
|
||||
self.reply_queue = reply_queue
|
||||
self.loop = loop
|
||||
self.reply_future = reply_future
|
||||
self.isolated_connection = isolated_connection
|
||||
self.transaction = transaction
|
||||
|
||||
|
||||
def _deliver_write_result(task, result, exception):
|
||||
# Called from the write thread. Delivers the result back to the
|
||||
# awaiting coroutine on its event loop via call_soon_threadsafe.
|
||||
def _set():
|
||||
if task.reply_future.done():
|
||||
# Awaiter was cancelled; nothing to do.
|
||||
return
|
||||
if exception is not None:
|
||||
task.reply_future.set_exception(exception)
|
||||
else:
|
||||
task.reply_future.set_result(result)
|
||||
|
||||
try:
|
||||
task.loop.call_soon_threadsafe(_set)
|
||||
except RuntimeError:
|
||||
# Event loop has been closed; the awaiter is gone.
|
||||
pass
|
||||
|
||||
|
||||
class QueryInterrupted(Exception):
|
||||
def __init__(self, e, sql, params):
|
||||
self.e = e
|
||||
|
|
@ -898,6 +902,44 @@ class MultipleValues(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class ExecuteWriteResult:
|
||||
def __init__(self, rowcount, lastrowid, description, rows, truncated):
|
||||
self.rowcount = rowcount
|
||||
self.lastrowid = lastrowid
|
||||
self.description = description
|
||||
self.truncated = truncated
|
||||
self._rows = rows
|
||||
|
||||
@classmethod
|
||||
def from_cursor(
|
||||
cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT
|
||||
):
|
||||
rows = []
|
||||
truncated = False
|
||||
description = cursor.description
|
||||
lastrowid = cursor.lastrowid
|
||||
try:
|
||||
if description is not None:
|
||||
if return_all:
|
||||
rows = cursor.fetchall()
|
||||
else:
|
||||
rows = cursor.fetchmany(returning_limit + 1)
|
||||
if len(rows) > returning_limit:
|
||||
rows = rows[:returning_limit]
|
||||
truncated = True
|
||||
rowcount = cursor.rowcount
|
||||
finally:
|
||||
cursor.close()
|
||||
if description is not None and not return_all and truncated:
|
||||
rowcount = -1
|
||||
return cls(rowcount, lastrowid, description, rows, truncated)
|
||||
|
||||
def fetchall(self):
|
||||
rows = self._rows
|
||||
self._rows = []
|
||||
return rows
|
||||
|
||||
|
||||
class Results:
|
||||
def __init__(self, rows, truncated, description):
|
||||
self.rows = rows
|
||||
|
|
|
|||
|
|
@ -48,12 +48,32 @@ def register_actions():
|
|||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="execute-write-sql",
|
||||
abbr="ews",
|
||||
description="Execute writable SQL queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="create-table",
|
||||
abbr="ct",
|
||||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="create-view",
|
||||
abbr="cv",
|
||||
description="Create views",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="store-query",
|
||||
abbr="sq",
|
||||
description="Create stored queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="execute-sql",
|
||||
),
|
||||
# Table-level actions (child-level)
|
||||
Action(
|
||||
name="view-table",
|
||||
|
|
@ -97,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",
|
||||
|
|
@ -104,4 +130,16 @@ def register_actions():
|
|||
description="View named query results",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
Action(
|
||||
name="update-query",
|
||||
abbr="uq",
|
||||
description="Update stored queries",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
Action(
|
||||
name="delete-query",
|
||||
abbr="dq",
|
||||
description="Delete stored queries",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ class JsonColumnType(ColumnType):
|
|||
return None
|
||||
|
||||
|
||||
class TextareaColumnType(ColumnType):
|
||||
name = "textarea"
|
||||
description = "Multiline text"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_column_types(datasette):
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType]
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType]
|
||||
|
|
|
|||
24
datasette/default_database_actions.py
Normal file
24
datasette/default_database_actions.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
|
||||
|
||||
@hookimpl
|
||||
def database_actions(datasette, actor, database, request):
|
||||
async def inner():
|
||||
if not datasette.get_database(database).is_mutable:
|
||||
return []
|
||||
if not await datasette.allowed(
|
||||
action="execute-write-sql",
|
||||
resource=DatabaseResource(database),
|
||||
actor=actor,
|
||||
):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"href": datasette.urls.database(database) + "/-/execute-write",
|
||||
"label": "Execute write SQL",
|
||||
"description": "Run writable SQL with table permission checks.",
|
||||
}
|
||||
]
|
||||
|
||||
return inner
|
||||
80
datasette/default_debug_menu.py
Normal file
80
datasette/default_debug_menu.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
DEBUG_MENU_ITEMS = (
|
||||
(
|
||||
"/-/databases",
|
||||
"Databases",
|
||||
"List of databases known to this Datasette instance.",
|
||||
),
|
||||
(
|
||||
"/-/plugins",
|
||||
"Installed plugins",
|
||||
"Review loaded plugins, their versions and their registered hooks.",
|
||||
),
|
||||
(
|
||||
"/-/versions",
|
||||
"Version info",
|
||||
"Check the Python, SQLite and dependency versions used by this server.",
|
||||
),
|
||||
(
|
||||
"/-/settings",
|
||||
"Settings",
|
||||
"Inspect the active Datasette settings and configuration values.",
|
||||
),
|
||||
(
|
||||
"/-/permissions",
|
||||
"Debug permissions",
|
||||
"Test permission checks for actors, actions and resources.",
|
||||
),
|
||||
(
|
||||
"/-/messages",
|
||||
"Debug messages",
|
||||
"Try out temporary flash messages shown to users.",
|
||||
),
|
||||
(
|
||||
"/-/allow-debug",
|
||||
"Debug allow rules",
|
||||
"Explore how allow blocks match actors against permission rules.",
|
||||
),
|
||||
(
|
||||
"/-/debug/autocomplete",
|
||||
"Debug autocomplete",
|
||||
"Try out table autocomplete against a detected label column.",
|
||||
),
|
||||
(
|
||||
"/-/threads",
|
||||
"Debug threads",
|
||||
"Inspect worker threads and database tasks.",
|
||||
),
|
||||
(
|
||||
"/-/actor",
|
||||
"Debug actor",
|
||||
"View the actor object for the current signed-in user.",
|
||||
),
|
||||
(
|
||||
"/-/patterns",
|
||||
"Pattern portfolio",
|
||||
"Browse Datasette UI patterns.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
if not await datasette.allowed(action="debug-menu", actor=actor):
|
||||
return []
|
||||
|
||||
return [
|
||||
JumpSQL.menu_item(
|
||||
label=label,
|
||||
url=datasette.urls.path(path),
|
||||
description=description,
|
||||
search_text=f"debug {label} {description}",
|
||||
item_type="debug",
|
||||
)
|
||||
for path, label, description in DEBUG_MENU_ITEMS
|
||||
]
|
||||
|
||||
return inner
|
||||
82
datasette/default_jump_items.py
Normal file
82
datasette/default_jump_items.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
database_sql, database_params = await datasette.allowed_resources_sql(
|
||||
action="view-database", actor=actor
|
||||
)
|
||||
table_sql, table_params = await datasette.allowed_resources_sql(
|
||||
action="view-table", actor=actor
|
||||
)
|
||||
query_sql, query_params = await datasette.allowed_resources_sql(
|
||||
action="view-query", actor=actor
|
||||
)
|
||||
return [
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_databases AS (
|
||||
{database_sql}
|
||||
)
|
||||
SELECT
|
||||
'database' AS type,
|
||||
parent AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'database',
|
||||
'database', parent
|
||||
) AS url,
|
||||
parent AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_databases
|
||||
""",
|
||||
params=database_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_tables AS (
|
||||
{table_sql}
|
||||
)
|
||||
SELECT
|
||||
CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type,
|
||||
allowed_tables.parent || ': ' || allowed_tables.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'table',
|
||||
'database', allowed_tables.parent,
|
||||
'table', allowed_tables.child
|
||||
) AS url,
|
||||
allowed_tables.parent || ' ' || allowed_tables.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_tables
|
||||
LEFT JOIN catalog_views
|
||||
ON catalog_views.database_name = allowed_tables.parent
|
||||
AND catalog_views.view_name = allowed_tables.child
|
||||
""",
|
||||
params=table_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_queries AS (
|
||||
{query_sql}
|
||||
)
|
||||
SELECT
|
||||
'query' AS type,
|
||||
allowed_queries.parent || ': ' || allowed_queries.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'query',
|
||||
'database', allowed_queries.parent,
|
||||
'query', allowed_queries.child
|
||||
) AS url,
|
||||
allowed_queries.parent || ' ' || allowed_queries.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_queries
|
||||
""",
|
||||
params=query_params,
|
||||
),
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
|
||||
|
||||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
async def inner():
|
||||
if not await datasette.allowed(action="debug-menu", actor=actor):
|
||||
return []
|
||||
|
||||
return [
|
||||
{"href": datasette.urls.path("/-/databases"), "label": "Databases"},
|
||||
{
|
||||
"href": datasette.urls.path("/-/plugins"),
|
||||
"label": "Installed plugins",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/versions"),
|
||||
"label": "Version info",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/settings"),
|
||||
"label": "Settings",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/permissions"),
|
||||
"label": "Debug permissions",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/messages"),
|
||||
"label": "Debug messages",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/allow-debug"),
|
||||
"label": "Debug allow rules",
|
||||
},
|
||||
{"href": datasette.urls.path("/-/threads"), "label": "Debug threads"},
|
||||
{"href": datasette.urls.path("/-/actor"), "label": "Debug actor"},
|
||||
{"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"},
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -17,13 +17,6 @@ UNION/INTERSECT operations. The order of evaluation is:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
# Re-export all hooks and public utilities
|
||||
from .restrictions import (
|
||||
actor_restrictions_sql as actor_restrictions_sql,
|
||||
|
|
@ -33,16 +26,9 @@ from .restrictions import (
|
|||
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||
from .config import config_permissions_sql as config_permissions_sql
|
||||
from .defaults import (
|
||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
||||
default_allow_sql_check as default_allow_sql_check,
|
||||
default_action_permissions_sql as default_action_permissions_sql,
|
||||
default_query_permissions_sql as default_query_permissions_sql,
|
||||
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
|
||||
"""Return canned queries defined in datasette.yaml configuration."""
|
||||
queries = (
|
||||
((datasette.config or {}).get("databases") or {}).get(database) or {}
|
||||
).get("queries") or {}
|
||||
return queries
|
||||
|
|
|
|||
|
|
@ -67,3 +67,48 @@ async def default_action_permissions_sql(
|
|||
return PermissionSQL.allow(reason=reason)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_query_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
if action not in {"view-query", "update-query", "delete-query"}:
|
||||
return None
|
||||
|
||||
params = {"query_owner_id": actor_id}
|
||||
rule_sqls = []
|
||||
if actor_id is not None:
|
||||
if action in {"update-query", "delete-query"}:
|
||||
# Query owner can update/delete query
|
||||
rule_sqls.append("""
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'query owner' AS reason
|
||||
FROM queries
|
||||
WHERE source = 'user'
|
||||
AND owner_id = :query_owner_id
|
||||
""")
|
||||
else:
|
||||
# Query owner can view-query
|
||||
rule_sqls.append("""
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'query owner' AS reason
|
||||
FROM queries
|
||||
WHERE owner_id = :query_owner_id
|
||||
""")
|
||||
|
||||
# restriction_sql enforces private queries ONLY visible/mutable by owner
|
||||
return PermissionSQL(
|
||||
sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None,
|
||||
restriction_sql="""
|
||||
SELECT database_name AS parent, name AS child
|
||||
FROM queries
|
||||
WHERE is_private = 0
|
||||
OR owner_id = :query_owner_id
|
||||
""",
|
||||
params=params,
|
||||
)
|
||||
|
|
|
|||
48
datasette/default_query_actions.py
Normal file
48
datasette/default_query_actions.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import QueryResource
|
||||
|
||||
|
||||
@hookimpl
|
||||
def query_actions(datasette, actor, database, query_name, request):
|
||||
# Only stored queries (with a name) can be edited or deleted
|
||||
if not query_name:
|
||||
return None
|
||||
|
||||
async def inner():
|
||||
query = await datasette.get_query(database, query_name)
|
||||
if query is None:
|
||||
return []
|
||||
# Config-defined and trusted queries are managed outside the UI
|
||||
if query.source == "config" or query.is_trusted:
|
||||
return []
|
||||
|
||||
links = []
|
||||
if await datasette.allowed(
|
||||
action="update-query",
|
||||
resource=QueryResource(database, query_name),
|
||||
actor=actor,
|
||||
):
|
||||
links.append(
|
||||
{
|
||||
"href": datasette.urls.table(database, query_name) + "/-/edit",
|
||||
"label": "Edit this query",
|
||||
"description": (
|
||||
"Change the title, description, SQL or visibility."
|
||||
),
|
||||
}
|
||||
)
|
||||
if await datasette.allowed(
|
||||
action="delete-query",
|
||||
resource=QueryResource(database, query_name),
|
||||
actor=actor,
|
||||
):
|
||||
links.append(
|
||||
{
|
||||
"href": datasette.urls.table(database, query_name) + "/-/delete",
|
||||
"label": "Delete this query",
|
||||
"description": "Permanently remove this saved query.",
|
||||
}
|
||||
)
|
||||
return links
|
||||
|
||||
return inner
|
||||
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
|
||||
141
datasette/extras.py
Normal file
141
datasette/extras.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import ClassVar
|
||||
|
||||
from asyncinject import Registry
|
||||
|
||||
from datasette.utils.asgi import BadRequest
|
||||
|
||||
|
||||
def extra_names_from_request(request):
|
||||
extra_bits = request.args.getlist("_extra")
|
||||
extras = set()
|
||||
for bit in extra_bits:
|
||||
extras.update(part for part in bit.split(",") if part)
|
||||
return extras
|
||||
|
||||
|
||||
class ExtraScope(Enum):
|
||||
TABLE = "table"
|
||||
ROW = "row"
|
||||
QUERY = "query"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtraExample:
|
||||
path: str | None = None
|
||||
key: str | None = None
|
||||
value: object | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class Provider:
|
||||
name: ClassVar[str | None] = None
|
||||
scopes: ClassVar[set[ExtraScope]] = set()
|
||||
public: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def key(cls):
|
||||
return cls.name or _camel_to_snake(cls.__name__)
|
||||
|
||||
@classmethod
|
||||
def available_for(cls, scope):
|
||||
return scope in cls.scopes
|
||||
|
||||
async def resolve(self, context):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Extra(Provider):
|
||||
description: ClassVar[str | None] = None
|
||||
example: ClassVar[ExtraExample | None] = None
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {}
|
||||
public: ClassVar[bool] = True
|
||||
expensive: ClassVar[bool] = False
|
||||
docs_note: ClassVar[str | None] = None
|
||||
|
||||
@classmethod
|
||||
def example_for_scope(cls, scope):
|
||||
return cls.examples.get(scope, cls.example)
|
||||
|
||||
|
||||
class ExtraRegistry:
|
||||
def __init__(self, classes):
|
||||
self.classes = list(classes)
|
||||
self.classes_by_name = {cls.key(): cls for cls in self.classes}
|
||||
# Lazily-built shared state, keyed by scope. Safe to share across
|
||||
# requests because Extra instances are stateless and asyncinject's
|
||||
# Registry keeps per-call state local to each resolve_multi() call.
|
||||
# If extras classes ever become registerable at runtime (e.g. via a
|
||||
# plugin hook) these caches will need invalidating.
|
||||
self._scope_registries = {}
|
||||
self._allowed_names = {}
|
||||
|
||||
def classes_for_scope(self, scope, include_internal=True):
|
||||
classes = [
|
||||
cls
|
||||
for cls in self.classes
|
||||
if cls.available_for(scope) and (include_internal or cls.public)
|
||||
]
|
||||
return classes
|
||||
|
||||
def public_classes_for_scope(self, scope):
|
||||
return self.classes_for_scope(scope, include_internal=False)
|
||||
|
||||
def internal_classes_for_scope(self, scope):
|
||||
# Extras that are available to HTML templates but excluded from
|
||||
# JSON responses - plain Providers are dependency plumbing and
|
||||
# never surface as keys, so they are not included
|
||||
return [
|
||||
cls
|
||||
for cls in self.classes_for_scope(scope)
|
||||
if issubclass(cls, Extra) and not cls.public
|
||||
]
|
||||
|
||||
def _registry_for_scope(self, scope):
|
||||
registry = self._scope_registries.get(scope)
|
||||
if registry is None:
|
||||
registry = Registry()
|
||||
for cls in self.classes_for_scope(scope):
|
||||
registry.register(cls().resolve, name=cls.key())
|
||||
self._scope_registries[scope] = registry
|
||||
return registry
|
||||
|
||||
def _allowed_names_for_scope(self, scope, include_internal):
|
||||
key = (scope, include_internal)
|
||||
names = self._allowed_names.get(key)
|
||||
if names is None:
|
||||
names = {
|
||||
cls.key()
|
||||
for cls in self.classes_for_scope(
|
||||
scope, include_internal=include_internal
|
||||
)
|
||||
}
|
||||
self._allowed_names[key] = names
|
||||
return names
|
||||
|
||||
def validate_requested(self, requested, scope):
|
||||
"""
|
||||
Raise BadRequest if any requested extra name is not a public extra
|
||||
for this scope. Used by data formats such as .json - HTML pages
|
||||
silently ignore unknown names instead.
|
||||
"""
|
||||
allowed = self._allowed_names_for_scope(scope, include_internal=False)
|
||||
unknown = sorted(name for name in requested if name not in allowed)
|
||||
if unknown:
|
||||
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
|
||||
|
||||
async def resolve(self, requested, context, scope, include_internal=False):
|
||||
allowed_names = self._allowed_names_for_scope(scope, include_internal)
|
||||
requested_names = [name for name in requested if name in allowed_names]
|
||||
resolved = await self._registry_for_scope(scope).resolve_multi(
|
||||
requested_names, results={"context": context}
|
||||
)
|
||||
return {name: resolved[name] for name in requested_names}
|
||||
|
||||
|
||||
def _camel_to_snake(name):
|
||||
name = re.sub(r"(Extra|Provider)$", "", name)
|
||||
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
|
||||
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
|
||||
|
|
@ -83,7 +83,7 @@ class Facet:
|
|||
self.ds = ds
|
||||
self.request = request
|
||||
self.database = database
|
||||
# For foreign key expansion. Can be None for e.g. canned SQL queries:
|
||||
# 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.params = params or []
|
||||
|
|
|
|||
415
datasette/fixtures.py
Normal file
415
datasette/fixtures.py
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils import documented
|
||||
import itertools
|
||||
import random
|
||||
import string
|
||||
|
||||
__all__ = [
|
||||
"EXTRA_DATABASE_SQL",
|
||||
"TABLES",
|
||||
"TABLE_PARAMETERIZED_SQL",
|
||||
"generate_compound_rows",
|
||||
"generate_sortable_rows",
|
||||
"populate_extra_database",
|
||||
"populate_fixture_database",
|
||||
"write_extra_database",
|
||||
"write_fixture_database",
|
||||
]
|
||||
|
||||
|
||||
def generate_compound_rows(num):
|
||||
"""Generate rows for the compound_three_primary_keys fixture table."""
|
||||
for a, b, c in itertools.islice(
|
||||
itertools.product(string.ascii_lowercase, repeat=3), num
|
||||
):
|
||||
yield a, b, c, f"{a}-{b}-{c}"
|
||||
|
||||
|
||||
def generate_sortable_rows(num):
|
||||
"""Generate rows for the sortable fixture table."""
|
||||
rand = random.Random(42)
|
||||
for a, b in itertools.islice(
|
||||
itertools.product(string.ascii_lowercase, repeat=2), num
|
||||
):
|
||||
yield {
|
||||
"pk1": a,
|
||||
"pk2": b,
|
||||
"content": f"{a}-{b}",
|
||||
"sortable": rand.randint(-100, 100),
|
||||
"sortable_with_nulls": rand.choice([None, rand.random(), rand.random()]),
|
||||
"sortable_with_nulls_2": rand.choice([None, rand.random(), rand.random()]),
|
||||
"text": rand.choice(["$null", "$blah"]),
|
||||
}
|
||||
|
||||
|
||||
TABLES = (
|
||||
"""
|
||||
CREATE TABLE simple_primary_key (
|
||||
id integer primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE primary_key_multiple_columns (
|
||||
id varchar(30) primary key,
|
||||
content text,
|
||||
content2 text
|
||||
);
|
||||
|
||||
CREATE TABLE primary_key_multiple_columns_explicit_label (
|
||||
id varchar(30) primary key,
|
||||
content text,
|
||||
content2 text
|
||||
);
|
||||
|
||||
CREATE TABLE compound_primary_key (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
content text,
|
||||
PRIMARY KEY (pk1, pk2)
|
||||
);
|
||||
|
||||
INSERT INTO compound_primary_key VALUES ('a', 'b', 'c');
|
||||
INSERT INTO compound_primary_key VALUES ('a/b', '.c-d', 'c');
|
||||
INSERT INTO compound_primary_key VALUES ('d', 'e', 'RENDER_CELL_DEMO');
|
||||
|
||||
CREATE TABLE compound_three_primary_keys (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
pk3 varchar(30),
|
||||
content text,
|
||||
PRIMARY KEY (pk1, pk2, pk3)
|
||||
);
|
||||
CREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content);
|
||||
|
||||
CREATE TABLE foreign_key_references (
|
||||
pk varchar(30) primary key,
|
||||
foreign_key_with_label integer,
|
||||
foreign_key_with_blank_label integer,
|
||||
foreign_key_with_no_label varchar(30),
|
||||
foreign_key_compound_pk1 varchar(30),
|
||||
foreign_key_compound_pk2 varchar(30),
|
||||
FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id),
|
||||
FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id),
|
||||
FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id)
|
||||
FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2)
|
||||
);
|
||||
|
||||
CREATE TABLE sortable (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
content text,
|
||||
sortable integer,
|
||||
sortable_with_nulls real,
|
||||
sortable_with_nulls_2 real,
|
||||
text text,
|
||||
PRIMARY KEY (pk1, pk2)
|
||||
);
|
||||
|
||||
CREATE TABLE no_primary_key (
|
||||
content text,
|
||||
a text,
|
||||
b text,
|
||||
c text
|
||||
);
|
||||
|
||||
CREATE TABLE [123_starts_with_digits] (
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE VIEW paginated_view AS
|
||||
SELECT
|
||||
content,
|
||||
'- ' || content || ' -' AS content_extra
|
||||
FROM no_primary_key;
|
||||
|
||||
CREATE TABLE "Table With Space In Name" (
|
||||
pk varchar(30) primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE "table/with/slashes.csv" (
|
||||
pk varchar(30) primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE "complex_foreign_keys" (
|
||||
pk varchar(30) primary key,
|
||||
f1 integer,
|
||||
f2 integer,
|
||||
f3 integer,
|
||||
FOREIGN KEY ("f1") REFERENCES [simple_primary_key](id),
|
||||
FOREIGN KEY ("f2") REFERENCES [simple_primary_key](id),
|
||||
FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id)
|
||||
);
|
||||
|
||||
CREATE TABLE "custom_foreign_key_label" (
|
||||
pk varchar(30) primary key,
|
||||
foreign_key_with_custom_label text,
|
||||
FOREIGN KEY ("foreign_key_with_custom_label") REFERENCES [primary_key_multiple_columns_explicit_label](id)
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
tag TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE searchable (
|
||||
pk integer primary key,
|
||||
text1 text,
|
||||
text2 text,
|
||||
[name with . and spaces] text
|
||||
);
|
||||
|
||||
CREATE TABLE searchable_tags (
|
||||
searchable_id integer,
|
||||
tag text,
|
||||
PRIMARY KEY (searchable_id, tag),
|
||||
FOREIGN KEY (searchable_id) REFERENCES searchable(pk),
|
||||
FOREIGN KEY (tag) REFERENCES tags(tag)
|
||||
);
|
||||
|
||||
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther');
|
||||
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma');
|
||||
|
||||
INSERT INTO tags VALUES ("canine");
|
||||
INSERT INTO tags VALUES ("feline");
|
||||
|
||||
INSERT INTO searchable_tags (searchable_id, tag) VALUES
|
||||
(1, "feline"),
|
||||
(2, "canine")
|
||||
;
|
||||
|
||||
CREATE VIRTUAL TABLE "searchable_fts"
|
||||
USING FTS5 (text1, text2, [name with . and spaces], content="searchable", content_rowid="pk");
|
||||
INSERT INTO "searchable_fts" (searchable_fts) VALUES ('rebuild');
|
||||
|
||||
CREATE TABLE [select] (
|
||||
[group] text,
|
||||
[having] text,
|
||||
[and] text,
|
||||
[json] text
|
||||
);
|
||||
INSERT INTO [select] VALUES ('group', 'having', 'and',
|
||||
'{"href": "http://example.com/", "label":"Example"}'
|
||||
);
|
||||
|
||||
CREATE TABLE infinity (
|
||||
value REAL
|
||||
);
|
||||
INSERT INTO infinity VALUES
|
||||
(1e999),
|
||||
(-1e999),
|
||||
(1.5)
|
||||
;
|
||||
|
||||
CREATE TABLE facet_cities (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
INSERT INTO facet_cities (id, name) VALUES
|
||||
(1, 'San Francisco'),
|
||||
(2, 'Los Angeles'),
|
||||
(3, 'Detroit'),
|
||||
(4, 'Memnonia')
|
||||
;
|
||||
|
||||
CREATE TABLE facetable (
|
||||
pk integer primary key,
|
||||
created text,
|
||||
planet_int integer,
|
||||
on_earth integer,
|
||||
state text,
|
||||
_city_id integer,
|
||||
_neighborhood text,
|
||||
tags text,
|
||||
complex_array text,
|
||||
distinct_some_null,
|
||||
n text,
|
||||
FOREIGN KEY ("_city_id") REFERENCES [facet_cities](id)
|
||||
);
|
||||
INSERT INTO facetable
|
||||
(created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n)
|
||||
VALUES
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Mission', '["tag1", "tag2"]', '[{"foo": "bar"}]', 'one', 'n1'),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Dogpatch', '["tag1", "tag3"]', '[]', 'two', 'n2'),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'SOMA', '[]', '[]', null, null),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Tenderloin', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Bernal Heights', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Hayes Valley', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Hollywood', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Downtown', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Los Feliz', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Koreatown', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'MI', 3, 'Downtown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Greektown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Corktown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Mexicantown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 2, 0, 'MC', 4, 'Arcadia Planitia', '[]', '[]', null, null)
|
||||
;
|
||||
|
||||
CREATE TABLE binary_data (
|
||||
data BLOB
|
||||
);
|
||||
|
||||
-- Many 2 Many demo: roadside attractions!
|
||||
|
||||
CREATE TABLE roadside_attractions (
|
||||
pk integer primary key,
|
||||
name text,
|
||||
address text,
|
||||
url text,
|
||||
latitude real,
|
||||
longitude real
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
1, "The Mystery Spot", "465 Mystery Spot Road, Santa Cruz, CA 95065", "https://www.mysteryspot.com/",
|
||||
37.0167, -122.0024
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
2, "Winchester Mystery House", "525 South Winchester Boulevard, San Jose, CA 95128", "https://winchestermysteryhouse.com/",
|
||||
37.3184, -121.9511
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
3, "Burlingame Museum of PEZ Memorabilia", "214 California Drive, Burlingame, CA 94010", null,
|
||||
37.5793, -122.3442
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
4, "Bigfoot Discovery Museum", "5497 Highway 9, Felton, CA 95018", "https://www.bigfootdiscoveryproject.com/",
|
||||
37.0414, -122.0725
|
||||
);
|
||||
|
||||
CREATE TABLE attraction_characteristic (
|
||||
pk integer primary key,
|
||||
name text
|
||||
);
|
||||
INSERT INTO attraction_characteristic VALUES (
|
||||
1, "Museum"
|
||||
);
|
||||
INSERT INTO attraction_characteristic VALUES (
|
||||
2, "Paranormal"
|
||||
);
|
||||
|
||||
CREATE TABLE roadside_attraction_characteristics (
|
||||
attraction_id INTEGER REFERENCES roadside_attractions(pk),
|
||||
characteristic_id INTEGER REFERENCES attraction_characteristic(pk)
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
1, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
2, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
4, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
3, 1
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
4, 1
|
||||
);
|
||||
|
||||
INSERT INTO simple_primary_key VALUES (1, 'hello');
|
||||
INSERT INTO simple_primary_key VALUES (2, 'world');
|
||||
INSERT INTO simple_primary_key VALUES (3, '');
|
||||
INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO');
|
||||
INSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC');
|
||||
|
||||
INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world');
|
||||
INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2');
|
||||
|
||||
INSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b');
|
||||
INSERT INTO foreign_key_references VALUES (2, null, null, null, null, null);
|
||||
|
||||
INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1);
|
||||
INSERT INTO custom_foreign_key_label VALUES (1, 1);
|
||||
|
||||
INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');
|
||||
|
||||
CREATE VIEW simple_view AS
|
||||
SELECT content, upper(content) AS upper_content FROM simple_primary_key;
|
||||
|
||||
CREATE VIEW searchable_view AS
|
||||
SELECT * from searchable;
|
||||
|
||||
CREATE VIEW searchable_view_configured_by_metadata AS
|
||||
SELECT * from searchable;
|
||||
|
||||
"""
|
||||
+ "\n".join(
|
||||
[
|
||||
'INSERT INTO no_primary_key VALUES ({i}, "a{i}", "b{i}", "c{i}");'.format(
|
||||
i=i + 1
|
||||
)
|
||||
for i in range(201)
|
||||
]
|
||||
)
|
||||
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
||||
+ "\n".join(
|
||||
[
|
||||
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
|
||||
a=a, b=b, c=c, content=content
|
||||
)
|
||||
for a, b, c, content in generate_compound_rows(1001)
|
||||
]
|
||||
)
|
||||
+ "\n".join(["""INSERT INTO sortable VALUES (
|
||||
"{pk1}", "{pk2}", "{content}", {sortable},
|
||||
{sortable_with_nulls}, {sortable_with_nulls_2}, "{text}");
|
||||
""".format(**row).replace("None", "null") for row in generate_sortable_rows(201)])
|
||||
)
|
||||
|
||||
TABLE_PARAMETERIZED_SQL = [
|
||||
("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]),
|
||||
("insert into binary_data (data) values (?);", [b"\x15\x1c\x03\xc7\xad\x05\xfe"]),
|
||||
("insert into binary_data (data) values (null);", []),
|
||||
]
|
||||
|
||||
EXTRA_DATABASE_SQL = """
|
||||
CREATE TABLE searchable (
|
||||
pk integer primary key,
|
||||
text1 text,
|
||||
text2 text
|
||||
);
|
||||
|
||||
CREATE VIEW searchable_view AS SELECT * FROM searchable;
|
||||
|
||||
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog');
|
||||
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel');
|
||||
|
||||
CREATE VIRTUAL TABLE "searchable_fts"
|
||||
USING FTS3 (text1, text2, content="searchable");
|
||||
INSERT INTO "searchable_fts" (rowid, text1, text2)
|
||||
SELECT rowid, text1, text2 FROM searchable;
|
||||
"""
|
||||
|
||||
|
||||
@documented(label="datasette_fixtures_populate_fixture_database")
|
||||
def populate_fixture_database(conn):
|
||||
"""Populate a SQLite connection with Datasette's test fixture tables."""
|
||||
conn.executescript(TABLES)
|
||||
for sql, params in TABLE_PARAMETERIZED_SQL:
|
||||
with conn:
|
||||
conn.execute(sql, params)
|
||||
|
||||
|
||||
def populate_extra_database(conn):
|
||||
"""Populate a SQLite connection with the extra database used in tests."""
|
||||
conn.executescript(EXTRA_DATABASE_SQL)
|
||||
|
||||
|
||||
def write_fixture_database(db_filename):
|
||||
"""Write Datasette's test fixture tables to a SQLite database file."""
|
||||
conn = sqlite3.connect(db_filename)
|
||||
try:
|
||||
populate_fixture_database(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write_extra_database(db_filename):
|
||||
"""Write the extra test database tables to a SQLite database file."""
|
||||
conn = sqlite3.connect(db_filename)
|
||||
try:
|
||||
populate_extra_database(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -137,11 +137,6 @@ def permission_resources_sql(datasette, actor, action):
|
|||
"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def canned_queries(datasette, database, actor):
|
||||
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_magic_parameters(datasette):
|
||||
"""Return a list of (name, function) magic parameter functions"""
|
||||
|
|
@ -157,34 +152,39 @@ def menu_links(datasette, actor, request):
|
|||
"""Links for the navigation menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
"""SQL fragments for extra items in the jump menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
"""Items for the row actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
"""Links for the table actions menu"""
|
||||
"""Items for the table actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def view_actions(datasette, actor, database, view, request):
|
||||
"""Links for the view actions menu"""
|
||||
"""Items for the view actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||
"""Links for the query and canned query actions menu"""
|
||||
"""Items for the query and stored query actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def database_actions(datasette, actor, database, request):
|
||||
"""Links for the database actions menu"""
|
||||
"""Items for the database actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def homepage_actions(datasette, actor, request):
|
||||
"""Links for the homepage actions menu"""
|
||||
"""Items for the homepage actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
@ -228,8 +228,8 @@ def top_query(datasette, request, database, sql):
|
|||
|
||||
|
||||
@hookspec
|
||||
def top_canned_query(datasette, request, database, query_name):
|
||||
"""HTML to include at the top of the canned query page"""
|
||||
def top_stored_query(datasette, request, database, query_name):
|
||||
"""HTML to include at the top of the stored query page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
|
|||
68
datasette/jump.py
Normal file
68
datasette/jump.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class JumpSQL:
|
||||
sql: str
|
||||
params: dict[str, Any] | None = None
|
||||
database: str | None = None
|
||||
|
||||
@classmethod
|
||||
def menu_item(
|
||||
cls,
|
||||
*,
|
||||
label: str,
|
||||
url: str,
|
||||
description: str = "Menu item",
|
||||
search_text: str | None = None,
|
||||
display_name: str | None = None,
|
||||
item_type: str = "menu",
|
||||
) -> "JumpSQL":
|
||||
if search_text is None:
|
||||
search_text = " ".join(
|
||||
text for text in (label, display_name, description) if text is not None
|
||||
)
|
||||
return cls(
|
||||
sql="""
|
||||
SELECT
|
||||
:type AS type,
|
||||
:label AS label,
|
||||
:description AS description,
|
||||
:url AS url,
|
||||
:search_text AS search_text,
|
||||
:display_name AS display_name
|
||||
""",
|
||||
params={
|
||||
"type": item_type,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"search_text": search_text,
|
||||
"display_name": display_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
|
||||
"""Rename named SQL parameters so UNION query parameters cannot collide."""
|
||||
if not params:
|
||||
return sql, {}
|
||||
|
||||
renamed = {key: f"{prefix}_{key}" for key in params}
|
||||
|
||||
def replace(match):
|
||||
key = match.group(1)
|
||||
if key not in renamed:
|
||||
return match.group(0)
|
||||
return f":{renamed[key]}"
|
||||
|
||||
return _PARAM_RE.sub(replace, sql), {
|
||||
renamed[key]: value for key, value in params.items()
|
||||
}
|
||||
|
|
@ -8,6 +8,14 @@ _skip_permission_checks = contextvars.ContextVar(
|
|||
"skip_permission_checks", default=False
|
||||
)
|
||||
|
||||
# Request-scoped cache of permission check results. The ASGI router sets
|
||||
# this to a fresh dict at the start of each request, so cached verdicts
|
||||
# never outlive a request or leak between actors. Keys are
|
||||
# (actor_json, action, parent, child) tuples, values are booleans.
|
||||
_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
|
||||
"permission_check_cache", default=None
|
||||
)
|
||||
|
||||
|
||||
class SkipPermissions:
|
||||
"""Context manager to temporarily skip permission checks.
|
||||
|
|
@ -58,6 +66,16 @@ class Resource(ABC):
|
|||
self.child = child
|
||||
self._private = None # Sentinel to track if private was set
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "/".join(
|
||||
str(part) for part in (self.parent, self.child) if part is not None
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{}(parent={!r}, child={!r})".format(
|
||||
self.__class__.__name__, self.parent, self.child
|
||||
)
|
||||
|
||||
@property
|
||||
def private(self) -> bool:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.default_column_types",
|
||||
"datasette.default_magic_parameters",
|
||||
"datasette.blob_renderer",
|
||||
"datasette.default_menu_links",
|
||||
"datasette.default_debug_menu",
|
||||
"datasette.default_jump_items",
|
||||
"datasette.default_database_actions",
|
||||
"datasette.default_table_actions",
|
||||
"datasette.default_query_actions",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
"datasette.events",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import json
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.utils import (
|
||||
error_body,
|
||||
value_as_boolean,
|
||||
remove_infinites,
|
||||
CustomJSONEncoder,
|
||||
|
|
@ -51,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
|
||||
|
|
@ -86,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"]
|
||||
|
||||
|
|
@ -99,16 +101,11 @@ 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
|
||||
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
|
||||
if isinstance(data, dict) and "columns" not in extra_names_from_request(request):
|
||||
data.pop("columns", None)
|
||||
|
||||
# Handle _nl option for _shape=array
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class TableResource(Resource):
|
|||
|
||||
|
||||
class QueryResource(Resource):
|
||||
"""A canned query in a database."""
|
||||
"""A stored query in a database."""
|
||||
|
||||
name = "query"
|
||||
parent_class = DatabaseResource
|
||||
|
|
@ -51,42 +51,8 @@ class QueryResource(Resource):
|
|||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import await_me_maybe
|
||||
|
||||
# Get all databases from catalog
|
||||
db = datasette.get_internal_database()
|
||||
result = await db.execute("SELECT database_name FROM catalog_databases")
|
||||
databases = [row[0] for row in result.rows]
|
||||
|
||||
# Gather canned queries for this actor from all databases.
|
||||
# This keeps allowed_resources("view-query", actor=...) consistent with
|
||||
# actor-specific canned_queries() implementations.
|
||||
query_pairs = []
|
||||
for database_name in databases:
|
||||
# Call the hook to get queries (including from config via default plugin)
|
||||
for queries_result in pm.hook.canned_queries(
|
||||
datasette=datasette,
|
||||
database=database_name,
|
||||
actor=actor,
|
||||
):
|
||||
queries = await await_me_maybe(queries_result)
|
||||
if queries:
|
||||
for query_name in queries.keys():
|
||||
query_pairs.append((database_name, query_name))
|
||||
|
||||
# Build SQL
|
||||
if not query_pairs:
|
||||
return "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||
|
||||
# Generate UNION ALL query
|
||||
selects = []
|
||||
for db_name, query_name in query_pairs:
|
||||
# Escape single quotes by doubling them
|
||||
db_escaped = db_name.replace("'", "''")
|
||||
query_escaped = query_name.replace("'", "''")
|
||||
selects.append(
|
||||
f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child"
|
||||
)
|
||||
|
||||
return " UNION ALL ".join(selects)
|
||||
return """
|
||||
SELECT q.database_name AS parent, q.name AS child
|
||||
FROM queries q
|
||||
JOIN catalog_databases cd ON cd.database_name = q.database_name
|
||||
"""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
344
datasette/static/autocomplete.js
Normal file
344
datasette/static/autocomplete.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
(function () {
|
||||
function autocompleteValueFromRow(row) {
|
||||
var pks = (row && row.pks) || {};
|
||||
var keys = Object.keys(pks);
|
||||
if (!keys.length) {
|
||||
return "";
|
||||
}
|
||||
if (keys.length === 1) {
|
||||
return String(pks[keys[0]]);
|
||||
}
|
||||
return keys
|
||||
.map(function (key) {
|
||||
return key + "=" + pks[key];
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function autocompleteLabelFromRow(row) {
|
||||
var value = autocompleteValueFromRow(row);
|
||||
if (row.label && String(row.label) !== value) {
|
||||
return row.label + " (" + value + ")";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!window.customElements || customElements.get("datasette-autocomplete")) {
|
||||
return;
|
||||
}
|
||||
|
||||
class DatasetteAutocomplete extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.input = null;
|
||||
this.listbox = null;
|
||||
this.status = null;
|
||||
this.results = [];
|
||||
this.activeIndex = -1;
|
||||
this.fetchId = 0;
|
||||
this.searchTimer = null;
|
||||
this.boundInput = this.handleInput.bind(this);
|
||||
this.boundKeydown = this.handleKeydown.bind(this);
|
||||
this.boundBlur = this.handleBlur.bind(this);
|
||||
this.boundFocus = this.handleFocus.bind(this);
|
||||
this.boundPositionListbox = this.positionListbox.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.input) {
|
||||
return;
|
||||
}
|
||||
this.input = this.querySelector("input");
|
||||
if (!this.input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var inputId =
|
||||
this.input.id ||
|
||||
"datasette-autocomplete-" + Math.random().toString(36).slice(2);
|
||||
this.input.id = inputId;
|
||||
var listboxId = inputId + "-listbox";
|
||||
var statusId = inputId + "-status";
|
||||
|
||||
this.classList.add("datasette-autocomplete");
|
||||
this.input.setAttribute("role", "combobox");
|
||||
this.input.setAttribute("aria-autocomplete", "list");
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.setAttribute("aria-controls", listboxId);
|
||||
this.input.setAttribute("autocomplete", "off");
|
||||
|
||||
this.listbox = document.createElement("div");
|
||||
this.listbox.className = "datasette-autocomplete-list";
|
||||
this.listbox.id = listboxId;
|
||||
this.listbox.setAttribute("role", "listbox");
|
||||
this.listbox.hidden = true;
|
||||
|
||||
this.status = document.createElement("span");
|
||||
this.status.className = "datasette-autocomplete-status";
|
||||
this.status.id = statusId;
|
||||
this.status.setAttribute("role", "status");
|
||||
this.status.setAttribute("aria-live", "polite");
|
||||
|
||||
this.input.setAttribute(
|
||||
"aria-describedby",
|
||||
[this.input.getAttribute("aria-describedby"), statusId]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
|
||||
this.appendChild(this.listbox);
|
||||
this.appendChild(this.status);
|
||||
|
||||
this.input.addEventListener("input", this.boundInput);
|
||||
this.input.addEventListener("keydown", this.boundKeydown);
|
||||
this.input.addEventListener("blur", this.boundBlur);
|
||||
this.input.addEventListener("focus", this.boundFocus);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (!this.input) {
|
||||
return;
|
||||
}
|
||||
this.input.removeEventListener("input", this.boundInput);
|
||||
this.input.removeEventListener("keydown", this.boundKeydown);
|
||||
this.input.removeEventListener("blur", this.boundBlur);
|
||||
this.input.removeEventListener("focus", this.boundFocus);
|
||||
}
|
||||
|
||||
handleInput() {
|
||||
this.scheduleSearch();
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) {
|
||||
this.scheduleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
window.setTimeout(() => this.close(), 150);
|
||||
}
|
||||
|
||||
handleKeydown(ev) {
|
||||
if (ev.key === "Escape") {
|
||||
if (!this.listbox.hidden) {
|
||||
ev.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
if (this.listbox.hidden) {
|
||||
this.scheduleSearch();
|
||||
} else {
|
||||
this.setActiveIndex(this.activeIndex + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowUp") {
|
||||
ev.preventDefault();
|
||||
if (!this.listbox.hidden) {
|
||||
this.setActiveIndex(this.activeIndex - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) {
|
||||
ev.preventDefault();
|
||||
this.chooseIndex(this.activeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleSearch() {
|
||||
window.clearTimeout(this.searchTimer);
|
||||
this.searchTimer = window.setTimeout(() => this.search(), 150);
|
||||
}
|
||||
|
||||
async search() {
|
||||
var query = this.input.value.trim();
|
||||
var initial = !query && this.hasAttribute("suggest-on-focus");
|
||||
if (!query && !initial) {
|
||||
this.close();
|
||||
this.status.textContent = "";
|
||||
return;
|
||||
}
|
||||
var src = this.getAttribute("src");
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
var url = new URL(src, location.href);
|
||||
url.searchParams.set("q", query);
|
||||
if (initial) {
|
||||
url.searchParams.set("_initial", "1");
|
||||
} else {
|
||||
url.searchParams.delete("_initial");
|
||||
}
|
||||
var fetchId = this.fetchId + 1;
|
||||
this.fetchId = fetchId;
|
||||
this.status.textContent = "Searching...";
|
||||
|
||||
try {
|
||||
var response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP " + response.status);
|
||||
}
|
||||
var data = await response.json();
|
||||
if (fetchId !== this.fetchId) {
|
||||
return;
|
||||
}
|
||||
this.results = (data && data.rows) || [];
|
||||
this.render();
|
||||
} catch (_error) {
|
||||
if (fetchId !== this.fetchId) {
|
||||
return;
|
||||
}
|
||||
this.results = [];
|
||||
this.close();
|
||||
this.status.textContent = "Could not load suggestions";
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.listbox.textContent = "";
|
||||
this.activeIndex = -1;
|
||||
if (!this.results.length) {
|
||||
this.close();
|
||||
this.status.textContent = "No matches";
|
||||
return;
|
||||
}
|
||||
|
||||
this.results.forEach((row, index) => {
|
||||
var option = document.createElement("div");
|
||||
option.className = "datasette-autocomplete-option";
|
||||
option.id = this.input.id + "-option-" + index;
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-selected", "false");
|
||||
option.dataset.index = String(index);
|
||||
option.dataset.value = autocompleteValueFromRow(row);
|
||||
option.textContent = autocompleteLabelFromRow(row);
|
||||
option.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
this.chooseIndex(index);
|
||||
});
|
||||
this.listbox.appendChild(option);
|
||||
});
|
||||
|
||||
this.listbox.hidden = false;
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
this.status.textContent =
|
||||
this.results.length + (this.results.length === 1 ? " match" : " matches");
|
||||
this.positionListbox();
|
||||
this.setActiveIndex(0);
|
||||
}
|
||||
|
||||
positionListbox() {
|
||||
if (!this.input || !this.listbox || this.listbox.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
var gap = 3;
|
||||
var margin = 8;
|
||||
var inputRect = this.input.getBoundingClientRect();
|
||||
this.listbox.style.maxHeight = "";
|
||||
var defaultMaxHeight = parseFloat(
|
||||
window.getComputedStyle(this.listbox).maxHeight,
|
||||
);
|
||||
if (!Number.isFinite(defaultMaxHeight)) {
|
||||
defaultMaxHeight = 256;
|
||||
}
|
||||
var scrollHeight = Math.ceil(this.listbox.scrollHeight);
|
||||
var desiredHeight = Math.min(scrollHeight, defaultMaxHeight);
|
||||
var availableBelow = Math.max(
|
||||
0,
|
||||
(window.innerHeight || document.documentElement.clientHeight) -
|
||||
inputRect.bottom -
|
||||
gap -
|
||||
margin,
|
||||
);
|
||||
|
||||
this.listbox.style.left = inputRect.left + "px";
|
||||
this.listbox.style.top = inputRect.bottom + gap + "px";
|
||||
this.listbox.style.width = inputRect.width + "px";
|
||||
if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) {
|
||||
this.listbox.style.maxHeight = "none";
|
||||
} else {
|
||||
this.listbox.style.maxHeight =
|
||||
Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) +
|
||||
"px";
|
||||
}
|
||||
window.addEventListener("resize", this.boundPositionListbox);
|
||||
document.addEventListener("scroll", this.boundPositionListbox, true);
|
||||
}
|
||||
|
||||
setActiveIndex(index) {
|
||||
var options = this.listbox.querySelectorAll("[role='option']");
|
||||
if (!options.length) {
|
||||
this.activeIndex = -1;
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
return;
|
||||
}
|
||||
if (index < 0) {
|
||||
index = options.length - 1;
|
||||
}
|
||||
if (index >= options.length) {
|
||||
index = 0;
|
||||
}
|
||||
options.forEach((option, optionIndex) => {
|
||||
option.setAttribute(
|
||||
"aria-selected",
|
||||
optionIndex === index ? "true" : "false",
|
||||
);
|
||||
});
|
||||
this.activeIndex = index;
|
||||
this.input.setAttribute("aria-activedescendant", options[index].id);
|
||||
}
|
||||
|
||||
chooseIndex(index) {
|
||||
var row = this.results[index];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
var value = autocompleteValueFromRow(row);
|
||||
var label = autocompleteLabelFromRow(row);
|
||||
this.input.value = value;
|
||||
this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
this.close();
|
||||
this.status.textContent = "Selected " + label;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("datasette-autocomplete-select", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
row: row,
|
||||
value: value,
|
||||
label: label,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.listbox) {
|
||||
this.listbox.hidden = true;
|
||||
this.listbox.textContent = "";
|
||||
this.listbox.style.left = "";
|
||||
this.listbox.style.maxHeight = "";
|
||||
this.listbox.style.top = "";
|
||||
this.listbox.style.width = "";
|
||||
}
|
||||
if (this.input) {
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
window.removeEventListener("resize", this.boundPositionListbox);
|
||||
document.removeEventListener("scroll", this.boundPositionListbox, true);
|
||||
this.activeIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("datasette-autocomplete", DatasetteAutocomplete);
|
||||
})();
|
||||
|
|
@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {
|
|||
<style>
|
||||
:host {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--accent-light: #e8effd;
|
||||
--card: #ffffff;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,48 @@ const datasetteManager = {
|
|||
return columnActions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
|
||||
* for specific Datasette column types.
|
||||
*
|
||||
* The first plugin to return a control object wins. Returning null or
|
||||
* undefined means "I do not handle this field".
|
||||
*/
|
||||
makeColumnField: (context) => {
|
||||
for (const [pluginName, plugin] of datasetteManager.plugins) {
|
||||
if (!plugin.makeColumnField) {
|
||||
continue;
|
||||
}
|
||||
let control = null;
|
||||
try {
|
||||
control = plugin.makeColumnField(context);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error in makeColumnField() for plugin ${pluginName}`,
|
||||
error,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (control) {
|
||||
return Object.assign({ pluginName }, control);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
makeJumpSections: (context) => {
|
||||
let jumpSections = [];
|
||||
|
||||
datasetteManager.plugins.forEach((plugin) => {
|
||||
if (plugin.makeJumpSections) {
|
||||
const sections = plugin.makeJumpSections(context) || [];
|
||||
jumpSections.push(...sections);
|
||||
}
|
||||
});
|
||||
|
||||
return jumpSections;
|
||||
},
|
||||
|
||||
/**
|
||||
* In MVP, each plugin can only have 1 instance.
|
||||
* In future, panels could be repeated. We omit that for now since so many plugins depend on
|
||||
|
|
@ -192,7 +234,6 @@ const initializeDatasette = () => {
|
|||
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
||||
|
||||
window.__DATASETTE__ = datasetteManager;
|
||||
console.debug("Datasette Manager Created!");
|
||||
|
||||
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
||||
detail: datasetteManager,
|
||||
|
|
|
|||
7622
datasette/static/edit-tools.js
Normal file
7622
datasette/static/edit-tools.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,22 @@
|
|||
let navigationSearchInstanceCounter = 0;
|
||||
|
||||
class NavigationSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.instanceId = ++navigationSearchInstanceCounter;
|
||||
this.inputId = `navigation-search-input-${this.instanceId}`;
|
||||
this.instructionsId = `navigation-search-instructions-${this.instanceId}`;
|
||||
this.listboxId = `navigation-search-results-${this.instanceId}`;
|
||||
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
|
||||
this.statusId = `navigation-search-status-${this.instanceId}`;
|
||||
this.titleId = `navigation-search-title-${this.instanceId}`;
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.selectedIndex = -1;
|
||||
this.matches = [];
|
||||
this.renderedMatches = [];
|
||||
this.debounceTimer = null;
|
||||
this.restoreFocusTarget = null;
|
||||
this.shouldRestoreFocus = true;
|
||||
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
|
|
@ -54,16 +66,20 @@ class NavigationSearch extends HTMLElement {
|
|||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
|
|
@ -77,12 +93,36 @@ class NavigationSearch extends HTMLElement {
|
|||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.close-search {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
font-size: 1.5rem;
|
||||
height: 2.75rem;
|
||||
line-height: 1;
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
.close-search:hover,
|
||||
.close-search:focus {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
overflow-y: auto;
|
||||
height: calc(80vh - 180px);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.results-list:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 0.875rem 1rem;
|
||||
cursor: pointer;
|
||||
|
|
@ -101,16 +141,81 @@ class NavigationSearch extends HTMLElement {
|
|||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.result-item > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jump-start-content {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.jump-start-content:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.result-description {
|
||||
color: #374151;
|
||||
display: -webkit-box;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
margin-top: 0.35rem;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.recent-actions {
|
||||
padding: 0.25rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.clear-recent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clear-recent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
|
|
@ -136,6 +241,18 @@ class NavigationSearch extends HTMLElement {
|
|||
font-family: monospace;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
dialog {
|
||||
|
|
@ -163,19 +280,29 @@ class NavigationSearch extends HTMLElement {
|
|||
}
|
||||
</style>
|
||||
|
||||
<dialog>
|
||||
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
||||
<div class="search-container">
|
||||
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
||||
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
||||
<div id="${this.statusId}" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="${this.inputId}"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
aria-label="Search navigation"
|
||||
placeholder="Jump to..."
|
||||
aria-label="Jump to"
|
||||
aria-describedby="${this.instructionsId}"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="${this.listboxId}"
|
||||
aria-expanded="false"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
||||
</div>
|
||||
<div class="results-container" role="listbox"></div>
|
||||
<div class="results-container"></div>
|
||||
<div class="hint-text">
|
||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||
<span><kbd>Enter</kbd> Select</span>
|
||||
|
|
@ -189,6 +316,7 @@ class NavigationSearch extends HTMLElement {
|
|||
setupEventListeners() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const closeButton = this.shadowRoot.querySelector(".close-search");
|
||||
const resultsContainer =
|
||||
this.shadowRoot.querySelector(".results-container");
|
||||
|
||||
|
|
@ -200,6 +328,17 @@ class NavigationSearch extends HTMLElement {
|
|||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const trigger = e.target.closest("[data-navigation-search-open]");
|
||||
if (trigger) {
|
||||
e.preventDefault();
|
||||
const details = trigger.closest("details");
|
||||
const restoreTarget = details?.querySelector("summary") || trigger;
|
||||
details?.removeAttribute("open");
|
||||
this.openMenu(restoreTarget);
|
||||
}
|
||||
});
|
||||
|
||||
// Input event
|
||||
input.addEventListener("input", (e) => {
|
||||
this.handleSearch(e.target.value);
|
||||
|
|
@ -221,8 +360,19 @@ class NavigationSearch extends HTMLElement {
|
|||
}
|
||||
});
|
||||
|
||||
closeButton.addEventListener("click", () => {
|
||||
this.closeMenu();
|
||||
});
|
||||
|
||||
// Click on result item
|
||||
resultsContainer.addEventListener("click", (e) => {
|
||||
const clearRecent = e.target.closest("[data-clear-recent-items]");
|
||||
if (clearRecent) {
|
||||
e.preventDefault();
|
||||
this.clearRecentItems();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = e.target.closest(".result-item");
|
||||
if (item) {
|
||||
const index = parseInt(item.dataset.index);
|
||||
|
|
@ -237,6 +387,15 @@ class NavigationSearch extends HTMLElement {
|
|||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", (e) => {
|
||||
e.preventDefault();
|
||||
this.closeMenu();
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", () => {
|
||||
this.onMenuClosed();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
|
@ -251,6 +410,106 @@ class NavigationSearch extends HTMLElement {
|
|||
);
|
||||
}
|
||||
|
||||
setElementAttribute(element, name, value) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (typeof element.setAttribute === "function") {
|
||||
element.setAttribute(name, value);
|
||||
} else {
|
||||
element[name] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
removeElementAttribute(element, name) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (typeof element.removeAttribute === "function") {
|
||||
element.removeAttribute(name);
|
||||
} else {
|
||||
delete element[name];
|
||||
}
|
||||
}
|
||||
|
||||
focusRestoreTarget(trigger) {
|
||||
if (trigger && typeof trigger.focus === "function") {
|
||||
return trigger;
|
||||
}
|
||||
if (
|
||||
document.activeElement &&
|
||||
typeof document.activeElement.focus === "function"
|
||||
) {
|
||||
return document.activeElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setNavigationTriggersExpanded(expanded) {
|
||||
if (typeof document.querySelectorAll !== "function") {
|
||||
return;
|
||||
}
|
||||
document
|
||||
.querySelectorAll("[data-navigation-search-open]")
|
||||
.forEach((trigger) => {
|
||||
this.setElementAttribute(
|
||||
trigger,
|
||||
"aria-expanded",
|
||||
expanded ? "true" : "false",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
resultOptionId(index) {
|
||||
return `${this.listboxId}-option-${index}`;
|
||||
}
|
||||
|
||||
updateComboboxState() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const matches = this.renderedMatches || [];
|
||||
this.setElementAttribute(
|
||||
input,
|
||||
"aria-expanded",
|
||||
dialog && dialog.open && matches.length > 0 ? "true" : "false",
|
||||
);
|
||||
|
||||
if (
|
||||
dialog &&
|
||||
dialog.open &&
|
||||
this.selectedIndex >= 0 &&
|
||||
this.selectedIndex < matches.length
|
||||
) {
|
||||
this.setElementAttribute(
|
||||
input,
|
||||
"aria-activedescendant",
|
||||
this.resultOptionId(this.selectedIndex),
|
||||
);
|
||||
} else {
|
||||
this.removeElementAttribute(input, "aria-activedescendant");
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(message) {
|
||||
const status = this.shadowRoot.querySelector(`#${this.statusId}`);
|
||||
if (status) {
|
||||
status.textContent = message || "";
|
||||
}
|
||||
}
|
||||
|
||||
resultsStatus(count, truncated) {
|
||||
if (truncated) {
|
||||
return "More than 100 results. Keep typing to narrow the list.";
|
||||
}
|
||||
if (count === 0) {
|
||||
return "No results found.";
|
||||
}
|
||||
if (count === 1) {
|
||||
return "1 result.";
|
||||
}
|
||||
return `${count} results.`;
|
||||
}
|
||||
|
||||
loadInitialData() {
|
||||
const itemsAttr = this.getAttribute("items");
|
||||
if (itemsAttr) {
|
||||
|
|
@ -267,6 +526,11 @@ class NavigationSearch extends HTMLElement {
|
|||
|
||||
handleSearch(query) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
if (query.trim()) {
|
||||
this.setStatus("Searching...");
|
||||
} else {
|
||||
this.setStatus("");
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
const url = this.getAttribute("url");
|
||||
|
|
@ -289,65 +553,262 @@ class NavigationSearch extends HTMLElement {
|
|||
this.matches = data.matches || [];
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
if (query.trim()) {
|
||||
this.setStatus(this.resultsStatus(this.matches.length, data.truncated));
|
||||
} else {
|
||||
this.setStatus("");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch search results:", e);
|
||||
this.matches = [];
|
||||
this.renderResults();
|
||||
this.setStatus("Search failed.");
|
||||
}
|
||||
}
|
||||
|
||||
filterLocalItems(query) {
|
||||
if (!query.trim()) {
|
||||
this.matches = [];
|
||||
this.matches = this.allItems || [];
|
||||
} else {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.matches = (this.allItems || []).filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
|
||||
item.url.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
if (query.trim()) {
|
||||
this.setStatus(this.resultsStatus(this.matches.length, false));
|
||||
} else {
|
||||
this.setStatus("");
|
||||
}
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
recentItemsStorageKey() {
|
||||
return "datasette.navigationSearch.recentItems";
|
||||
}
|
||||
|
||||
if (this.matches.length === 0) {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||
loadRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(this.recentItemsStorageKey());
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed
|
||||
.filter((item) => item && item.name && item.url)
|
||||
.map((item) => ({
|
||||
name: String(item.name),
|
||||
display_name: item.display_name ? String(item.display_name) : "",
|
||||
url: String(item.url),
|
||||
type: item.type ? String(item.type) : "",
|
||||
description: item.description ? String(item.description) : "",
|
||||
}))
|
||||
.slice(0, 5);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
saveRecentItem(match) {
|
||||
if (
|
||||
typeof localStorage === "undefined" ||
|
||||
!match ||
|
||||
!match.name ||
|
||||
!match.url
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.matches
|
||||
.map(
|
||||
(match, index) => `
|
||||
<div
|
||||
class="result-item ${
|
||||
index === this.selectedIndex ? "selected" : ""
|
||||
}"
|
||||
try {
|
||||
const item = {
|
||||
name: String(match.name),
|
||||
display_name: match.display_name ? String(match.display_name) : "",
|
||||
url: String(match.url),
|
||||
type: match.type ? String(match.type) : "",
|
||||
description: match.description ? String(match.description) : "",
|
||||
};
|
||||
const recentItems = this.loadRecentItems().filter(
|
||||
(recentItem) => recentItem.url !== item.url,
|
||||
);
|
||||
localStorage.setItem(
|
||||
this.recentItemsStorageKey(),
|
||||
JSON.stringify([item, ...recentItems].slice(0, 5)),
|
||||
);
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable, full, or disabled.
|
||||
}
|
||||
}
|
||||
|
||||
clearRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.recentItemsStorageKey());
|
||||
} catch (e) {
|
||||
localStorage.setItem(this.recentItemsStorageKey(), "[]");
|
||||
}
|
||||
this.renderResults();
|
||||
this.setStatus("Recent items cleared.");
|
||||
}
|
||||
|
||||
jumpSections() {
|
||||
const manager = window.__DATASETTE__;
|
||||
if (!manager || typeof manager.makeJumpSections !== "function") {
|
||||
return [];
|
||||
}
|
||||
const sections = manager.makeJumpSections({
|
||||
navigationSearch: this,
|
||||
});
|
||||
return Array.isArray(sections)
|
||||
? sections.filter(
|
||||
(section) => section && typeof section.render === "function",
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
jumpSectionsHtml(jumpSections) {
|
||||
return jumpSections
|
||||
.map((section, index) => {
|
||||
const id = section.id
|
||||
? ` data-jump-section-id="${this.escapeHtml(section.id)}"`
|
||||
: "";
|
||||
return `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
renderJumpSections(container, jumpSections) {
|
||||
jumpSections.forEach((section, index) => {
|
||||
const node = container.querySelector(
|
||||
`[data-jump-section-index="${index}"]`,
|
||||
);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
section.render(node, {
|
||||
navigationSearch: this,
|
||||
container,
|
||||
input: this.shadowRoot.querySelector(".search-input"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resultItemHtml(match, index) {
|
||||
const displayName = match.display_name || match.name;
|
||||
const label =
|
||||
match.display_name && match.display_name !== match.name
|
||||
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
|
||||
: "";
|
||||
const type = match.type
|
||||
? `<div class="result-type">${this.escapeHtml(match.type)}</div>`
|
||||
: "";
|
||||
const description = match.description
|
||||
? `<div class="result-description">${this.escapeHtml(
|
||||
match.description,
|
||||
)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<div
|
||||
id="${this.resultOptionId(index)}"
|
||||
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
|
||||
data-index="${index}"
|
||||
role="option"
|
||||
aria-selected="${index === this.selectedIndex}"
|
||||
>
|
||||
<div>
|
||||
<div class="result-name">${this.escapeHtml(
|
||||
match.name,
|
||||
)}</div>
|
||||
${type}
|
||||
<div class="result-name">${this.escapeHtml(displayName)}</div>
|
||||
${label}
|
||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||
${description}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
`;
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const showStartContent = !input.value.trim();
|
||||
const jumpSections = showStartContent ? this.jumpSections() : [];
|
||||
const startBlock = showStartContent
|
||||
? this.jumpSectionsHtml(jumpSections)
|
||||
: "";
|
||||
const recentItems = showStartContent ? this.loadRecentItems() : [];
|
||||
const defaultMatches = showStartContent ? [] : this.matches;
|
||||
const renderedMatches = [...recentItems, ...defaultMatches];
|
||||
this.renderedMatches = renderedMatches;
|
||||
const emptyListbox = `<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results"></div>`;
|
||||
|
||||
if (renderedMatches.length) {
|
||||
if (
|
||||
this.selectedIndex < 0 ||
|
||||
this.selectedIndex >= renderedMatches.length
|
||||
) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
} else {
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
if (renderedMatches.length === 0) {
|
||||
if (startBlock) {
|
||||
container.innerHTML = startBlock + emptyListbox;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
} else if (showStartContent) {
|
||||
container.innerHTML = emptyListbox;
|
||||
} else {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `${emptyListbox}<div class="no-results">${message}</div>`;
|
||||
}
|
||||
this.updateComboboxState();
|
||||
return;
|
||||
}
|
||||
|
||||
const recentHeading = recentItems.length
|
||||
? `<div class="results-heading" id="${this.recentHeadingId}">Recent</div>`
|
||||
: "";
|
||||
const recentGroup = recentItems.length
|
||||
? `<div role="group" aria-labelledby="${this.recentHeadingId}">${recentItems
|
||||
.map((match, index) => this.resultItemHtml(match, index))
|
||||
.join("")}</div>`
|
||||
: "";
|
||||
const recentActions = recentItems.length
|
||||
? `<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
|
||||
: "";
|
||||
const defaultHtml = defaultMatches
|
||||
.map((match, index) =>
|
||||
this.resultItemHtml(match, recentItems.length + index),
|
||||
)
|
||||
.join("");
|
||||
container.innerHTML =
|
||||
startBlock +
|
||||
recentHeading +
|
||||
`<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results">${recentGroup}${defaultHtml}</div>` +
|
||||
recentActions;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
this.updateComboboxState();
|
||||
|
||||
// Scroll selected item into view
|
||||
if (this.selectedIndex >= 0) {
|
||||
const selectedItem = container.children[this.selectedIndex];
|
||||
const selectedItem = container.querySelector(
|
||||
`.result-item[data-index="${this.selectedIndex}"]`,
|
||||
);
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
|
@ -355,22 +816,27 @@ class NavigationSearch extends HTMLElement {
|
|||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const newIndex = this.selectedIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < this.matches.length) {
|
||||
if (newIndex >= 0 && newIndex < matches.length) {
|
||||
this.selectedIndex = newIndex;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrentItem() {
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
const match = this.matches[index];
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const match = matches[index];
|
||||
if (match) {
|
||||
this.saveRecentItem(match);
|
||||
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
|
|
@ -383,32 +849,59 @@ class NavigationSearch extends HTMLElement {
|
|||
// Navigate to URL
|
||||
window.location.href = match.url;
|
||||
|
||||
this.closeMenu();
|
||||
this.closeMenu({ restoreFocus: false });
|
||||
}
|
||||
}
|
||||
|
||||
openMenu() {
|
||||
openMenu(trigger) {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
|
||||
dialog.showModal();
|
||||
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
|
||||
this.shouldRestoreFocus = true;
|
||||
if (!dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
this.setNavigationTriggersExpanded(true);
|
||||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
// Reset state - start with no items shown
|
||||
// Reset state, then populate the default jump list.
|
||||
this.matches = [];
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults();
|
||||
this.setStatus("");
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
closeMenu(options = {}) {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
dialog.close();
|
||||
this.shouldRestoreFocus = options.restoreFocus !== false;
|
||||
if (dialog.open) {
|
||||
dialog.close();
|
||||
} else {
|
||||
this.onMenuClosed();
|
||||
}
|
||||
}
|
||||
|
||||
onMenuClosed() {
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
this.setElementAttribute(input, "aria-expanded", "false");
|
||||
this.removeElementAttribute(input, "aria-activedescendant");
|
||||
this.setNavigationTriggersExpanded(false);
|
||||
this.setStatus("");
|
||||
if (
|
||||
this.shouldRestoreFocus &&
|
||||
this.restoreFocusTarget &&
|
||||
typeof this.restoreFocusTarget.focus === "function"
|
||||
) {
|
||||
this.restoreFocusTarget.focus();
|
||||
}
|
||||
this.restoreFocusTarget = null;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
div.textContent = text == null ? "" : text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
|
||||
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
|
||||
var setColumnTypeDialogState = null;
|
||||
|
||||
function getParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
|
|
@ -634,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 */
|
||||
|
|
@ -688,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);
|
||||
}
|
||||
|
|
|
|||
579
datasette/stored_queries.py
Normal file
579
datasette/stored_queries.py
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .utils import tilde_encode, urlsafe_components
|
||||
|
||||
UNCHANGED = object()
|
||||
|
||||
|
||||
QUERY_OPTION_FIELDS = (
|
||||
"hide_sql",
|
||||
"fragment",
|
||||
"on_success_message",
|
||||
"on_success_message_sql",
|
||||
"on_success_redirect",
|
||||
"on_error_message",
|
||||
"on_error_redirect",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredQuery:
|
||||
database: str
|
||||
name: str
|
||||
sql: str
|
||||
title: str | None
|
||||
description: str | None
|
||||
description_html: str | None
|
||||
hide_sql: bool
|
||||
fragment: str | None
|
||||
parameters: list[str]
|
||||
is_write: bool
|
||||
is_private: bool
|
||||
is_trusted: bool
|
||||
source: str
|
||||
owner_id: str | None
|
||||
on_success_message: str | None
|
||||
on_success_message_sql: str | None
|
||||
on_success_redirect: str | None
|
||||
on_error_message: str | None
|
||||
on_error_redirect: str | None
|
||||
private: bool | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredQueryPage:
|
||||
queries: list[StoredQuery]
|
||||
next: str | None
|
||||
has_more: bool
|
||||
limit: int
|
||||
|
||||
|
||||
def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
|
||||
data = {
|
||||
"database": query.database,
|
||||
"name": query.name,
|
||||
"sql": query.sql,
|
||||
"title": query.title,
|
||||
"description": query.description,
|
||||
"description_html": query.description_html,
|
||||
"hide_sql": query.hide_sql,
|
||||
"fragment": query.fragment,
|
||||
"parameters": list(query.parameters),
|
||||
"is_write": query.is_write,
|
||||
"is_private": query.is_private,
|
||||
"is_trusted": query.is_trusted,
|
||||
"source": query.source,
|
||||
"owner_id": query.owner_id,
|
||||
"on_success_message": query.on_success_message,
|
||||
"on_success_message_sql": query.on_success_message_sql,
|
||||
"on_success_redirect": query.on_success_redirect,
|
||||
"on_error_message": query.on_error_message,
|
||||
"on_error_redirect": query.on_error_redirect,
|
||||
}
|
||||
if query.private is not None:
|
||||
data["private"] = query.private
|
||||
return data
|
||||
|
||||
|
||||
def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
|
||||
return {
|
||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
||||
"next": page.next,
|
||||
"limit": page.limit,
|
||||
}
|
||||
|
||||
|
||||
async def save_queries_from_config(datasette: Any) -> None:
|
||||
# Apply configured query entries from datasette.yaml to the internal table.
|
||||
await datasette.get_internal_database().execute_write(
|
||||
"DELETE FROM queries WHERE source = 'config'"
|
||||
)
|
||||
for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items():
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
if not isinstance(query_config, dict):
|
||||
query_config = {"sql": query_config}
|
||||
await datasette.add_query(
|
||||
dbname,
|
||||
query_name,
|
||||
query_config["sql"],
|
||||
title=query_config.get("title"),
|
||||
description=query_config.get("description"),
|
||||
description_html=query_config.get("description_html"),
|
||||
hide_sql=bool(query_config.get("hide_sql")),
|
||||
fragment=query_config.get("fragment"),
|
||||
parameters=query_config.get("params"),
|
||||
is_write=bool(query_config.get("write")),
|
||||
is_trusted=bool(query_config.get("is_trusted", True)),
|
||||
source="config",
|
||||
on_success_message=query_config.get("on_success_message"),
|
||||
on_success_message_sql=query_config.get("on_success_message_sql"),
|
||||
on_success_redirect=query_config.get("on_success_redirect"),
|
||||
on_error_message=query_config.get("on_error_message"),
|
||||
on_error_redirect=query_config.get("on_error_redirect"),
|
||||
)
|
||||
|
||||
|
||||
def query_row_to_stored_query(
|
||||
row: Any, private: bool | None = None
|
||||
) -> StoredQuery | None:
|
||||
if row is None:
|
||||
return None
|
||||
parameters = json.loads(row["parameters"] or "[]")
|
||||
options = json.loads(row["options"] or "{}")
|
||||
return StoredQuery(
|
||||
database=row["database_name"],
|
||||
name=row["name"],
|
||||
sql=row["sql"],
|
||||
title=row["title"],
|
||||
description=row["description"],
|
||||
description_html=row["description_html"],
|
||||
hide_sql=bool(options.get("hide_sql")),
|
||||
fragment=options.get("fragment"),
|
||||
parameters=parameters,
|
||||
is_write=bool(row["is_write"]),
|
||||
is_private=bool(row["is_private"]),
|
||||
is_trusted=bool(row["is_trusted"]),
|
||||
source=row["source"],
|
||||
owner_id=row["owner_id"],
|
||||
on_success_message=options.get("on_success_message"),
|
||||
on_success_message_sql=options.get("on_success_message_sql"),
|
||||
on_success_redirect=options.get("on_success_redirect"),
|
||||
on_error_message=options.get("on_error_message"),
|
||||
on_error_redirect=options.get("on_error_redirect"),
|
||||
private=private,
|
||||
)
|
||||
|
||||
|
||||
def query_options_json(options: dict[str, Any]) -> str:
|
||||
options_dict = {}
|
||||
for field in QUERY_OPTION_FIELDS:
|
||||
value = options.get(field)
|
||||
if field == "hide_sql":
|
||||
if value:
|
||||
options_dict[field] = True
|
||||
elif value is not None:
|
||||
options_dict[field] = value
|
||||
return json.dumps(options_dict, sort_keys=True)
|
||||
|
||||
|
||||
async def add_query(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
name: str,
|
||||
sql: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
description_html: str | None = None,
|
||||
hide_sql: bool = False,
|
||||
fragment: str | None = None,
|
||||
parameters: Iterable[str] | None = None,
|
||||
is_write: bool = False,
|
||||
is_private: bool = False,
|
||||
is_trusted: bool = False,
|
||||
source: str = "plugin",
|
||||
owner_id: str | None = None,
|
||||
on_success_message: str | None = None,
|
||||
on_success_message_sql: str | None = None,
|
||||
on_success_redirect: str | None = None,
|
||||
on_error_message: str | None = None,
|
||||
on_error_redirect: str | None = None,
|
||||
replace: bool = True,
|
||||
) -> None:
|
||||
parameters_json = json.dumps(list(parameters or []))
|
||||
options_json = query_options_json(
|
||||
{
|
||||
"hide_sql": hide_sql,
|
||||
"fragment": fragment,
|
||||
"on_success_message": on_success_message,
|
||||
"on_success_message_sql": on_success_message_sql,
|
||||
"on_success_redirect": on_success_redirect,
|
||||
"on_error_message": on_error_message,
|
||||
"on_error_redirect": on_error_redirect,
|
||||
}
|
||||
)
|
||||
sql_statement = """
|
||||
INSERT INTO queries (
|
||||
database_name, name, sql, title, description, description_html,
|
||||
options, parameters, is_write, is_private, is_trusted, source, owner_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
if replace:
|
||||
sql_statement += """
|
||||
ON CONFLICT(database_name, name) DO UPDATE SET
|
||||
sql = excluded.sql,
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
description_html = excluded.description_html,
|
||||
options = excluded.options,
|
||||
parameters = excluded.parameters,
|
||||
is_write = excluded.is_write,
|
||||
is_private = excluded.is_private,
|
||||
is_trusted = excluded.is_trusted,
|
||||
source = excluded.source,
|
||||
owner_id = excluded.owner_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
await datasette.get_internal_database().execute_write(
|
||||
sql_statement,
|
||||
[
|
||||
database,
|
||||
name,
|
||||
sql,
|
||||
title,
|
||||
description,
|
||||
description_html,
|
||||
options_json,
|
||||
parameters_json,
|
||||
int(bool(is_write)),
|
||||
int(bool(is_private)),
|
||||
int(bool(is_trusted)),
|
||||
source,
|
||||
owner_id,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def update_query(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
name: str,
|
||||
*,
|
||||
sql=UNCHANGED,
|
||||
title=UNCHANGED,
|
||||
description=UNCHANGED,
|
||||
description_html=UNCHANGED,
|
||||
hide_sql=UNCHANGED,
|
||||
fragment=UNCHANGED,
|
||||
parameters=UNCHANGED,
|
||||
is_write=UNCHANGED,
|
||||
is_private=UNCHANGED,
|
||||
is_trusted=UNCHANGED,
|
||||
source=UNCHANGED,
|
||||
owner_id=UNCHANGED,
|
||||
on_success_message=UNCHANGED,
|
||||
on_success_message_sql=UNCHANGED,
|
||||
on_success_redirect=UNCHANGED,
|
||||
on_error_message=UNCHANGED,
|
||||
on_error_redirect=UNCHANGED,
|
||||
) -> None:
|
||||
fields = {
|
||||
"sql": sql,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"description_html": description_html,
|
||||
"parameters": parameters,
|
||||
"is_write": is_write,
|
||||
"is_private": is_private,
|
||||
"is_trusted": is_trusted,
|
||||
"source": source,
|
||||
"owner_id": owner_id,
|
||||
}
|
||||
option_fields = {
|
||||
"hide_sql": hide_sql,
|
||||
"fragment": fragment,
|
||||
"on_success_message": on_success_message,
|
||||
"on_success_message_sql": on_success_message_sql,
|
||||
"on_success_redirect": on_success_redirect,
|
||||
"on_error_message": on_error_message,
|
||||
"on_error_redirect": on_error_redirect,
|
||||
}
|
||||
updates = []
|
||||
params = []
|
||||
for field, value in fields.items():
|
||||
if value is UNCHANGED:
|
||||
continue
|
||||
if field in {"is_write", "is_private", "is_trusted"}:
|
||||
value = int(bool(value))
|
||||
elif field == "parameters":
|
||||
value = json.dumps(list(value or []))
|
||||
updates.append(f"{field} = ?")
|
||||
params.append(value)
|
||||
changed_options = {
|
||||
field: value for field, value in option_fields.items() if value is not UNCHANGED
|
||||
}
|
||||
if changed_options:
|
||||
rows = await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT options FROM queries
|
||||
WHERE database_name = ? AND name = ?
|
||||
""",
|
||||
[database, name],
|
||||
)
|
||||
row = rows.first()
|
||||
options = json.loads(row["options"] or "{}") if row is not None else {}
|
||||
for field, value in changed_options.items():
|
||||
if field == "hide_sql":
|
||||
if value:
|
||||
options[field] = True
|
||||
else:
|
||||
options.pop(field, None)
|
||||
elif value is None:
|
||||
options.pop(field, None)
|
||||
else:
|
||||
options[field] = value
|
||||
updates.append("options = ?")
|
||||
params.append(json.dumps(options, sort_keys=True))
|
||||
if not updates:
|
||||
return
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.extend([database, name])
|
||||
await datasette.get_internal_database().execute_write(
|
||||
"""
|
||||
UPDATE queries
|
||||
SET {}
|
||||
WHERE database_name = ? AND name = ?
|
||||
""".format(", ".join(updates)),
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
async def remove_query(
|
||||
datasette: Any, database: str, name: str, source: str | None = None
|
||||
) -> None:
|
||||
sql = "DELETE FROM queries WHERE database_name = ? AND name = ?"
|
||||
params = [database, name]
|
||||
if source is not None:
|
||||
sql += " AND source = ?"
|
||||
params.append(source)
|
||||
await datasette.get_internal_database().execute_write(sql, params)
|
||||
|
||||
|
||||
async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None:
|
||||
rows = await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT * FROM queries
|
||||
WHERE database_name = ? AND name = ?
|
||||
""",
|
||||
[database, name],
|
||||
)
|
||||
return query_row_to_stored_query(rows.first())
|
||||
|
||||
|
||||
async def count_queries(
|
||||
datasette: Any,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
) -> int:
|
||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
||||
action="view-query",
|
||||
actor=actor,
|
||||
parent=database,
|
||||
)
|
||||
params = dict(allowed_params)
|
||||
where_clauses = []
|
||||
if database is not None:
|
||||
params["query_database"] = database
|
||||
where_clauses.append("q.database_name = :query_database")
|
||||
|
||||
if q:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.name LIKE :query_search
|
||||
OR q.title LIKE :query_search
|
||||
OR q.description LIKE :query_search
|
||||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
if is_private is not None:
|
||||
where_clauses.append("q.is_private = :query_is_private")
|
||||
params["query_is_private"] = int(bool(is_private))
|
||||
if is_trusted is not None:
|
||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
||||
params["query_is_trusted"] = int(bool(is_trusted))
|
||||
if source is not None:
|
||||
where_clauses.append("q.source = :query_source")
|
||||
params["query_source"] = source
|
||||
if owner_id is not None:
|
||||
where_clauses.append("q.owner_id = :query_owner_id")
|
||||
params["query_owner_id"] = owner_id
|
||||
|
||||
row = (
|
||||
await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM queries q
|
||||
JOIN (
|
||||
{allowed_sql}
|
||||
) allowed
|
||||
ON allowed.parent = q.database_name
|
||||
AND allowed.child = q.name
|
||||
WHERE {where}
|
||||
""".format(
|
||||
allowed_sql=allowed_sql,
|
||||
where=" AND ".join(where_clauses) or "1 = 1",
|
||||
),
|
||||
params,
|
||||
)
|
||||
).first()
|
||||
return row["count"]
|
||||
|
||||
|
||||
async def list_queries(
|
||||
datasette: Any,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
limit: int = 50,
|
||||
cursor: str | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
include_private: bool = False,
|
||||
) -> StoredQueryPage:
|
||||
limit = min(max(1, int(limit)), 1000)
|
||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
||||
action="view-query",
|
||||
actor=actor,
|
||||
parent=database,
|
||||
include_is_private=include_private,
|
||||
)
|
||||
params = dict(allowed_params)
|
||||
params.update({"limit": limit + 1})
|
||||
sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))"
|
||||
where_clauses = []
|
||||
order_by = "q.database_name, sort_key, q.name"
|
||||
if database is not None:
|
||||
params["query_database"] = database
|
||||
where_clauses.append("q.database_name = :query_database")
|
||||
order_by = "sort_key, q.name"
|
||||
|
||||
if cursor:
|
||||
try:
|
||||
components = urlsafe_components(cursor)
|
||||
except ValueError:
|
||||
components = []
|
||||
if database is None and len(components) == 3:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.database_name > :cursor_database
|
||||
OR (
|
||||
q.database_name = :cursor_database
|
||||
AND (
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
{sort_key_sql} = :cursor_sort_key
|
||||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_database"] = components[0]
|
||||
params["cursor_sort_key"] = components[1]
|
||||
params["cursor_name"] = components[2]
|
||||
elif database is not None and len(components) == 2:
|
||||
where_clauses.append("""
|
||||
(
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
{sort_key_sql} = :cursor_sort_key
|
||||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_sort_key"] = components[0]
|
||||
params["cursor_name"] = components[1]
|
||||
|
||||
if q:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.name LIKE :query_search
|
||||
OR q.title LIKE :query_search
|
||||
OR q.description LIKE :query_search
|
||||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
if is_private is not None:
|
||||
where_clauses.append("q.is_private = :query_is_private")
|
||||
params["query_is_private"] = int(bool(is_private))
|
||||
if is_trusted is not None:
|
||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
||||
params["query_is_trusted"] = int(bool(is_trusted))
|
||||
if source is not None:
|
||||
where_clauses.append("q.source = :query_source")
|
||||
params["query_source"] = source
|
||||
if owner_id is not None:
|
||||
where_clauses.append("q.owner_id = :query_owner_id")
|
||||
params["query_owner_id"] = owner_id
|
||||
|
||||
private_select = ", allowed.is_private AS private" if include_private else ""
|
||||
rows = list(
|
||||
(
|
||||
await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT q.*, {sort_key_sql} AS sort_key{private_select}
|
||||
FROM queries q
|
||||
JOIN (
|
||||
{allowed_sql}
|
||||
) allowed
|
||||
ON allowed.parent = q.database_name
|
||||
AND allowed.child = q.name
|
||||
WHERE {where}
|
||||
ORDER BY {order_by}
|
||||
LIMIT :limit
|
||||
""".format(
|
||||
allowed_sql=allowed_sql,
|
||||
private_select=private_select,
|
||||
sort_key_sql=sort_key_sql,
|
||||
where=" AND ".join(where_clauses) or "1 = 1",
|
||||
order_by=order_by,
|
||||
),
|
||||
params,
|
||||
)
|
||||
).rows
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
queries = []
|
||||
for row in rows:
|
||||
query = query_row_to_stored_query(
|
||||
row, private=bool(row["private"]) if include_private else None
|
||||
)
|
||||
assert query is not None
|
||||
queries.append(query)
|
||||
|
||||
next_token = None
|
||||
if has_more and rows:
|
||||
last_row = rows[-1]
|
||||
if database is None:
|
||||
next_token = "{},{},{}".format(
|
||||
tilde_encode(last_row["database_name"]),
|
||||
tilde_encode(last_row["sort_key"]),
|
||||
tilde_encode(last_row["name"]),
|
||||
)
|
||||
else:
|
||||
next_token = "{},{}".format(
|
||||
tilde_encode(last_row["sort_key"]),
|
||||
tilde_encode(last_row["name"]),
|
||||
)
|
||||
return StoredQueryPage(
|
||||
queries=queries,
|
||||
next=next_token,
|
||||
has_more=has_more,
|
||||
limit=limit,
|
||||
)
|
||||
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,7 +1,7 @@
|
|||
{% if action_links %}
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary>
|
||||
<summary aria-haspopup="menu" aria-expanded="false">
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">{{ action_title }}</title>
|
||||
|
|
@ -13,16 +13,24 @@
|
|||
</summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul>
|
||||
<ul role="menu">
|
||||
{% for link in action_links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<p class="dropdown-description">{{ link.description }}</p>
|
||||
{% endif %}</a>
|
||||
<li role="none">
|
||||
{% if link.get("type") == "button" %}
|
||||
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</button>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -13,4 +13,50 @@ document.body.addEventListener('click', (ev) => {
|
|||
(details) => details.open && details != detailsClickedWithin
|
||||
).forEach(details => details.open = false);
|
||||
});
|
||||
|
||||
/* Sync aria-expanded and add keyboard navigation for details-menu elements */
|
||||
document.querySelectorAll('details.details-menu').forEach(function(details) {
|
||||
var summary = details.querySelector('summary');
|
||||
details.addEventListener('toggle', function() {
|
||||
if (summary) {
|
||||
summary.setAttribute('aria-expanded', details.open ? 'true' : 'false');
|
||||
}
|
||||
if (details.open) {
|
||||
/* Focus first menu item when menu opens */
|
||||
var firstItem = details.querySelector('[role="menuitem"]');
|
||||
if (firstItem) { firstItem.focus(); }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.body.addEventListener('keydown', function(ev) {
|
||||
/* Keyboard navigation for open details-menu elements */
|
||||
var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]'));
|
||||
if (!openDetails.length) { return; }
|
||||
|
||||
if (ev.key === 'Escape') {
|
||||
openDetails.forEach(function(details) {
|
||||
details.open = false;
|
||||
var summary = details.querySelector('summary');
|
||||
if (summary) { summary.focus(); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
||||
var focused = document.activeElement;
|
||||
openDetails.forEach(function(details) {
|
||||
var items = Array.from(details.querySelectorAll('[role="menuitem"]'));
|
||||
if (!items.length) { return; }
|
||||
var idx = items.indexOf(focused);
|
||||
if (idx === -1) { return; }
|
||||
ev.preventDefault();
|
||||
if (ev.key === 'ArrowDown') {
|
||||
items[(idx + 1) % items.length].focus();
|
||||
} else {
|
||||
items[(idx - 1 + items.length) % items.length].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
111
datasette/templates/_execute_write_analysis_scripts.html
Normal file
111
datasette/templates/_execute_write_analysis_scripts.html
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script>
|
||||
window.datasetteSqlAnalysis = (() => {
|
||||
if (
|
||||
window.datasetteSqlAnalysis &&
|
||||
window.datasetteSqlAnalysis.renderAnalysis
|
||||
) {
|
||||
return window.datasetteSqlAnalysis;
|
||||
}
|
||||
|
||||
function appendCodeCell(row, value, emptyText) {
|
||||
const cell = document.createElement("td");
|
||||
if (value) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = value;
|
||||
cell.appendChild(code);
|
||||
} else if (emptyText) {
|
||||
appendNotApplicable(cell);
|
||||
}
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function appendNotApplicable(cell) {
|
||||
const notApplicable = document.createElement("span");
|
||||
notApplicable.className = "execute-write-analysis-na";
|
||||
notApplicable.textContent = "n/a";
|
||||
cell.appendChild(notApplicable);
|
||||
}
|
||||
|
||||
function renderAnalysis(section, data) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
section.replaceChildren();
|
||||
if (data.has_sql === false) {
|
||||
section.hidden = true;
|
||||
return;
|
||||
}
|
||||
section.hidden = false;
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Query operations";
|
||||
section.appendChild(heading);
|
||||
|
||||
if (data.analysis_error) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "message-error";
|
||||
error.textContent = data.analysis_error;
|
||||
section.appendChild(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = data.analysis_rows || [];
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent =
|
||||
"Analysis will show each affected table and required permission.";
|
||||
section.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "table-wrapper";
|
||||
const table = document.createElement("table");
|
||||
table.className = "execute-write-analysis";
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
[
|
||||
"Operation",
|
||||
"Database",
|
||||
"Table",
|
||||
"Required permission",
|
||||
"Allowed",
|
||||
].forEach((label) => {
|
||||
const th = document.createElement("th");
|
||||
th.scope = "col";
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
rows.forEach((analysisRow) => {
|
||||
const row = document.createElement("tr");
|
||||
appendCodeCell(row, analysisRow.operation);
|
||||
appendCodeCell(row, analysisRow.database);
|
||||
appendCodeCell(row, analysisRow.table);
|
||||
appendCodeCell(row, analysisRow.required_permission, "n/a");
|
||||
|
||||
const allowedCell = document.createElement("td");
|
||||
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
|
||||
const allowed = document.createElement("span");
|
||||
allowed.className = analysisRow.allowed
|
||||
? "execute-write-analysis-allowed"
|
||||
: "execute-write-analysis-denied";
|
||||
allowed.textContent = analysisRow.allowed ? "yes" : "no";
|
||||
allowedCell.appendChild(allowed);
|
||||
} else {
|
||||
appendNotApplicable(allowedCell);
|
||||
}
|
||||
row.appendChild(allowedCell);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrapper.appendChild(table);
|
||||
section.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return { renderAnalysis };
|
||||
})();
|
||||
</script>
|
||||
41
datasette/templates/_execute_write_analysis_styles.html
Normal file
41
datasette/templates/_execute_write_analysis_styles.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<style>
|
||||
.execute-write-analysis {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
min-width: 44rem;
|
||||
}
|
||||
.execute-write-analysis th,
|
||||
.execute-write-analysis td {
|
||||
border-bottom: 1px solid #d7dde5;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.execute-write-analysis th {
|
||||
background-color: #edf6fb;
|
||||
border-top: 1px solid #d7dde5;
|
||||
color: #39445a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis tbody tr:nth-child(even) {
|
||||
background-color: rgba(39, 104, 144, 0.05);
|
||||
}
|
||||
.execute-write-analysis code {
|
||||
background: transparent;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.execute-write-analysis-allowed {
|
||||
color: #267a3e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis-denied {
|
||||
color: #b00020;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis-na {
|
||||
color: #687386;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -12,9 +12,9 @@
|
|||
<ul class="tight-bullets">
|
||||
{% for facet_value in facet_info.results %}
|
||||
{% if not facet_value.selected %}
|
||||
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span></li>
|
||||
{% else %}
|
||||
<li>{{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||
<li>{{ facet_value.label or "-" }} · <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span> <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if facet_info.truncated %}
|
||||
|
|
|
|||
138
datasette/templates/_query_form_styles.html
Normal file
138
datasette/templates/_query_form_styles.html
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<style>
|
||||
.query-create-page {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.query-create-form {
|
||||
--query-create-label-width: clamp(7rem, 18vw, 10rem);
|
||||
--query-create-column-gap: 0.8rem;
|
||||
--query-create-control-width: minmax(16rem, 1fr);
|
||||
}
|
||||
.query-create-fields {
|
||||
margin: 0 0 0.85rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.query-create-field {
|
||||
align-items: start;
|
||||
column-gap: var(--query-create-column-gap);
|
||||
display: grid;
|
||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
.query-create-field label {
|
||||
padding-top: 0.55rem;
|
||||
width: auto;
|
||||
}
|
||||
.query-create-field input[type=text],
|
||||
.query-create-field textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .query-create-field textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-url-control {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: max-content minmax(12rem, 1fr);
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-url-prefix {
|
||||
color: #4f5b6d;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.query-create-url-control input[type=text] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.query-create-url-static {
|
||||
color: #39445a;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
.query-create-field textarea {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
min-height: 5rem;
|
||||
padding: 9px 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
form.sql .query-create-sql {
|
||||
column-gap: var(--query-create-column-gap);
|
||||
display: grid;
|
||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
||||
margin: 0.9rem 0 0.75rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.query-create-sql .cm-editor,
|
||||
form.sql .query-create-sql textarea#sql-editor {
|
||||
grid-column: 2;
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-options {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem 1.4rem;
|
||||
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
||||
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
|
||||
}
|
||||
.query-create-options label {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
width: auto;
|
||||
}
|
||||
.query-create-options input[type=checkbox] {
|
||||
margin: 0;
|
||||
}
|
||||
.query-create-option-note,
|
||||
.query-create-analysis-note {
|
||||
color: #4f5b6d;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.query-create-option-note {
|
||||
margin: -0.45rem 0 0;
|
||||
}
|
||||
.query-create-analysis-note {
|
||||
margin: 0;
|
||||
}
|
||||
.query-create-analysis {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
.query-create-submit {
|
||||
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
||||
margin-bottom: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.query-create-form {
|
||||
--query-create-label-width: 1fr;
|
||||
--query-create-column-gap: 0;
|
||||
}
|
||||
.query-create-field {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
.query-create-field label {
|
||||
padding-top: 0;
|
||||
}
|
||||
form.sql .query-create-sql {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.query-create-sql .cm-editor,
|
||||
form.sql .query-create-sql textarea#sql-editor {
|
||||
grid-column: 1;
|
||||
}
|
||||
.query-create-options,
|
||||
.query-create-submit {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
datasette/templates/_query_results.html
Normal file
20
datasette/templates/_query_results.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% if display_rows %}
|
||||
<div class="table-wrapper"><table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr>
|
||||
{% for column, td in zip(columns, row) %}
|
||||
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% elif show_zero_results %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% endif %}
|
||||
307
datasette/templates/_sql_parameter_scripts.html
Normal file
307
datasette/templates/_sql_parameter_scripts.html
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<script>
|
||||
window.datasetteSqlParameters = (() => {
|
||||
if (
|
||||
window.datasetteSqlParameters &&
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh
|
||||
) {
|
||||
return window.datasetteSqlParameters;
|
||||
}
|
||||
|
||||
function currentSql(form) {
|
||||
if (window.editor) {
|
||||
return window.editor.state.doc.toString();
|
||||
}
|
||||
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
|
||||
return sqlInput ? sqlInput.value : "";
|
||||
}
|
||||
|
||||
function controlState(control) {
|
||||
return {
|
||||
value: control.value,
|
||||
expanded: control.tagName.toLowerCase() === "textarea",
|
||||
};
|
||||
}
|
||||
|
||||
function syncParameterState(manager) {
|
||||
manager.parameterState = new Map();
|
||||
manager.section
|
||||
.querySelectorAll("[data-parameter-control]")
|
||||
.forEach((control) => {
|
||||
manager.parameterState.set(
|
||||
control.dataset.parameterName,
|
||||
controlState(control)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createControl(parameter, id, state, namePrefix) {
|
||||
const control = document.createElement(state.expanded ? "textarea" : "input");
|
||||
control.id = id;
|
||||
control.name = `${namePrefix || ""}${parameter}`;
|
||||
control.value = state.value;
|
||||
control.setAttribute("data-parameter-control", "");
|
||||
control.dataset.parameterName = parameter;
|
||||
if (state.expanded) {
|
||||
control.rows = 5;
|
||||
} else {
|
||||
control.type = "text";
|
||||
}
|
||||
return control;
|
||||
}
|
||||
|
||||
function replaceParameterControl(
|
||||
manager,
|
||||
control,
|
||||
button,
|
||||
expand,
|
||||
value,
|
||||
selectionStart
|
||||
) {
|
||||
const parameter = control.dataset.parameterName;
|
||||
const replacement = createControl(
|
||||
parameter,
|
||||
control.id,
|
||||
{
|
||||
value: value === undefined ? control.value : value,
|
||||
expanded: expand,
|
||||
},
|
||||
manager.namePrefix
|
||||
);
|
||||
button.textContent = expand ? "Collapse" : "Expand";
|
||||
button.setAttribute("aria-expanded", expand ? "true" : "false");
|
||||
control.replaceWith(replacement);
|
||||
replacement.focus();
|
||||
if (selectionStart !== undefined && replacement.setSelectionRange) {
|
||||
replacement.setSelectionRange(selectionStart, selectionStart);
|
||||
}
|
||||
manager.parameterState.set(parameter, controlState(replacement));
|
||||
}
|
||||
|
||||
function renderParameters(manager, parameters) {
|
||||
syncParameterState(manager);
|
||||
const previousState = manager.parameterState;
|
||||
const nextState = new Map();
|
||||
manager.section.replaceChildren();
|
||||
if (!parameters.length) {
|
||||
manager.parameterState = nextState;
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Parameters";
|
||||
manager.section.appendChild(heading);
|
||||
|
||||
parameters.forEach((parameter, index) => {
|
||||
const id = `qp${index + 1}`;
|
||||
const state = previousState.get(parameter) || {
|
||||
value: "",
|
||||
expanded: false,
|
||||
};
|
||||
if (!manager.allowExpand) {
|
||||
state.expanded = false;
|
||||
}
|
||||
nextState.set(parameter, state);
|
||||
|
||||
const row = document.createElement("p");
|
||||
row.className = "sql-parameter-row";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = id;
|
||||
label.textContent = parameter;
|
||||
|
||||
const control = createControl(parameter, id, state, manager.namePrefix);
|
||||
|
||||
row.append(label, control);
|
||||
if (manager.allowExpand) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "sql-parameter-toggle";
|
||||
button.setAttribute("data-parameter-toggle", "");
|
||||
button.setAttribute("aria-controls", id);
|
||||
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
|
||||
button.textContent = state.expanded ? "Collapse" : "Expand";
|
||||
row.append(" ", button);
|
||||
}
|
||||
manager.section.appendChild(row);
|
||||
});
|
||||
|
||||
manager.parameterState = nextState;
|
||||
}
|
||||
|
||||
function bindParameterControls(manager) {
|
||||
manager.form.addEventListener("input", (event) => {
|
||||
const control = event.target;
|
||||
if (!control.matches || !control.matches("[data-parameter-control]")) {
|
||||
return;
|
||||
}
|
||||
manager.parameterState.set(
|
||||
control.dataset.parameterName,
|
||||
controlState(control)
|
||||
);
|
||||
});
|
||||
|
||||
if (!manager.allowExpand) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.form.addEventListener("click", (event) => {
|
||||
const button = event.target.closest
|
||||
? event.target.closest("[data-parameter-toggle]")
|
||||
: null;
|
||||
if (!button || !manager.form.contains(button)) {
|
||||
return;
|
||||
}
|
||||
const control = document.getElementById(button.getAttribute("aria-controls"));
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
const expanded = control.tagName.toLowerCase() === "textarea";
|
||||
replaceParameterControl(manager, control, button, !expanded);
|
||||
});
|
||||
|
||||
manager.form.addEventListener("paste", (event) => {
|
||||
const control = event.target;
|
||||
if (
|
||||
!(control instanceof HTMLInputElement) ||
|
||||
!control.matches("[data-parameter-control]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
|
||||
if (!/[\r\n]/.test(pasted)) {
|
||||
return;
|
||||
}
|
||||
const button = document.querySelector(
|
||||
`[data-parameter-toggle][aria-controls="${control.id}"]`
|
||||
);
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const selectionStart = control.selectionStart ?? control.value.length;
|
||||
const selectionEnd = control.selectionEnd ?? selectionStart;
|
||||
const value =
|
||||
control.value.slice(0, selectionStart) +
|
||||
pasted +
|
||||
control.value.slice(selectionEnd);
|
||||
replaceParameterControl(
|
||||
manager,
|
||||
control,
|
||||
button,
|
||||
true,
|
||||
value,
|
||||
selectionStart + pasted.length
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEditorChanges(form, callback) {
|
||||
const editorElement = form.querySelector(".cm-content");
|
||||
if (editorElement) {
|
||||
editorElement.addEventListener("input", callback);
|
||||
}
|
||||
if (!window.editor) {
|
||||
const sqlInput = form.querySelector("textarea#sql-editor");
|
||||
if (sqlInput) {
|
||||
sqlInput.addEventListener("input", callback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!window.editor.datasetteSqlParameterCallbacks) {
|
||||
const editor = window.editor;
|
||||
const originalDispatch = editor.dispatch.bind(editor);
|
||||
editor.datasetteSqlParameterCallbacks = [];
|
||||
editor.dispatch = (...transactions) => {
|
||||
const before = editor.state.doc.toString();
|
||||
originalDispatch(...transactions);
|
||||
if (editor.state.doc.toString() !== before) {
|
||||
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
|
||||
}
|
||||
};
|
||||
}
|
||||
window.editor.datasetteSqlParameterCallbacks.push(callback);
|
||||
}
|
||||
|
||||
function setupSqlParameterRefresh(options) {
|
||||
const form =
|
||||
options.form || document.querySelector("form.sql.core[data-parameters-url]");
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
const shouldRenderParameters = options.renderParameters !== false;
|
||||
const section =
|
||||
options.section || form.querySelector("[data-sql-parameters-section]");
|
||||
if (shouldRenderParameters && !section) {
|
||||
return null;
|
||||
}
|
||||
const manager = {
|
||||
form,
|
||||
section,
|
||||
allowExpand:
|
||||
options.allowExpand === undefined
|
||||
? section
|
||||
? section.dataset.allowExpand === "1"
|
||||
: false
|
||||
: options.allowExpand,
|
||||
namePrefix: section ? section.dataset.parameterNamePrefix || "" : "",
|
||||
parameterState: new Map(),
|
||||
};
|
||||
if (section) {
|
||||
bindParameterControls(manager);
|
||||
syncParameterState(manager);
|
||||
}
|
||||
|
||||
const url = options.url || form.dataset.parametersUrl;
|
||||
let refreshTimer = null;
|
||||
let refreshSequence = 0;
|
||||
|
||||
async function refreshParameters() {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const sequence = ++refreshSequence;
|
||||
try {
|
||||
const requestUrl = new URL(url, window.location.href);
|
||||
requestUrl.searchParams.set("sql", currentSql(form));
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (sequence !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error((data.errors || [response.statusText]).join("; "));
|
||||
}
|
||||
if (shouldRenderParameters) {
|
||||
renderParameters(manager, data.parameters || []);
|
||||
}
|
||||
if (options.onData) {
|
||||
options.onData(data, manager);
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
if (options.onError) {
|
||||
options.onError(error, manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
|
||||
}
|
||||
|
||||
bindEditorChanges(form, scheduleRefresh);
|
||||
return {
|
||||
currentSql: () => currentSql(form),
|
||||
refreshParameters,
|
||||
renderParameters: (parameters) => renderParameters(manager, parameters),
|
||||
};
|
||||
}
|
||||
|
||||
return { setupSqlParameterRefresh };
|
||||
})();
|
||||
</script>
|
||||
58
datasette/templates/_sql_parameter_styles.html
Normal file
58
datasette/templates/_sql_parameter_styles.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<style>
|
||||
form.sql .sql-editor {
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-editor textarea#sql-editor {
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .sql-parameters-section {
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-parameter-row {
|
||||
align-items: start;
|
||||
column-gap: 0.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
|
||||
margin: 0 0 0.65rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-parameter-row label {
|
||||
overflow-wrap: anywhere;
|
||||
padding-top: 0.55rem;
|
||||
width: auto;
|
||||
}
|
||||
form.sql .sql-parameter-row input[data-parameter-control],
|
||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
min-height: 7rem;
|
||||
padding: 9px 4px;
|
||||
}
|
||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
||||
font-size: 0.72rem;
|
||||
height: 1.8rem;
|
||||
line-height: 1;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
form.sql .sql-parameter-row {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
form.sql .sql-parameter-row label {
|
||||
padding-top: 0;
|
||||
}
|
||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
||||
justify-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
10
datasette/templates/_sql_parameters.html
Normal file
10
datasette/templates/_sql_parameters.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %}
|
||||
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameter_name_prefix %} data-parameter-name-prefix="{{ sql_parameter_name_prefix }}"{% endif %}{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
|
||||
{% if parameter_names %}
|
||||
<h2>Parameters</h2>
|
||||
{% for parameter in parameter_names %}
|
||||
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
|
||||
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ sql_parameter_name_prefix }}{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control data-parameter-name="{{ parameter }}">{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr>
|
||||
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}>
|
||||
{% for cell in row %}
|
||||
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
</p>
|
||||
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
|
||||
<summary style="cursor: pointer;">GET</summary>
|
||||
<form class="core" method="get" id="api-explorer-get" style="margin-top: 0.7em">
|
||||
<form class="core" method="get" action="{{ urls.path('-/api') }}" id="api-explorer-get" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
</details>
|
||||
<details style="border: 2px solid #ccc; padding: 0.5em">
|
||||
<summary style="cursor: pointer">POST</summary>
|
||||
<form class="core" method="post" id="api-explorer-post" style="margin-top: 0.7em">
|
||||
<form class="core" method="post" action="{{ urls.path('-/api') }}" id="api-explorer-post" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
<body class="{% block body_class %}{% endblock %}">
|
||||
<div class="not-footer">
|
||||
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||
{% set links = menu_links() %}{% if links or show_logout %}
|
||||
{% set links = menu_links() %}
|
||||
<details class="nav-menu details-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -29,19 +29,18 @@
|
|||
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
|
||||
</svg></summary>
|
||||
<div class="nav-menu-inner">
|
||||
{% if links %}
|
||||
<ul>
|
||||
<li><button type="button" class="button-as-link" data-navigation-search-open aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="/">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true" title="Keyboard shortcut: press / to open Jump to">/</kbd></button></li>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if show_logout %}
|
||||
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</details>
|
||||
{% if actor %}
|
||||
<div class="actor">
|
||||
<strong>{{ display_actor(actor) }}</strong>
|
||||
|
|
@ -71,7 +70,7 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/tables"></navigation-search>
|
||||
<script src="{{ static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
{% block extra_head %}
|
||||
{{- 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 %}
|
||||
|
|
@ -25,9 +30,13 @@
|
|||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if allow_execute_sql %}
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||
<h3>Custom SQL query</h3>
|
||||
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
{% set parameter_names = [] %}
|
||||
{% set parameter_values = {} %}
|
||||
{% set sql_parameters_allow_expand = false %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
<p>
|
||||
<button id="sql-format" type="button" hidden>Format SQL</button>
|
||||
<input type="submit" value="Run SQL">
|
||||
|
|
@ -53,6 +62,9 @@
|
|||
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if queries_more %}
|
||||
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if tables %}
|
||||
|
|
@ -64,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 %}
|
||||
|
|
@ -87,5 +99,11 @@
|
|||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
const page = params.get('_page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== '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(), {
|
||||
|
|
|
|||
78
datasette/templates/debug_autocomplete.html
Normal file
78
datasette/templates/debug_autocomplete.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug autocomplete{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Debug autocomplete</h1>
|
||||
|
||||
<form class="core debug-autocomplete-form" action="{{ urls.path('-/debug/autocomplete') }}" method="get">
|
||||
<p>
|
||||
<label for="debug-autocomplete-database">Database</label>
|
||||
<input id="debug-autocomplete-database" type="text" name="database" value="{{ database_name or "" }}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="debug-autocomplete-table">Table</label>
|
||||
<input id="debug-autocomplete-table" type="text" name="table" value="{{ table_name or "" }}">
|
||||
</p>
|
||||
<p><input type="submit" value="Open autocomplete"></p>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<p class="message-error">{{ error }}</p>
|
||||
{% elif autocomplete_url %}
|
||||
<h2>{{ database_name }} / {{ table_name }}</h2>
|
||||
{% if label_column %}
|
||||
<p>Label column: <code>{{ label_column }}</code></p>
|
||||
{% else %}
|
||||
<p>No label column detected. Results will use primary key values.</p>
|
||||
{% endif %}
|
||||
<div class="debug-autocomplete-demo">
|
||||
<label for="debug-autocomplete-input">Search rows</label>
|
||||
<datasette-autocomplete src="{{ autocomplete_url }}">
|
||||
<input id="debug-autocomplete-input" type="text">
|
||||
</datasette-autocomplete>
|
||||
</div>
|
||||
<h3>Selected row</h3>
|
||||
<pre class="debug-autocomplete-selected" aria-live="polite">No row selected.</pre>
|
||||
<script>
|
||||
document.addEventListener("datasette-autocomplete-select", function (event) {
|
||||
var output = document.querySelector(".debug-autocomplete-selected");
|
||||
if (output) {
|
||||
output.textContent = JSON.stringify(event.detail.row, null, 2);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<h2>Suggested tables</h2>
|
||||
{% if suggestions %}
|
||||
<p>Showing up to five tables with a detected label column.</p>
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Database</th>
|
||||
<th>Table</th>
|
||||
<th>Label column</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for suggestion in suggestions %}
|
||||
<tr>
|
||||
<td>{{ suggestion.database }}</td>
|
||||
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
|
||||
<td><code>{{ suggestion.label_column }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No tables with detected label columns found.</p>
|
||||
{% endif %}
|
||||
<p>Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
{% block title %}Permission Check{% endblock %}
|
||||
|
||||
{% block 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>
|
||||
|
|
|
|||
|
|
@ -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" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
const page = params.get('_page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== '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(), {
|
||||
|
|
|
|||
337
datasette/templates/execute_write.html
Normal file
337
datasette/templates/execute_write.html
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Write to this database{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
<style>
|
||||
.execute-write-template-menu {
|
||||
margin: 0.9rem 0 0.8rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.execute-write-template-menu summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.execute-write-template-controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.4rem 0 0.7rem;
|
||||
}
|
||||
.execute-write-template-menu .execute-write-template-controls label {
|
||||
margin-right: 0.25rem;
|
||||
width: auto;
|
||||
}
|
||||
.execute-write-template-controls select,
|
||||
.execute-write-template-controls button[type=button] {
|
||||
box-sizing: border-box;
|
||||
font-size: 0.78rem;
|
||||
height: 2rem;
|
||||
line-height: 1.1;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
.execute-write-template-controls select {
|
||||
background-color: #fff;
|
||||
border: 1px solid #777;
|
||||
border-radius: 0.25rem;
|
||||
min-width: 13rem;
|
||||
}
|
||||
.execute-write-submit-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem 0.75rem;
|
||||
}
|
||||
.execute-write-submit-row [hidden] {
|
||||
display: none;
|
||||
}
|
||||
form.sql.core input[data-execute-write-submit]:disabled {
|
||||
background: #d0d7de;
|
||||
border-color: #b6c0cc;
|
||||
color: #5f6975;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.execute-write form.sql .sql-editor-min-lines .cm-content,
|
||||
.execute-write form.sql .sql-editor-min-lines .cm-gutter {
|
||||
/* Four visible editor lines without adding blank lines to the SQL value. */
|
||||
min-height: calc(5.6em + 8px);
|
||||
}
|
||||
.execute-write-disabled-reason {
|
||||
color: #4f5b6d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
{% include "_execute_write_analysis_styles.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Write to this database</h1>
|
||||
|
||||
<p>Execute SQL to insert, update or delete rows in this database.</p>
|
||||
|
||||
{% if execution_message %}
|
||||
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if execute_write_returns_rows %}
|
||||
<h2>Returned rows</h2>
|
||||
{% if execute_write_truncated %}
|
||||
<p class="message-warning">Only the first {{ "{:,}".format(execute_write_display_rows|length) }} returned rows are shown.</p>
|
||||
{% endif %}
|
||||
{% set columns = execute_write_columns %}
|
||||
{% set display_rows = execute_write_display_rows %}
|
||||
{% set show_zero_results = true %}
|
||||
{% include "_query_results.html" %}
|
||||
{% endif %}
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
|
||||
{% if write_create_table_template_sql or write_template_tables %}
|
||||
<div class="execute-write-template-menu">
|
||||
<details>
|
||||
<summary>Start with a template</summary>
|
||||
<p class="execute-write-template-controls">
|
||||
{% if write_create_table_template_sql %}
|
||||
<button type="button" data-sql-template="create" data-template-sql="{{ write_create_table_template_sql }}">Create table</button>
|
||||
{% endif %}
|
||||
{% if write_template_tables %}
|
||||
<label for="execute-write-template-table">{% if write_create_table_template_sql %}or table:{% else %}Table{% endif %}</label>
|
||||
<select id="execute-write-template-table">
|
||||
{% for table_name, table in write_template_tables|dictsort %}
|
||||
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% for operation in write_template_operations %}
|
||||
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
|
||||
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
||||
{% set sql_parameters_allow_expand = true %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
|
||||
<div id="execute-write-analysis-section">
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="execute-write-submit-row"{% if save_query_base_url %} data-save-query-base-url="{{ save_query_base_url }}"{% endif %}>
|
||||
<input type="submit" value="Execute" data-execute-write-submit aria-describedby="execute-write-disabled-reason"{% if execute_disabled %} disabled{% endif %}>
|
||||
<span id="execute-write-disabled-reason" class="execute-write-disabled-reason" data-execute-write-disabled-reason aria-live="polite"{% if not execute_disabled_reason %} hidden{% endif %}>{{ execute_disabled_reason or "" }}</span>
|
||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query" data-save-query-link>Save this query</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
||||
const submitButton = form
|
||||
? form.querySelector("[data-execute-write-submit]")
|
||||
: null;
|
||||
const submitDisabledReason = form
|
||||
? form.querySelector("[data-execute-write-disabled-reason]")
|
||||
: null;
|
||||
const submitRow = form
|
||||
? form.querySelector(".execute-write-submit-row")
|
||||
: null;
|
||||
let saveQueryLink = form
|
||||
? form.querySelector("[data-save-query-link]")
|
||||
: null;
|
||||
|
||||
function updateSubmitState(data) {
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.execute_disabled;
|
||||
}
|
||||
if (!submitDisabledReason) {
|
||||
return;
|
||||
}
|
||||
const reason = data.execute_disabled_reason || "";
|
||||
submitDisabledReason.textContent = reason;
|
||||
submitDisabledReason.hidden = !reason;
|
||||
}
|
||||
|
||||
function updateSaveQueryLink(data) {
|
||||
if (!submitRow || !submitRow.dataset.saveQueryBaseUrl) {
|
||||
return;
|
||||
}
|
||||
const sql = window.editor
|
||||
? window.editor.state.doc.toString()
|
||||
: executeWriteSqlInput.value;
|
||||
if (!sql.trim() || !data.ok || data.execute_disabled) {
|
||||
if (saveQueryLink) {
|
||||
saveQueryLink.remove();
|
||||
saveQueryLink = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!saveQueryLink) {
|
||||
saveQueryLink = document.createElement("a");
|
||||
saveQueryLink.className = "save-query";
|
||||
saveQueryLink.setAttribute("data-save-query-link", "");
|
||||
saveQueryLink.textContent = "Save this query";
|
||||
submitRow.appendChild(saveQueryLink);
|
||||
}
|
||||
const url = new URL(
|
||||
submitRow.dataset.saveQueryBaseUrl,
|
||||
window.location.href
|
||||
);
|
||||
url.searchParams.set("sql", sql);
|
||||
saveQueryLink.href = url.pathname + url.search + url.hash;
|
||||
}
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
allowExpand: true,
|
||||
onData(data) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
updateSubmitState(data);
|
||||
updateSaveQueryLink(data);
|
||||
},
|
||||
onError(error) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
updateSubmitState({
|
||||
execute_disabled: true,
|
||||
execute_disabled_reason: error.message,
|
||||
});
|
||||
updateSaveQueryLink({ ok: false, execute_disabled: true });
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if write_create_table_template_sql or write_template_tables %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const tableSelect = document.querySelector("#execute-write-template-table");
|
||||
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
||||
|
||||
function dataKey(operation) {
|
||||
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
|
||||
}
|
||||
|
||||
function selectedOption() {
|
||||
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
|
||||
}
|
||||
|
||||
function templateSql(button) {
|
||||
if (button.dataset.templateSql) {
|
||||
return button.dataset.templateSql;
|
||||
}
|
||||
const operation = button.dataset.sqlTemplate;
|
||||
const option = selectedOption();
|
||||
return option ? option.dataset[dataKey(operation)] || "" : "";
|
||||
}
|
||||
|
||||
function updateTemplateButtons() {
|
||||
templateButtons.forEach((button) => {
|
||||
button.hidden = !templateSql(button);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSqlUrl(sql) {
|
||||
if (!window.history || !window.history.replaceState) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sql", sql);
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
function setEditorSql(sql) {
|
||||
if (window.editor) {
|
||||
window.editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: window.editor.state.doc.length,
|
||||
insert: sql,
|
||||
},
|
||||
selection: { anchor: sql.length },
|
||||
});
|
||||
window.editor.focus();
|
||||
if (sqlInput) {
|
||||
sqlInput.value = sql;
|
||||
}
|
||||
} else if (sqlInput) {
|
||||
sqlInput.value = sql;
|
||||
sqlInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
sqlInput.focus();
|
||||
}
|
||||
updateSqlUrl(sql);
|
||||
}
|
||||
|
||||
templateButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const sql = templateSql(button);
|
||||
if (!sql) {
|
||||
return;
|
||||
}
|
||||
setEditorSql(sql);
|
||||
});
|
||||
});
|
||||
if (tableSelect) {
|
||||
tableSelect.addEventListener("change", updateTemplateButtons);
|
||||
}
|
||||
updateTemplateButtons();
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<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>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
<header class="hd"><nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a>
|
||||
<a href="{{ base_url }}">home</a>
|
||||
</p>
|
||||
<details class="nav-menu details-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
|
|
@ -22,11 +22,11 @@
|
|||
</svg></summary>
|
||||
<div class="nav-menu-inner">
|
||||
<ul>
|
||||
<li><a href="/-/databases">Databases</a></li>
|
||||
<li><a href="/-/plugins">Installed plugins</a></li>
|
||||
<li><a href="/-/versions">Version info</a></li>
|
||||
<li><a href="{{ base_url }}-/databases">Databases</a></li>
|
||||
<li><a href="{{ base_url }}-/plugins">Installed plugins</a></li>
|
||||
<li><a href="{{ base_url }}-/versions">Version info</a></li>
|
||||
</ul>
|
||||
<form class="nav-menu-logout" action="/-/logout" method="post">
|
||||
<form class="nav-menu-logout" action="{{ base_url }}-/logout" method="post">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -48,9 +48,9 @@
|
|||
<header class="hd">
|
||||
<nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a> /
|
||||
<a href="/fixtures">fixtures</a> /
|
||||
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
|
||||
<a href="{{ base_url }}">home</a> /
|
||||
<a href="{{ base_url }}fixtures">fixtures</a> /
|
||||
<a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a>
|
||||
</p>
|
||||
<div class="actor">
|
||||
<strong>testuser</strong>
|
||||
|
|
@ -80,16 +80,16 @@
|
|||
<a href="https://github.com/simonw/datasette">
|
||||
About Datasette</a>
|
||||
</p>
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="/fixtures">fixtures</a></h2>
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="{{ base_url }}fixtures">fixtures</a></h2>
|
||||
<p>
|
||||
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
|
||||
</p>
|
||||
<p><a href="/fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="/fixtures/sortable" title="201 rows">sortable</a>, <a href="/fixtures/facetable" title="15 rows">facetable</a>, <a href="/fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="/fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="/fixtures">...</a></p>
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="/data">data</a></h2>
|
||||
<p><a href="{{ base_url }}fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="{{ base_url }}fixtures/sortable" title="201 rows">sortable</a>, <a href="{{ base_url }}fixtures/facetable" title="15 rows">facetable</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="{{ base_url }}fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="{{ base_url }}fixtures">...</a></p>
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="{{ base_url }}data">data</a></h2>
|
||||
<p>
|
||||
6 rows in 2 tables
|
||||
</p>
|
||||
<p><a href="/data/names" title="6 rows">names</a>, <a href="/data/foo">foo</a></p>
|
||||
<p><a href="{{ base_url }}data/names" title="6 rows">names</a>, <a href="{{ base_url }}data/foo">foo</a></p>
|
||||
</section>
|
||||
|
||||
<h2 class="pattern-heading">.bd for /database</h2>
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
<a href="https://github.com/simonw/datasette">
|
||||
About Datasette</a>
|
||||
</p>
|
||||
<form class="sql" action="/fixtures" method="get">
|
||||
<form class="sql" action="{{ base_url }}fixtures" method="get">
|
||||
<h3>Custom SQL query</h3>
|
||||
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
|
||||
<p>
|
||||
|
|
@ -143,17 +143,17 @@
|
|||
</p>
|
||||
</form>
|
||||
<div class="db-table">
|
||||
<h2><a href="/fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
|
||||
<h2><a href="{{ base_url }}fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
|
||||
<p><em>content</em></p>
|
||||
<p>0 rows</p>
|
||||
</div>
|
||||
<div class="db-table">
|
||||
<h2><a href="/fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
|
||||
<h2><a href="{{ base_url }}fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
|
||||
<p><em>pk, content</em></p>
|
||||
<p>0 rows</p>
|
||||
</div>
|
||||
<div class="db-table">
|
||||
<h2><a href="/fixtures/attraction_characteristic">attraction_characteristic</a></h2>
|
||||
<h2><a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a></h2>
|
||||
<p><em>pk, name</em></p>
|
||||
<p>2 rows</p>
|
||||
</div>
|
||||
|
|
@ -202,9 +202,9 @@
|
|||
<h3>3 rows
|
||||
where characteristic_id = 2
|
||||
</h3>
|
||||
<form class="filters" action="/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>
|
||||
|
||||
|
|
@ -290,16 +290,16 @@
|
|||
<h3>2 extra where clauses</h3>
|
||||
<ul>
|
||||
|
||||
<li><code>planet_int=1</code> [<a href="/fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
|
||||
<li><code>planet_int=1</code> [<a href="{{ base_url }}fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
|
||||
|
||||
<li><code>state='CA'</code> [<a href="/fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
|
||||
<li><code>state='CA'</code> [<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where "characteristic_id" = :p0 order by rowid limit 101" href="/fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&p0=2">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where "characteristic_id" = :p0 order by rowid limit 101" href="{{ base_url }}fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&p0=2">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||
|
||||
<p class="export-links">This data as <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">json</a>, <a href="/fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&_labels=on&_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
|
||||
<p class="export-links">This data as <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">json</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&_labels=on&_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
|
||||
|
||||
<p class="suggested-facets">
|
||||
Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_array=tags#facet-tags">tags</a> (array)
|
||||
|
|
@ -311,7 +311,7 @@
|
|||
<p class="facet-info-name">
|
||||
<strong>tags (array)</strong>
|
||||
|
||||
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created" class="cross">✖</a>
|
||||
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created" class="cross">✖</a>
|
||||
|
||||
</p>
|
||||
<ul>
|
||||
|
|
@ -336,7 +336,7 @@
|
|||
<p class="facet-info-name">
|
||||
<strong>created</strong>
|
||||
|
||||
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet_array=tags" class="cross">✖</a>
|
||||
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet_array=tags" class="cross">✖</a>
|
||||
|
||||
</p>
|
||||
<ul>
|
||||
|
|
@ -361,7 +361,7 @@
|
|||
<p class="facet-info-name">
|
||||
<strong>city_id</strong>
|
||||
|
||||
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=created&_facet_array=tags" class="cross">✖</a>
|
||||
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=created&_facet_array=tags" class="cross">✖</a>
|
||||
|
||||
</p>
|
||||
<ul>
|
||||
|
|
@ -387,45 +387,45 @@
|
|||
Link
|
||||
</th>
|
||||
<th class="col-rowid" scope="col">
|
||||
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort_desc=rowid" rel="nofollow">rowid ▼</a>
|
||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort_desc=rowid" rel="nofollow">rowid ▼</a>
|
||||
</th>
|
||||
<th class="col-attraction_id" scope="col">
|
||||
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=attraction_id" rel="nofollow">attraction_id</a>
|
||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=attraction_id" rel="nofollow">attraction_id</a>
|
||||
</th>
|
||||
<th class="col-characteristic_id" scope="col">
|
||||
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=characteristic_id" rel="nofollow">characteristic_id</a>
|
||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=characteristic_id" rel="nofollow">characteristic_id</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/1">1</a></td>
|
||||
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/1">1</a></td>
|
||||
<td class="col-rowid">1</td>
|
||||
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/1">The Mystery Spot</a> <em>1</em></td>
|
||||
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/1">The Mystery Spot</a> <em>1</em></td>
|
||||
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/2">2</a></td>
|
||||
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/2">2</a></td>
|
||||
<td class="col-rowid">2</td>
|
||||
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/2">Winchester Mystery House</a> <em>2</em></td>
|
||||
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/2">Winchester Mystery House</a> <em>2</em></td>
|
||||
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/3">3</a></td>
|
||||
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/3">3</a></td>
|
||||
<td class="col-rowid">3</td>
|
||||
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a> <em>4</em></td>
|
||||
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a> <em>4</em></td>
|
||||
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="export" class="advanced-export">
|
||||
<h3>Advanced export</h3>
|
||||
<p>JSON shape:
|
||||
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">default</a>,
|
||||
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array">array</a>,
|
||||
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array&_nl=on">newline-delimited</a>
|
||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">default</a>,
|
||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array">array</a>,
|
||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array&_nl=on">newline-delimited</a>
|
||||
</p>
|
||||
<form action="/fixtures/roadside_attraction_characteristics.csv" method="get">
|
||||
<form action="{{ base_url }}fixtures/roadside_attraction_characteristics.csv" method="get">
|
||||
<p>
|
||||
CSV options:
|
||||
<label><input type="checkbox" name="_dl"> download file</label>
|
||||
|
|
@ -445,7 +445,7 @@
|
|||
<h2 class="pattern-heading">.bd for /database/table/row</h2>
|
||||
<section class="content">
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
|
||||
<p>This data as <a href="/fixtures/roadside_attractions/2.json">json</a></p>
|
||||
<p>This data as <a href="{{ base_url }}fixtures/roadside_attractions/2.json">json</a></p>
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -479,7 +479,7 @@
|
|||
<h2>Links from other tables</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/fixtures/roadside_attraction_characteristics?attraction_id=2">
|
||||
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?attraction_id=2">
|
||||
1 row</a>
|
||||
from attraction_id in roadside_attraction_characteristics
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@
|
|||
</style>
|
||||
{% endif %}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
|
||||
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
|
|
@ -24,19 +25,19 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% if canned_query_write and db_is_immutable %}
|
||||
{% if stored_query_write and db_is_immutable %}
|
||||
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
||||
{% endif %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set action_links, action_title = query_actions(), "Query actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||
{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
|
||||
<form class="sql core" action="{{ urls.database(database) }}{% if stored_query %}/{{ stored_query }}{% endif %}" method="{% if stored_query_write %}post{% else %}get{% endif %}" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
|
||||
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
||||
{% endif %}</h3>
|
||||
|
|
@ -45,56 +46,43 @@
|
|||
{% endif %}
|
||||
{% if not hide_sql %}
|
||||
{% if editable and allow_execute_sql %}
|
||||
<p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
||||
>{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
||||
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
||||
{% else %}
|
||||
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if not canned_query %}
|
||||
{% if not stored_query %}
|
||||
<input type="hidden" name="sql"
|
||||
value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
||||
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
||||
>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if named_parameter_values %}
|
||||
<h3>Query parameters</h3>
|
||||
{% for name, value in named_parameter_values.items() %}
|
||||
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set parameter_names = named_parameter_values.keys()|list %}
|
||||
{% set parameter_values = named_parameter_values %}
|
||||
{% set sql_parameters_allow_expand = false %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
<p>
|
||||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
||||
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
|
||||
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}>
|
||||
{{ show_hide_hidden }}
|
||||
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %}
|
||||
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{% if display_rows %}
|
||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
|
||||
<div class="table-wrapper"><table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr>
|
||||
{% for column, td in zip(columns, row) %}
|
||||
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
{% if not canned_query_write and not error %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set show_zero_results = not stored_query_write and not error %}
|
||||
{% include "_query_results.html" %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
163
datasette/templates/query_create.html
Normal file
163
datasette/templates/query_create.html
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create query{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_execute_write_analysis_styles.html" %}
|
||||
{% include "_query_form_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="query-create-page">
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
|
||||
|
||||
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/store" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
|
||||
<div class="query-create-fields">
|
||||
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
|
||||
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
|
||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
|
||||
</div>
|
||||
|
||||
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
|
||||
<p class="query-create-options">
|
||||
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
|
||||
<input type="hidden" name="is_private" value="0">
|
||||
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
|
||||
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
|
||||
</p>
|
||||
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
|
||||
|
||||
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
|
||||
{% if has_sql %}
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const titleInput = document.querySelector("#query-title");
|
||||
const urlInput = document.querySelector("#query-url-slug");
|
||||
let urlEdited = Boolean(urlInput && urlInput.value);
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
if (titleInput && urlInput) {
|
||||
titleInput.addEventListener("input", () => {
|
||||
if (!urlEdited) {
|
||||
urlInput.value = slugify(titleInput.value);
|
||||
}
|
||||
});
|
||||
urlInput.addEventListener("input", () => {
|
||||
urlEdited = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#query-create-analysis-section");
|
||||
const submitButton = form
|
||||
? form.querySelector("[data-query-create-submit]")
|
||||
: null;
|
||||
const analysisNote = form
|
||||
? form.querySelector("[data-query-create-analysis-note]")
|
||||
: null;
|
||||
|
||||
function updateAnalysisNote(data) {
|
||||
if (!analysisNote) {
|
||||
return;
|
||||
}
|
||||
if (data.analysis_error) {
|
||||
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
|
||||
} else if (data.has_sql === false) {
|
||||
analysisNote.textContent = "Enter SQL to analyze this query.";
|
||||
} else if (data.analysis_is_write) {
|
||||
analysisNote.textContent = "This query updates data in the database.";
|
||||
} else {
|
||||
analysisNote.textContent = "This is a read-only query.";
|
||||
}
|
||||
}
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
renderParameters: false,
|
||||
onData(data) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.save_disabled;
|
||||
}
|
||||
updateAnalysisNote(data);
|
||||
},
|
||||
onError(error) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
}
|
||||
updateAnalysisNote({ analysis_error: error.message });
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
82
datasette/templates/query_delete.html
Normal file
82
datasette/templates/query_delete.html
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Delete query: {{ query.name }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<style>
|
||||
.query-delete-page {
|
||||
max-width: 48rem;
|
||||
}
|
||||
.query-delete-summary {
|
||||
background-color: #f7f7f9;
|
||||
border: 1px solid #d7dde5;
|
||||
border-radius: 4px;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.query-delete-summary dt {
|
||||
color: #4f5b6d;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-delete-summary dd {
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.query-delete-summary dd pre {
|
||||
margin: 0.2rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.query-delete-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.query-delete-form input[type=submit] {
|
||||
background: linear-gradient(180deg, #d73a31 0%, #b42318 100%);
|
||||
border-color: #b42318;
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-delete-form input[type=submit]:hover,
|
||||
.query-delete-form input[type=submit]:focus {
|
||||
background: linear-gradient(180deg, #c3342b 0%, #971c14 100%);
|
||||
border-color: #971c14;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-delete db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="query-delete-page">
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Delete query: {{ query.title or query.name }}</h1>
|
||||
|
||||
<p>Are you sure you want to delete this saved query? This cannot be undone.</p>
|
||||
|
||||
<dl class="query-delete-summary">
|
||||
<dt>URL</dt>
|
||||
<dd><a href="{{ query_url }}">{{ query_url }}</a></dd>
|
||||
{% if query.description %}
|
||||
<dt>Description</dt>
|
||||
<dd>{{ query.description }}</dd>
|
||||
{% endif %}
|
||||
<dt>SQL</dt>
|
||||
<dd><pre>{{ query.sql }}</pre></dd>
|
||||
</dl>
|
||||
|
||||
<form class="core query-delete-form" action="{{ query_url }}/-/delete" method="post">
|
||||
<p class="query-delete-actions">
|
||||
<input type="submit" value="Delete query">
|
||||
<a href="{{ query_url }}">Cancel</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
133
datasette/templates/query_edit.html
Normal file
133
datasette/templates/query_edit.html
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit query: {{ name }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_execute_write_analysis_styles.html" %}
|
||||
{% include "_query_form_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-edit db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="query-create-page">
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Edit query: {{ title or name }}</h1>
|
||||
|
||||
<form class="sql core query-create-form" action="{{ query_url }}/-/edit" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
|
||||
<div class="query-create-fields">
|
||||
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
|
||||
<p class="query-create-field"><label>URL</label> <span class="query-create-url-static">{{ query_url }}</span></p>
|
||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
|
||||
</div>
|
||||
|
||||
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
|
||||
<p class="query-create-options">
|
||||
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
|
||||
<input type="hidden" name="is_private" value="0">
|
||||
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
|
||||
<span class="query-create-option-note">Queries marked private can only be seen and edited by you, their owner.</span>
|
||||
</p>
|
||||
<p class="query-create-submit"><input type="submit" value="Save changes" data-query-create-submit{% if save_disabled %} disabled{% endif %}> <a href="{{ query_url }}">Cancel</a></p>
|
||||
|
||||
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
|
||||
{% if has_sql %}
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#query-create-analysis-section");
|
||||
const submitButton = form
|
||||
? form.querySelector("[data-query-create-submit]")
|
||||
: null;
|
||||
const analysisNote = form
|
||||
? form.querySelector("[data-query-create-analysis-note]")
|
||||
: null;
|
||||
|
||||
function updateAnalysisNote(data) {
|
||||
if (!analysisNote) {
|
||||
return;
|
||||
}
|
||||
if (data.analysis_error) {
|
||||
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
|
||||
} else if (data.has_sql === false) {
|
||||
analysisNote.textContent = "Enter SQL to analyze this query.";
|
||||
} else if (data.analysis_is_write) {
|
||||
analysisNote.textContent = "This query updates data in the database.";
|
||||
} else {
|
||||
analysisNote.textContent = "This is a read-only query.";
|
||||
}
|
||||
}
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
renderParameters: false,
|
||||
onData(data) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.save_disabled;
|
||||
}
|
||||
updateAnalysisNote(data);
|
||||
},
|
||||
onError(error) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
}
|
||||
updateAnalysisNote({ analysis_error: error.message });
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
281
datasette/templates/query_list.html
Normal file
281
datasette/templates/query_list.html
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<style>
|
||||
.query-list-page {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.query-list-filters {
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
}
|
||||
.query-list-search {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.query-list-search label {
|
||||
width: auto;
|
||||
}
|
||||
.query-list-search input[type=search] {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 18rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
.query-list-search button[type=submit] {
|
||||
font-size: 0.78rem;
|
||||
height: 2rem;
|
||||
line-height: 1.1;
|
||||
padding: 0.35rem 0.65rem;
|
||||
}
|
||||
.query-list-facets {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.6rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.query-list-facet {
|
||||
margin: 0;
|
||||
}
|
||||
.query-list-facet h2 {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.query-list-facet ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.query-list-facet-link,
|
||||
.query-list-facet-link:link,
|
||||
.query-list-facet-link:visited,
|
||||
.query-list-facet-link:hover,
|
||||
.query-list-facet-link:focus,
|
||||
.query-list-facet-link:active {
|
||||
align-items: center;
|
||||
border: 1px solid #c8d1dc;
|
||||
border-radius: 0.25rem;
|
||||
color: #39445a;
|
||||
display: inline-flex;
|
||||
font-size: 0.82rem;
|
||||
gap: 0.4rem;
|
||||
line-height: 1.1;
|
||||
padding: 0.35rem 0.55rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.query-list-facet-link:hover {
|
||||
border-color: #7ca5c8;
|
||||
color: #1f5d85;
|
||||
}
|
||||
.query-list-facet-link-active {
|
||||
background-color: #edf6fb;
|
||||
border-color: #6d9fc0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-list-facet-disabled {
|
||||
color: #7b8794;
|
||||
cursor: default;
|
||||
}
|
||||
.query-list-facet-count {
|
||||
color: #4f5b6d;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.query-list-results {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
min-width: 42rem;
|
||||
width: 100%;
|
||||
}
|
||||
.query-list-results th,
|
||||
.query-list-results td {
|
||||
border-bottom: 1px solid #d7dde5;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.query-list-results th {
|
||||
background-color: #edf6fb;
|
||||
border-top: 1px solid #d7dde5;
|
||||
color: #39445a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-list-results tbody tr:nth-child(even) {
|
||||
background-color: rgba(39, 104, 144, 0.05);
|
||||
}
|
||||
.query-list-results a.query-list-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-list-description {
|
||||
color: #4f5b6d;
|
||||
font-size: 0.78rem;
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
.query-list-owner {
|
||||
color: #39445a;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.query-list-flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.query-list-pill {
|
||||
background-color: #eef1f5;
|
||||
border: 1px solid #d7dde5;
|
||||
border-radius: 0.25rem;
|
||||
color: #39445a;
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.query-list-pill-write {
|
||||
background-color: #fff4db;
|
||||
border-color: #e2b64e;
|
||||
}
|
||||
.query-list-pill-public {
|
||||
background-color: #e7f5ec;
|
||||
border-color: #9ecfab;
|
||||
color: #267a3e;
|
||||
}
|
||||
.query-list-pill-private {
|
||||
background-color: #f7edf0;
|
||||
border-color: #dbb8c1;
|
||||
}
|
||||
.query-list-pill-trusted {
|
||||
background-color: #e7f5ec;
|
||||
border-color: #9ecfab;
|
||||
color: #267a3e;
|
||||
}
|
||||
.query-list-empty {
|
||||
color: #6b7280;
|
||||
}
|
||||
.query-list-footnotes {
|
||||
border-top: 1px solid #d7dde5;
|
||||
color: #4f5b6d;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.35rem 0 1rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
.query-list-footnotes p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.query-list-footnotes .query-list-pill {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.query-list-pagination a {
|
||||
border: 1px solid #007bff;
|
||||
border-radius: 0.25rem;
|
||||
display: inline-block;
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
.query-list-pagination-bottom {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.query-list-search input[type=search] {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="query-list-page">
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
|
||||
|
||||
{% if queries %}
|
||||
<form class="query-list-filters core" action="{{ query_list_path }}" method="get">
|
||||
<p class="query-list-search">
|
||||
<label for="query-search">Search</label>
|
||||
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
|
||||
{% if filters.is_write %}<input type="hidden" name="is_write" value="{{ filters.is_write }}">{% endif %}
|
||||
{% if filters.is_private %}<input type="hidden" name="is_private" value="{{ filters.is_private }}">{% endif %}
|
||||
{% if filters.source %}<input type="hidden" name="source" value="{{ filters.source }}">{% endif %}
|
||||
{% if filters.owner_id %}<input type="hidden" name="owner_id" value="{{ filters.owner_id }}">{% endif %}
|
||||
<button type="submit">Search</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<nav class="query-list-facets" aria-label="Query filters">
|
||||
{% for facet in facets %}
|
||||
<section class="query-list-facet">
|
||||
<h2>{{ facet.title }}</h2>
|
||||
<ul>
|
||||
{% for item in facet["items"] %}
|
||||
<li>{% if item.href %}<a class="query-list-facet-link{% if item.active %} query-list-facet-link-active{% endif %}" href="{{ item.href }}"{% if item.active %} aria-current="true"{% endif %}>{% else %}<span class="query-list-facet-link query-list-facet-disabled">{% endif %}<span>{{ item.label }}</span><span class="query-list-facet-count">{{ item.count }}</span>{% if item.href %}</a>{% else %}</span>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="table-wrapper"><table class="query-list-results">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if show_database %}<th scope="col">Database</th>{% endif %}
|
||||
<th scope="col">Query</th>
|
||||
<th scope="col">Owner</th>
|
||||
<th scope="col">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for query in queries %}
|
||||
<tr>
|
||||
{% if show_database %}
|
||||
<td><a class="query-list-database" href="{{ urls.database(query.database) }}">{{ query.database }}</a></td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
|
||||
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
|
||||
</td>
|
||||
<td class="query-list-owner">{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}<span class="query-list-empty">-</span>{% endif %}</td>
|
||||
<td>
|
||||
<span class="query-list-flags">
|
||||
{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}
|
||||
{% if query.is_private %}<span class="query-list-pill query-list-pill-private">Private</span>{% endif %}
|
||||
{% if query.is_trusted %}<span class="query-list-pill query-list-pill-trusted">Trusted</span>{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% if show_private_note or show_trusted_note %}
|
||||
<div class="query-list-footnotes">
|
||||
{% if show_private_note %}<p><span class="query-list-pill query-list-pill-private">Private</span>Only the owning actor can view this query.</p>{% endif %}
|
||||
{% if show_trusted_note %}<p><span class="query-list-pill query-list-pill-trusted">Trusted</span>Execution skips the usual SQL and write permission checks after view-query allows access.</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>No queries found.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if next_url %}
|
||||
<nav class="query-list-pagination query-list-pagination-bottom" aria-label="Query pagination"><a href="{{ next_url }}">Next page</a></nav>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -4,6 +4,13 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% if row_mutation_ui %}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in columns %}
|
||||
|
|
|
|||
|
|
@ -1,12 +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 src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
<script src="{{ static('column-chooser.js') }}" defer></script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||
<script src="{{ static('table.js') }}" defer></script>
|
||||
<script src="{{ static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
@ -43,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>
|
||||
|
|
@ -72,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>
|
||||
|
|
@ -88,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 %}
|
||||
|
|
@ -100,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>
|
||||
|
||||
|
|
@ -158,6 +163,19 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
|||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if table_insert_ui %}
|
||||
<div class="table-row-toolbar">
|
||||
<button type="button" class="core table-insert-row" data-table-action="insert-row">
|
||||
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
||||
<path d="M8 12h8"></path>
|
||||
<path d="M12 8v8"></path>
|
||||
</svg>
|
||||
<span>Insert row</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
{% if next_url %}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ def get_task_id():
|
|||
@contextmanager
|
||||
def trace_child_tasks():
|
||||
token = trace_task_id.set(get_task_id())
|
||||
yield
|
||||
trace_task_id.reset(token)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
trace_task_id.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import binascii
|
||||
from contextlib import contextmanager
|
||||
import aiofiles
|
||||
import click
|
||||
|
|
@ -155,9 +156,15 @@ Column = namedtuple(
|
|||
functions_marked_as_documented = []
|
||||
|
||||
|
||||
def documented(fn):
|
||||
functions_marked_as_documented.append(fn)
|
||||
return fn
|
||||
def documented(fn=None, *, label=None):
|
||||
def decorate(fn):
|
||||
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
|
||||
functions_marked_as_documented.append(fn)
|
||||
return fn
|
||||
|
||||
if fn is None:
|
||||
return decorate
|
||||
return decorate(fn)
|
||||
|
||||
|
||||
@documented
|
||||
|
|
@ -218,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)
|
||||
|
|
@ -404,8 +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
|
||||
else:
|
||||
return f"[{s}]"
|
||||
return '"{}"'.format(s.replace('"', '""'))
|
||||
|
||||
|
||||
def make_dockerfile(
|
||||
|
|
@ -831,7 +884,8 @@ def path_with_format(
|
|||
*, request=None, path=None, format=None, extra_qs=None, replace_format=None
|
||||
):
|
||||
qs = extra_qs or {}
|
||||
path = request.path if request else path
|
||||
if path is None and request:
|
||||
path = request.path
|
||||
if replace_format and path.endswith(f".{replace_format}"):
|
||||
path = path[: -(1 + len(replace_format))]
|
||||
if "." in path:
|
||||
|
|
@ -1234,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+)")
|
||||
|
||||
|
||||
|
|
@ -1248,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)
|
||||
|
||||
|
|
@ -1264,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"
|
||||
|
|
@ -1537,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]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ The core pattern is:
|
|||
- Across levels, child beats parent beats global
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from datasette.utils.permissions import gather_permission_sql_from_hooks
|
||||
|
|
@ -241,6 +243,14 @@ async def _build_single_action_sql(
|
|||
"),",
|
||||
]
|
||||
)
|
||||
else:
|
||||
query_parts.extend(
|
||||
[
|
||||
"anon_rules AS (",
|
||||
" SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Continue with the cascading logic
|
||||
query_parts.extend(
|
||||
|
|
@ -487,6 +497,153 @@ async def build_permission_rules_sql(
|
|||
return rules_union, all_params, restriction_sqls
|
||||
|
||||
|
||||
async def check_permissions_for_actions(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
actor: dict | None,
|
||||
actions: list[str],
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Check several actions for one actor and resource in a single query.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
actor: The actor dict (or None)
|
||||
actions: List of action names to check
|
||||
parent: The parent resource identifier (e.g., database name, or None)
|
||||
child: The child resource identifier (e.g., table name, or None)
|
||||
|
||||
Returns:
|
||||
Dict mapping each action name to True (allowed) or False (denied)
|
||||
|
||||
Each action contributes its own tagged block of permission rules
|
||||
(gathered from the permission_resources_sql hook, with parameters
|
||||
namespaced per action to avoid collisions) plus an optional
|
||||
restriction allowlist CTE. One internal database query resolves
|
||||
the winning rule per action using the same specificity-then-deny
|
||||
ordering as the rest of the permission system.
|
||||
|
||||
Note: this resolves each action independently - also_requires
|
||||
dependencies are handled by the caller (Datasette.allowed_many).
|
||||
"""
|
||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||
|
||||
for action in actions:
|
||||
if not datasette.actions.get(action):
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
# Dedupe while preserving order
|
||||
unique_actions = list(dict.fromkeys(actions))
|
||||
if not unique_actions:
|
||||
return {}
|
||||
|
||||
# Gather hook results for each action concurrently - hooks within a
|
||||
# single action still run sequentially, preserving existing semantics
|
||||
gathered = await asyncio.gather(
|
||||
*(
|
||||
gather_permission_sql_from_hooks(
|
||||
datasette=datasette, actor=actor, action=action
|
||||
)
|
||||
for action in unique_actions
|
||||
)
|
||||
)
|
||||
|
||||
if any(result is SKIP_PERMISSION_CHECKS for result in gathered):
|
||||
return {action: True for action in unique_actions}
|
||||
|
||||
params = {"_check_parent": parent, "_check_child": child}
|
||||
ctes = []
|
||||
result_rows = []
|
||||
verdicts = {}
|
||||
|
||||
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
|
||||
prefix = f"a{i}_"
|
||||
rule_parts = []
|
||||
restriction_parts = []
|
||||
|
||||
for permission_sql in permission_sqls:
|
||||
sql = permission_sql.sql
|
||||
restriction_sql = permission_sql.restriction_sql
|
||||
# Namespace this block's params so identical names used for
|
||||
# different actions cannot collide
|
||||
for key in permission_sql.params or {}:
|
||||
new_key = prefix + key
|
||||
params[new_key] = permission_sql.params[key]
|
||||
pattern = re.compile(":" + re.escape(key) + r"(?![A-Za-z0-9_])")
|
||||
if sql:
|
||||
sql = pattern.sub(":" + new_key, sql)
|
||||
if restriction_sql:
|
||||
restriction_sql = pattern.sub(":" + new_key, restriction_sql)
|
||||
|
||||
if restriction_sql:
|
||||
restriction_parts.append(restriction_sql)
|
||||
|
||||
# Skip plugins that only provide restriction_sql (no permission rules)
|
||||
if sql is None:
|
||||
continue
|
||||
rule_parts.append(
|
||||
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
|
||||
)
|
||||
|
||||
if not rule_parts:
|
||||
# No rules from any plugin - default deny. Restrictions can
|
||||
# only restrict, never grant, so no SQL is needed at all
|
||||
verdicts[action] = False
|
||||
continue
|
||||
ctes.append(f"a{i}_rules AS (\n" + "\nUNION ALL\n".join(rule_parts) + "\n)")
|
||||
|
||||
# Winning rule for this action: most specific depth first, then
|
||||
# deny-beats-allow, then source_plugin as a stable tie-break
|
||||
verdict_sql = f"""COALESCE((
|
||||
SELECT allow FROM (
|
||||
SELECT allow, source_plugin,
|
||||
CASE
|
||||
WHEN child IS NOT NULL THEN 2
|
||||
WHEN parent IS NOT NULL THEN 1
|
||||
ELSE 0
|
||||
END AS depth
|
||||
FROM a{i}_rules
|
||||
WHERE (parent IS NULL OR parent = :_check_parent)
|
||||
AND (child IS NULL OR child = :_check_child)
|
||||
ORDER BY
|
||||
depth DESC,
|
||||
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
|
||||
source_plugin
|
||||
LIMIT 1
|
||||
)
|
||||
), 0)"""
|
||||
|
||||
if restriction_parts:
|
||||
# Database-level restrictions (parent, NULL) match all children
|
||||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_parts
|
||||
)
|
||||
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
|
||||
verdict_sql = f"""({verdict_sql}) AND EXISTS (
|
||||
SELECT 1 FROM a{i}_restriction r
|
||||
WHERE (r.parent = :_check_parent OR r.parent IS NULL)
|
||||
AND (r.child = :_check_child OR r.child IS NULL)
|
||||
)"""
|
||||
|
||||
result_rows.append(f"({i}, ({verdict_sql}))")
|
||||
|
||||
if result_rows:
|
||||
ctes.append(
|
||||
"results(action_idx, is_allowed) AS (VALUES\n"
|
||||
+ ",\n".join(result_rows)
|
||||
+ "\n)"
|
||||
)
|
||||
query = (
|
||||
"WITH\n" + ",\n".join(ctes) + "\nSELECT action_idx, is_allowed FROM results"
|
||||
)
|
||||
result = await datasette.get_internal_database().execute(query, params)
|
||||
for row in result.rows:
|
||||
verdicts[unique_actions[row[0]]] = bool(row[1])
|
||||
return verdicts
|
||||
|
||||
|
||||
async def check_permission_for_resource(
|
||||
*,
|
||||
datasette: "Datasette",
|
||||
|
|
@ -507,77 +664,12 @@ async def check_permission_for_resource(
|
|||
|
||||
Returns:
|
||||
True if the actor is allowed, False otherwise
|
||||
|
||||
This builds the cascading permission query and checks if the specific
|
||||
resource is in the allowed set.
|
||||
"""
|
||||
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
|
||||
datasette, actor, action
|
||||
results = await check_permissions_for_actions(
|
||||
datasette=datasette,
|
||||
actor=actor,
|
||||
actions=[action],
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
|
||||
# If no rules (empty SQL), default deny
|
||||
if not rules_union:
|
||||
return False
|
||||
|
||||
# Add parameters for the resource we're checking
|
||||
all_params["_check_parent"] = parent
|
||||
all_params["_check_child"] = child
|
||||
|
||||
# If there are restriction filters, check if the resource passes them first
|
||||
if restriction_sqls:
|
||||
# Check if resource is in restriction allowlist
|
||||
# Database-level restrictions (parent, NULL) should match all children (parent, *)
|
||||
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
|
||||
restriction_check = "\nINTERSECT\n".join(
|
||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||
)
|
||||
restriction_query = f"""
|
||||
WITH restriction_list AS (
|
||||
{restriction_check}
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM restriction_list
|
||||
WHERE (parent = :_check_parent OR parent IS NULL)
|
||||
AND (child = :_check_child OR child IS NULL)
|
||||
) AS in_allowlist
|
||||
"""
|
||||
result = await datasette.get_internal_database().execute(
|
||||
restriction_query, all_params
|
||||
)
|
||||
if result.rows and not result.rows[0][0]:
|
||||
# Resource not in restriction allowlist - deny
|
||||
return False
|
||||
|
||||
query = f"""
|
||||
WITH
|
||||
all_rules AS (
|
||||
{rules_union}
|
||||
),
|
||||
matched_rules AS (
|
||||
SELECT ar.*,
|
||||
CASE
|
||||
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
|
||||
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
|
||||
ELSE 0 -- root/global
|
||||
END AS depth
|
||||
FROM all_rules ar
|
||||
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
|
||||
AND (ar.child IS NULL OR ar.child = :_check_child)
|
||||
),
|
||||
winner AS (
|
||||
SELECT *
|
||||
FROM matched_rules
|
||||
ORDER BY
|
||||
depth DESC, -- specificity first (higher depth wins)
|
||||
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
|
||||
source_plugin -- stable tie-break
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
|
||||
"""
|
||||
|
||||
# Execute the query against the internal database
|
||||
result = await datasette.get_internal_database().execute(query, all_params)
|
||||
if result.rows:
|
||||
return bool(result.rows[0][0])
|
||||
return False
|
||||
return results[action]
|
||||
|
|
|
|||
|
|
@ -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,20 +153,52 @@ 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()
|
||||
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
|
||||
|
||||
async def json(self):
|
||||
body = await self.post_body()
|
||||
return json.loads(body)
|
||||
|
||||
async def form(
|
||||
self,
|
||||
files: bool = False,
|
||||
|
|
@ -330,9 +374,11 @@ async def asgi_send_html(send, html, status=200, headers=None):
|
|||
|
||||
|
||||
async def asgi_send_redirect(send, location, status=302):
|
||||
# Prevent open redirect vulnerability: strip multiple leading slashes
|
||||
# //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)
|
||||
location = re.sub(r"^/+", "/", location)
|
||||
# Prevent open redirect vulnerability: collapse leading slashes and
|
||||
# backslashes down to a single slash. //example.com is a protocol-relative
|
||||
# URL, and browsers normalise backslashes to slashes so /\example.com would
|
||||
# be treated as //example.com - https://github.com/simonw/datasette/issues/2680
|
||||
location = re.sub(r"^[/\\]+", "/", location)
|
||||
await asgi_send(
|
||||
send,
|
||||
"",
|
||||
|
|
@ -391,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 = {}
|
||||
|
|
@ -417,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
|
||||
)
|
||||
|
|
@ -520,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 sqlite_utils import Database as SQLiteUtilsDatabase
|
||||
from sqlite_utils import Migrations
|
||||
|
||||
from datasette.utils import table_column_details
|
||||
|
||||
INTERNAL_DB_SCHEMA_TABLES = {
|
||||
"catalog_databases",
|
||||
"catalog_tables",
|
||||
"catalog_views",
|
||||
"catalog_columns",
|
||||
"catalog_indexes",
|
||||
"catalog_foreign_keys",
|
||||
"metadata_instance",
|
||||
"metadata_databases",
|
||||
"metadata_resources",
|
||||
"metadata_columns",
|
||||
"column_types",
|
||||
"queries",
|
||||
}
|
||||
|
||||
async def init_internal_db(db):
|
||||
create_tables_sql = textwrap.dedent("""
|
||||
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,52 +88,96 @@ 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)
|
||||
);
|
||||
internal_migrations = Migrations("datasette_internal")
|
||||
|
||||
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)
|
||||
);
|
||||
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)
|
||||
|
||||
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)
|
||||
);
|
||||
"""))
|
||||
@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):
|
||||
|
|
|
|||
553
datasette/utils/sql_analysis.py
Normal file
553
datasette/utils/sql_analysis.py
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from datasette.utils.sqlite import SQLiteTableType, sqlite3, sqlite_table_type
|
||||
|
||||
SQLOperation = Literal[
|
||||
"read",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"select",
|
||||
"function",
|
||||
"create",
|
||||
"alter",
|
||||
"drop",
|
||||
"begin",
|
||||
"commit",
|
||||
"rollback",
|
||||
"savepoint",
|
||||
"attach",
|
||||
"detach",
|
||||
"pragma",
|
||||
"analyze",
|
||||
"reindex",
|
||||
"vacuum",
|
||||
"unknown",
|
||||
]
|
||||
SQLTargetType = Literal[
|
||||
"table",
|
||||
"index",
|
||||
"view",
|
||||
"trigger",
|
||||
"virtual-table",
|
||||
"schema",
|
||||
"statement",
|
||||
"transaction",
|
||||
"database",
|
||||
"pragma",
|
||||
"function",
|
||||
"unknown",
|
||||
]
|
||||
SQLTableOperation = Literal["read", "insert", "update", "delete"]
|
||||
SQLSchemaOperation = Literal["create", "drop"]
|
||||
SQLSchemaTargetType = Literal["index", "table", "trigger", "view", "virtual-table"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Operation:
|
||||
operation: SQLOperation
|
||||
target_type: SQLTargetType
|
||||
database: str | None
|
||||
table: str | None
|
||||
sqlite_schema: str | None
|
||||
table_kind: SQLiteTableType | None = None
|
||||
target: str | None = None
|
||||
columns: tuple[str, ...] = ()
|
||||
source: str | None = None
|
||||
internal: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SQLAnalysis:
|
||||
operations: tuple[Operation, ...]
|
||||
|
||||
|
||||
# Hashable dict key for grouping repeated authorizer callbacks while collecting columns.
|
||||
@dataclass(frozen=True)
|
||||
class OperationKey:
|
||||
operation: SQLOperation
|
||||
target_type: SQLTargetType
|
||||
database: str | None
|
||||
table: str | None
|
||||
sqlite_schema: str | None
|
||||
target: str | None
|
||||
source: str | None
|
||||
internal: bool
|
||||
|
||||
|
||||
_ACTION_TO_OPERATION: dict[int, SQLTableOperation] = {
|
||||
sqlite3.SQLITE_READ: "read",
|
||||
sqlite3.SQLITE_INSERT: "insert",
|
||||
sqlite3.SQLITE_UPDATE: "update",
|
||||
sqlite3.SQLITE_DELETE: "delete",
|
||||
}
|
||||
|
||||
# Values are (operation, target_type) pairs used to construct Operation objects.
|
||||
_CREATE_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
|
||||
sqlite3.SQLITE_CREATE_INDEX: ("create", "index"),
|
||||
sqlite3.SQLITE_CREATE_TABLE: ("create", "table"),
|
||||
sqlite3.SQLITE_CREATE_TRIGGER: ("create", "trigger"),
|
||||
sqlite3.SQLITE_CREATE_VIEW: ("create", "view"),
|
||||
}
|
||||
_DROP_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
|
||||
sqlite3.SQLITE_DROP_INDEX: ("drop", "index"),
|
||||
sqlite3.SQLITE_DROP_TABLE: ("drop", "table"),
|
||||
sqlite3.SQLITE_DROP_TRIGGER: ("drop", "trigger"),
|
||||
sqlite3.SQLITE_DROP_VIEW: ("drop", "view"),
|
||||
}
|
||||
|
||||
|
||||
def _add_schema_action(
|
||||
action_name: str,
|
||||
operation: SQLSchemaOperation,
|
||||
target_type: SQLSchemaTargetType,
|
||||
) -> None:
|
||||
action_value = getattr(sqlite3, action_name, None)
|
||||
if action_value is not None:
|
||||
actions = _CREATE_ACTIONS if operation == "create" else _DROP_ACTIONS
|
||||
actions[action_value] = (operation, target_type)
|
||||
|
||||
|
||||
_TEMP_SCHEMA_ACTIONS: tuple[
|
||||
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
|
||||
] = (
|
||||
("SQLITE_CREATE_TEMP_INDEX", "create", "index"),
|
||||
("SQLITE_CREATE_TEMP_TABLE", "create", "table"),
|
||||
("SQLITE_CREATE_TEMP_TRIGGER", "create", "trigger"),
|
||||
("SQLITE_CREATE_TEMP_VIEW", "create", "view"),
|
||||
("SQLITE_DROP_TEMP_INDEX", "drop", "index"),
|
||||
("SQLITE_DROP_TEMP_TABLE", "drop", "table"),
|
||||
("SQLITE_DROP_TEMP_TRIGGER", "drop", "trigger"),
|
||||
("SQLITE_DROP_TEMP_VIEW", "drop", "view"),
|
||||
)
|
||||
for schema_action in _TEMP_SCHEMA_ACTIONS:
|
||||
_add_schema_action(*schema_action)
|
||||
|
||||
_VTABLE_SCHEMA_ACTIONS: tuple[
|
||||
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
|
||||
] = (
|
||||
("SQLITE_CREATE_VTABLE", "create", "virtual-table"),
|
||||
("SQLITE_DROP_VTABLE", "drop", "virtual-table"),
|
||||
)
|
||||
for schema_action in _VTABLE_SCHEMA_ACTIONS:
|
||||
_add_schema_action(*schema_action)
|
||||
|
||||
_SQLITE_SCHEMA_TABLES = {
|
||||
"sqlite_master",
|
||||
"sqlite_schema",
|
||||
"sqlite_temp_master",
|
||||
"sqlite_temp_schema",
|
||||
}
|
||||
_SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
|
||||
"length",
|
||||
"like",
|
||||
"printf",
|
||||
"sqlite_drop_column",
|
||||
"sqlite_rename_column",
|
||||
"sqlite_rename_quotefix",
|
||||
"sqlite_rename_table",
|
||||
"sqlite_rename_test",
|
||||
"substr",
|
||||
}
|
||||
_AUTHORIZER_ACTION_NAMES = {
|
||||
getattr(sqlite3, name): name
|
||||
for name in (
|
||||
"SQLITE_CREATE_INDEX",
|
||||
"SQLITE_CREATE_TABLE",
|
||||
"SQLITE_CREATE_TEMP_INDEX",
|
||||
"SQLITE_CREATE_TEMP_TABLE",
|
||||
"SQLITE_CREATE_TEMP_TRIGGER",
|
||||
"SQLITE_CREATE_TEMP_VIEW",
|
||||
"SQLITE_CREATE_TRIGGER",
|
||||
"SQLITE_CREATE_VIEW",
|
||||
"SQLITE_DELETE",
|
||||
"SQLITE_DROP_INDEX",
|
||||
"SQLITE_DROP_TABLE",
|
||||
"SQLITE_DROP_TEMP_INDEX",
|
||||
"SQLITE_DROP_TEMP_TABLE",
|
||||
"SQLITE_DROP_TEMP_TRIGGER",
|
||||
"SQLITE_DROP_TEMP_VIEW",
|
||||
"SQLITE_DROP_TRIGGER",
|
||||
"SQLITE_DROP_VIEW",
|
||||
"SQLITE_INSERT",
|
||||
"SQLITE_PRAGMA",
|
||||
"SQLITE_READ",
|
||||
"SQLITE_SELECT",
|
||||
"SQLITE_TRANSACTION",
|
||||
"SQLITE_UPDATE",
|
||||
"SQLITE_ATTACH",
|
||||
"SQLITE_DETACH",
|
||||
"SQLITE_ALTER_TABLE",
|
||||
"SQLITE_REINDEX",
|
||||
"SQLITE_ANALYZE",
|
||||
"SQLITE_CREATE_VTABLE",
|
||||
"SQLITE_DROP_VTABLE",
|
||||
"SQLITE_FUNCTION",
|
||||
"SQLITE_SAVEPOINT",
|
||||
"SQLITE_RECURSIVE",
|
||||
)
|
||||
if hasattr(sqlite3, name)
|
||||
}
|
||||
|
||||
|
||||
def _allow_authorizer_action(*args):
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
|
||||
def analyze_sql_tables(
|
||||
conn,
|
||||
sql: str,
|
||||
params=None,
|
||||
*,
|
||||
database_name: str | None = None,
|
||||
schema_to_database: dict[str, str] | None = None,
|
||||
) -> SQLAnalysis:
|
||||
"""
|
||||
Return operations performed by a SQL statement according to SQLite's authorizer.
|
||||
|
||||
This function is synchronous and connection-based. It temporarily installs a
|
||||
SQLite authorizer, prepares ``EXPLAIN <sql>``, and returns the operation
|
||||
callbacks observed while SQLite compiles the statement.
|
||||
"""
|
||||
operations: dict[OperationKey, set[str]] = {}
|
||||
|
||||
def database_for_schema(sqlite_schema):
|
||||
if schema_to_database and sqlite_schema in schema_to_database:
|
||||
return schema_to_database[sqlite_schema]
|
||||
if sqlite_schema == "main" and database_name is not None:
|
||||
return database_name
|
||||
return sqlite_schema
|
||||
|
||||
def record(
|
||||
operation: SQLOperation,
|
||||
target_type: SQLTargetType,
|
||||
*,
|
||||
database: str | None,
|
||||
table: str | None,
|
||||
sqlite_schema: str | None,
|
||||
target: str | None,
|
||||
source: str | None,
|
||||
column: str | None = None,
|
||||
internal: bool = False,
|
||||
):
|
||||
key = OperationKey(
|
||||
operation=operation,
|
||||
target_type=target_type,
|
||||
database=database,
|
||||
table=table,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=target,
|
||||
source=source,
|
||||
internal=internal,
|
||||
)
|
||||
columns = operations.setdefault(key, set())
|
||||
if column is not None:
|
||||
columns.add(column)
|
||||
|
||||
def authorizer(action, arg1, arg2, sqlite_schema, source):
|
||||
operation = _ACTION_TO_OPERATION.get(action)
|
||||
if operation is not None and arg1 is not None:
|
||||
target_type = "schema" if arg1 in _SQLITE_SCHEMA_TABLES else "table"
|
||||
column = (
|
||||
arg2 if operation in ("read", "update") and arg2 is not None else None
|
||||
)
|
||||
record(
|
||||
operation,
|
||||
target_type,
|
||||
database=database_for_schema(sqlite_schema),
|
||||
table=arg1 if target_type == "table" else None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=arg1,
|
||||
source=source,
|
||||
column=column,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
create_operation = _CREATE_ACTIONS.get(action)
|
||||
if create_operation is not None and arg1 is not None:
|
||||
operation, target_type = create_operation
|
||||
related_table = arg2 if target_type in {"index", "trigger"} else arg1
|
||||
record(
|
||||
operation,
|
||||
target_type,
|
||||
database=database_for_schema(sqlite_schema),
|
||||
table=related_table,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
drop_operation = _DROP_ACTIONS.get(action)
|
||||
if drop_operation is not None and arg1 is not None:
|
||||
operation, target_type = drop_operation
|
||||
related_table = arg2 if target_type in {"index", "trigger"} else arg1
|
||||
record(
|
||||
operation,
|
||||
target_type,
|
||||
database=database_for_schema(sqlite_schema),
|
||||
table=related_table,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_ALTER_TABLE and arg2 is not None:
|
||||
record(
|
||||
"alter",
|
||||
"table",
|
||||
database=database_for_schema(arg1),
|
||||
table=arg2,
|
||||
sqlite_schema=arg1,
|
||||
target=arg2,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_TRANSACTION and arg1 is not None:
|
||||
record(
|
||||
arg1.lower(),
|
||||
"transaction",
|
||||
database=None,
|
||||
table=None,
|
||||
sqlite_schema=None,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_ATTACH and arg1 is not None:
|
||||
record(
|
||||
"attach",
|
||||
"database",
|
||||
database=None,
|
||||
table=None,
|
||||
sqlite_schema=None,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_DETACH and arg1 is not None:
|
||||
record(
|
||||
"detach",
|
||||
"database",
|
||||
database=None,
|
||||
table=None,
|
||||
sqlite_schema=None,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_PRAGMA and arg1 is not None:
|
||||
record(
|
||||
"pragma",
|
||||
"pragma",
|
||||
database=None,
|
||||
table=None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_ANALYZE:
|
||||
record(
|
||||
"analyze",
|
||||
"database" if arg1 is None else "table",
|
||||
database=database_for_schema(sqlite_schema),
|
||||
table=arg1,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_REINDEX and arg1 is not None:
|
||||
record(
|
||||
"reindex",
|
||||
"index",
|
||||
database=database_for_schema(sqlite_schema),
|
||||
table=None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_SELECT:
|
||||
record(
|
||||
"select",
|
||||
"statement",
|
||||
database=None,
|
||||
table=None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=None,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_RECURSIVE:
|
||||
# Recursive CTE bookkeeping; table reads are reported separately.
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_FUNCTION and arg2 is not None:
|
||||
record(
|
||||
"function",
|
||||
"function",
|
||||
database=None,
|
||||
table=None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=arg2,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
if action == sqlite3.SQLITE_SAVEPOINT and arg1 is not None:
|
||||
record(
|
||||
"savepoint",
|
||||
"transaction",
|
||||
database=None,
|
||||
table=None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
|
||||
record(
|
||||
"unknown",
|
||||
"unknown",
|
||||
database=database_for_schema(sqlite_schema),
|
||||
table=None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=action_name,
|
||||
source=source,
|
||||
)
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
table_kind_cache: dict[tuple[str | None, str], SQLiteTableType | None] = {}
|
||||
|
||||
conn.set_authorizer(authorizer)
|
||||
try:
|
||||
explain_rows = conn.execute(
|
||||
"EXPLAIN " + sql, params if params is not None else {}
|
||||
).fetchall()
|
||||
# Passing None before these lookups leaves a failing callback installed
|
||||
# on Python 3.10, so use a permissive callback until they are complete.
|
||||
conn.set_authorizer(_allow_authorizer_action)
|
||||
|
||||
if not operations:
|
||||
vacuum_row = next((row for row in explain_rows if row[1] == "Vacuum"), None)
|
||||
if vacuum_row is not None:
|
||||
schema_by_index = {
|
||||
row[0]: row[1] for row in conn.execute("PRAGMA database_list")
|
||||
}
|
||||
sqlite_schema = schema_by_index.get(vacuum_row[2])
|
||||
database = database_for_schema(sqlite_schema)
|
||||
record(
|
||||
"vacuum",
|
||||
"database",
|
||||
database=database,
|
||||
table=None,
|
||||
sqlite_schema=sqlite_schema,
|
||||
target=database,
|
||||
source=None,
|
||||
)
|
||||
else:
|
||||
record(
|
||||
"unknown",
|
||||
"statement",
|
||||
database=database_name,
|
||||
table=None,
|
||||
sqlite_schema=None,
|
||||
target=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
for key in operations:
|
||||
if (
|
||||
key.target_type == "table"
|
||||
and key.operation in {"read", "insert", "update", "delete"}
|
||||
and key.table is not None
|
||||
):
|
||||
cache_key = (key.sqlite_schema, key.table)
|
||||
if cache_key not in table_kind_cache:
|
||||
table_kind_cache[cache_key] = sqlite_table_type(
|
||||
conn, key.table, schema=key.sqlite_schema
|
||||
)
|
||||
finally:
|
||||
conn.set_authorizer(None)
|
||||
|
||||
has_schema_operation = any(
|
||||
key.target_type in {"table", "index", "view", "trigger", "virtual-table"}
|
||||
and key.operation in {"create", "alter", "drop"}
|
||||
for key in operations
|
||||
)
|
||||
dropped_tables_and_views = {
|
||||
(key.database, key.table)
|
||||
for key in operations
|
||||
if key.operation == "drop" and key.target_type in {"table", "view"}
|
||||
}
|
||||
|
||||
def key_is_drop_table_delete(key: OperationKey) -> bool:
|
||||
return (
|
||||
key.operation == "delete"
|
||||
and key.target_type == "table"
|
||||
and (key.database, key.table) in dropped_tables_and_views
|
||||
)
|
||||
|
||||
has_user_table_access_in_schema_operation = any(
|
||||
key.operation in {"read", "insert", "update", "delete"}
|
||||
and key.target_type == "table"
|
||||
and not key.internal
|
||||
and not key_is_drop_table_delete(key)
|
||||
for key in operations
|
||||
)
|
||||
|
||||
def operation_is_internal(key: OperationKey) -> bool:
|
||||
if key.internal or (has_schema_operation and key.target_type == "schema"):
|
||||
return True
|
||||
if has_schema_operation and key.operation == "reindex":
|
||||
return True
|
||||
if (
|
||||
has_schema_operation
|
||||
and not has_user_table_access_in_schema_operation
|
||||
and key.operation == "function"
|
||||
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
|
||||
):
|
||||
return True
|
||||
if key_is_drop_table_delete(key):
|
||||
return True
|
||||
return False
|
||||
|
||||
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
|
||||
if (
|
||||
key.target_type != "table"
|
||||
or key.operation not in {"read", "insert", "update", "delete"}
|
||||
or key.table is None
|
||||
):
|
||||
return None
|
||||
return table_kind_cache[(key.sqlite_schema, key.table)]
|
||||
|
||||
return SQLAnalysis(
|
||||
operations=tuple(
|
||||
Operation(
|
||||
operation=key.operation,
|
||||
target_type=key.target_type,
|
||||
database=key.database,
|
||||
table=key.table,
|
||||
sqlite_schema=key.sqlite_schema,
|
||||
table_kind=table_kind_for(key),
|
||||
target=key.target,
|
||||
columns=tuple(sorted(columns)),
|
||||
source=key.source,
|
||||
internal=operation_is_internal(key),
|
||||
)
|
||||
for key, columns in operations.items()
|
||||
)
|
||||
)
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import re
|
||||
from typing import Literal
|
||||
|
||||
using_pysqlite3 = False
|
||||
try:
|
||||
import pysqlite3 as sqlite3
|
||||
|
|
@ -10,6 +13,19 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
|
|||
sqlite3.enable_callback_tracebacks(True)
|
||||
|
||||
_cached_sqlite_version = None
|
||||
_cached_supports_returning = None
|
||||
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
|
||||
_VIRTUAL_TABLE_MODULE_RE = re.compile(
|
||||
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
|
||||
"fts3": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
||||
"fts4": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
||||
"fts5": ("_data", "_idx", "_docsize", "_content", "_config"),
|
||||
"rtree": ("_node", "_parent", "_rowid"),
|
||||
"rtree_i32": ("_node", "_parent", "_rowid"),
|
||||
}
|
||||
|
||||
|
||||
def sqlite_version():
|
||||
|
|
@ -36,5 +52,146 @@ def supports_table_xinfo():
|
|||
return sqlite_version() >= (3, 26, 0)
|
||||
|
||||
|
||||
def supports_table_list():
|
||||
return sqlite_version() >= (3, 37, 0)
|
||||
|
||||
|
||||
def supports_generated_columns():
|
||||
return sqlite_version() >= (3, 31, 0)
|
||||
|
||||
|
||||
def supports_returning():
|
||||
global _cached_supports_returning
|
||||
if _cached_supports_returning is None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute("create table t (id integer primary key)")
|
||||
conn.execute("insert into t default values returning id").fetchone()
|
||||
_cached_supports_returning = True
|
||||
except sqlite3.DatabaseError:
|
||||
_cached_supports_returning = False
|
||||
finally:
|
||||
conn.close()
|
||||
return _cached_supports_returning
|
||||
|
||||
|
||||
def sqlite_table_type(
|
||||
conn,
|
||||
table: str,
|
||||
*,
|
||||
schema: str | None = "main",
|
||||
) -> SQLiteTableType | None:
|
||||
if supports_table_list():
|
||||
try:
|
||||
query = "select type from pragma_table_list where name = ?"
|
||||
params: tuple[str, ...] = (table,)
|
||||
if schema is not None:
|
||||
query += " and schema = ?"
|
||||
params = (table, schema)
|
||||
row = conn.execute(query, params).fetchone()
|
||||
if row is not None and row[0] in {"table", "view", "virtual", "shadow"}:
|
||||
return row[0]
|
||||
except sqlite3.DatabaseError:
|
||||
pass
|
||||
return _sqlite_table_type_from_schema(conn, table, schema=schema)
|
||||
|
||||
|
||||
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
|
||||
schema_table = _sqlite_schema_table(schema)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
||||
).fetchall()
|
||||
except sqlite3.DatabaseError:
|
||||
return []
|
||||
hidden_tables = []
|
||||
content_fts_tables = []
|
||||
for name, sql in rows:
|
||||
if (
|
||||
name in {"sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"}
|
||||
or name.startswith("_")
|
||||
or sqlite_table_type(conn, name, schema=schema) == "shadow"
|
||||
):
|
||||
hidden_tables.append(name)
|
||||
elif _is_fts_content_virtual_table(sql):
|
||||
content_fts_tables.append(name)
|
||||
return sorted(hidden_tables) + content_fts_tables
|
||||
|
||||
|
||||
def _sqlite_table_type_from_schema(
|
||||
conn,
|
||||
table: str,
|
||||
*,
|
||||
schema: str | None = "main",
|
||||
) -> SQLiteTableType | None:
|
||||
schema_table = _sqlite_schema_table(schema)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"select type, sql from {} where name = ?".format(schema_table),
|
||||
(table,),
|
||||
).fetchone()
|
||||
except sqlite3.DatabaseError:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
object_type, sql = row
|
||||
if object_type == "view":
|
||||
return "view"
|
||||
if object_type != "table":
|
||||
return None
|
||||
if _virtual_table_module(sql) is not None:
|
||||
return "virtual"
|
||||
if _is_known_shadow_table(conn, table, schema=schema):
|
||||
return "shadow"
|
||||
return "table"
|
||||
|
||||
|
||||
def _is_known_shadow_table(
|
||||
conn,
|
||||
table: str,
|
||||
*,
|
||||
schema: str | None = "main",
|
||||
) -> bool:
|
||||
schema_table = _sqlite_schema_table(schema)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
||||
).fetchall()
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
for virtual_table, sql in rows:
|
||||
module = _virtual_table_module(sql)
|
||||
if module is None:
|
||||
continue
|
||||
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
|
||||
if table == virtual_table + suffix:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sqlite_schema_table(schema: str | None) -> str:
|
||||
if schema is None or schema == "main":
|
||||
return "sqlite_master"
|
||||
if schema == "temp":
|
||||
return "sqlite_temp_master"
|
||||
return "{}.sqlite_master".format(_quote_identifier(schema))
|
||||
|
||||
|
||||
def _quote_identifier(value: str) -> str:
|
||||
return '"{}"'.format(value.replace('"', '""'))
|
||||
|
||||
|
||||
def _virtual_table_module(sql: str | None) -> str | None:
|
||||
if not sql:
|
||||
return None
|
||||
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
|
||||
if match is None:
|
||||
return None
|
||||
return match.group(1).strip("\"'[]`").lower()
|
||||
|
||||
|
||||
def _is_fts_content_virtual_table(sql: str | None) -> bool:
|
||||
return (
|
||||
_virtual_table_module(sql) in {"fts3", "fts4", "fts5"}
|
||||
and "content=" in sql.lower()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
__version__ = "1.0a28"
|
||||
__version__ = "1.0a36"
|
||||
__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
|
||||
|
|
@ -153,7 +140,13 @@ class BaseView:
|
|||
if self.has_json_alternate:
|
||||
alternate_url_json = self.ds.absolute_url(
|
||||
request,
|
||||
self.ds.urls.path(path_with_format(request=request, format="json")),
|
||||
self.ds.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format="json",
|
||||
)
|
||||
),
|
||||
)
|
||||
template_context["alternate_url_json"] = alternate_url_json
|
||||
headers.update(
|
||||
|
|
@ -186,223 +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, format=key, extra_qs={**url_labels_extra}
|
||||
)
|
||||
)
|
||||
|
||||
url_csv_args = {"_size": "max", **url_labels_extra}
|
||||
url_csv = self.ds.urls.path(
|
||||
path_with_format(request=request, format="csv", extra_qs=url_csv_args)
|
||||
)
|
||||
url_csv_path = url_csv.split("?")[0]
|
||||
context = {
|
||||
**data,
|
||||
**extras,
|
||||
**{
|
||||
"renderers": renderers,
|
||||
"url_csv": url_csv,
|
||||
"url_csv_path": url_csv_path,
|
||||
"url_csv_hidden_args": [
|
||||
(key, value)
|
||||
for key, value in urllib.parse.parse_qsl(request.query_string)
|
||||
if key not in ("_labels", "_facet", "_size")
|
||||
]
|
||||
+ [("_size", "max")],
|
||||
"settings": self.ds.settings_dict(),
|
||||
},
|
||||
}
|
||||
if "metadata" not in context:
|
||||
context["metadata"] = await self.ds.get_instance_metadata()
|
||||
r = await self.render(templates, request=request, context=context)
|
||||
if status_code is not None:
|
||||
r.status = status_code
|
||||
|
||||
ttl = request.args.get("_ttl", None)
|
||||
if ttl is None or not ttl.isdigit():
|
||||
ttl = self.ds.setting("default_cache_ttl")
|
||||
|
||||
return self.set_response_headers(r, ttl)
|
||||
|
||||
def set_response_headers(self, response, ttl):
|
||||
# Set far-future cache expiry
|
||||
if self.ds.cache_headers and response.status == 200:
|
||||
ttl = int(ttl)
|
||||
if ttl == 0:
|
||||
ttl_header = "no-cache"
|
||||
else:
|
||||
ttl_header = f"max-age={ttl}"
|
||||
response.headers["Cache-Control"] = ttl_header
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
if self.ds.cors:
|
||||
add_cors_headers(response.headers)
|
||||
return response
|
||||
|
||||
|
||||
def _error(messages, status=400):
|
||||
return Response.json({"ok": False, "errors": messages}, status=status)
|
||||
|
||||
|
||||
async def stream_csv(datasette, fetch_data, request, database):
|
||||
kwargs = {}
|
||||
stream = request.args.get("_stream")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
505
datasette/views/execute_write.py
Normal file
505
datasette/views/execute_write.py
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
import re
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
from .base import BaseView
|
||||
from .database import display_rows as display_query_rows
|
||||
from .query_helpers import (
|
||||
QueryValidationError,
|
||||
SQL_PARAMETER_FORM_PREFIX,
|
||||
_analysis_is_write,
|
||||
_analysis_rows,
|
||||
_analysis_rows_with_permissions,
|
||||
_block_framing,
|
||||
_coerce_execute_write_payload,
|
||||
_derived_query_parameters,
|
||||
_execute_write_analysis_data,
|
||||
_execute_write_disabled_reason,
|
||||
_inserted_row_url,
|
||||
_json_or_form_payload,
|
||||
_prepare_execute_write,
|
||||
_table_columns,
|
||||
_wants_json,
|
||||
)
|
||||
|
||||
WRITE_TEMPLATE_LABELS = {
|
||||
"insert": "Insert row",
|
||||
"update": "Update rows",
|
||||
"delete": "Delete rows",
|
||||
}
|
||||
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
|
||||
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
|
||||
(
|
||||
"create table new_table (",
|
||||
" id integer primary key,",
|
||||
" name text",
|
||||
" -- created text default (datetime('now'))",
|
||||
")",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _parameter_names(columns):
|
||||
seen = set()
|
||||
names = {}
|
||||
for column in columns:
|
||||
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
||||
base = base.strip("_") or "value"
|
||||
if base[0].isdigit():
|
||||
base = "p_{}".format(base)
|
||||
name = base
|
||||
index = 2
|
||||
while name in seen:
|
||||
name = "{}_{}".format(base, index)
|
||||
index += 1
|
||||
seen.add(name)
|
||||
names[column] = name
|
||||
return names
|
||||
|
||||
|
||||
def _quote_identifier(identifier):
|
||||
return '"{}"'.format(identifier.replace('"', '""'))
|
||||
|
||||
|
||||
def _preferred_where_column(table, columns):
|
||||
lower_table_id = "{}_id".format(table.lower())
|
||||
return (
|
||||
next((column for column in columns if column.lower() == "id"), None)
|
||||
or next(
|
||||
(column for column in columns if column.lower() == lower_table_id), None
|
||||
)
|
||||
or columns[0]
|
||||
)
|
||||
|
||||
|
||||
def _auto_incrementing_primary_key(columns):
|
||||
primary_keys = [column for column in columns if column.is_pk]
|
||||
if len(primary_keys) != 1:
|
||||
return None
|
||||
primary_key = primary_keys[0]
|
||||
if primary_key.type and primary_key.type.lower() == "integer":
|
||||
return primary_key.name
|
||||
return None
|
||||
|
||||
|
||||
def _insert_template_sql(table, columns):
|
||||
column_names = [column.name for column in columns]
|
||||
auto_pk = _auto_incrementing_primary_key(columns)
|
||||
insert_columns = [column for column in column_names if column != auto_pk]
|
||||
if not insert_columns:
|
||||
return "insert into {}\ndefault values".format(_quote_identifier(table))
|
||||
names = _parameter_names(insert_columns)
|
||||
return "\n".join(
|
||||
(
|
||||
"insert into {} (".format(_quote_identifier(table)),
|
||||
",\n".join(
|
||||
" {}".format(_quote_identifier(column)) for column in insert_columns
|
||||
),
|
||||
")",
|
||||
"values (",
|
||||
",\n".join(" :{}".format(names[column]) for column in insert_columns),
|
||||
")",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _update_template_sql(table, columns):
|
||||
column_names = [column.name for column in columns]
|
||||
names = _parameter_names(column_names)
|
||||
where_column = _preferred_where_column(table, column_names)
|
||||
set_columns = [column for column in column_names if column != where_column]
|
||||
if not set_columns:
|
||||
return "\n".join(
|
||||
(
|
||||
"update {}".format(_quote_identifier(table)),
|
||||
"set {} = :new_{}".format(
|
||||
_quote_identifier(where_column), names[where_column]
|
||||
),
|
||||
"where {} = :{}".format(
|
||||
_quote_identifier(where_column), names[where_column]
|
||||
),
|
||||
)
|
||||
)
|
||||
return "\n".join(
|
||||
(
|
||||
"update {}".format(_quote_identifier(table)),
|
||||
"set "
|
||||
+ ",\n".join(
|
||||
"{}{} = :{}".format(
|
||||
" " if index else "",
|
||||
_quote_identifier(column),
|
||||
names[column],
|
||||
)
|
||||
for index, column in enumerate(set_columns)
|
||||
),
|
||||
"where {} = :{}".format(
|
||||
_quote_identifier(where_column), names[where_column]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _delete_template_sql(table, columns):
|
||||
column_names = [column.name for column in columns]
|
||||
names = _parameter_names(column_names)
|
||||
where_column = _preferred_where_column(table, column_names)
|
||||
return "\n".join(
|
||||
(
|
||||
"delete from {}".format(_quote_identifier(table)),
|
||||
"where {} = :{}".format(
|
||||
_quote_identifier(where_column), names[where_column]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _template_sqls_for_table(table, columns):
|
||||
return {
|
||||
"insert": _insert_template_sql(table, columns),
|
||||
"update": _update_template_sql(table, columns),
|
||||
"delete": _delete_template_sql(table, columns),
|
||||
}
|
||||
|
||||
|
||||
async def _template_sql_allowed(datasette, db, sql, actor):
|
||||
params = {parameter: "" for parameter in _derived_query_parameters(sql)}
|
||||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
if not _analysis_is_write(analysis):
|
||||
return False
|
||||
analysis_rows = await _analysis_rows_with_permissions(datasette, analysis, actor)
|
||||
return _execute_write_disabled_reason(sql, None, analysis_rows) is None
|
||||
|
||||
|
||||
async def _write_template_tables(
|
||||
datasette, db, table_columns, hidden_table_names, actor
|
||||
):
|
||||
write_template_tables = {}
|
||||
for table in table_columns:
|
||||
if table in hidden_table_names or not table_columns[table]:
|
||||
continue
|
||||
column_details = [
|
||||
column
|
||||
for column in await db.table_column_details(table)
|
||||
if not column.hidden
|
||||
]
|
||||
if not column_details:
|
||||
continue
|
||||
templates = {}
|
||||
for operation, sql in _template_sqls_for_table(table, column_details).items():
|
||||
if await _template_sql_allowed(datasette, db, sql, actor):
|
||||
templates[operation] = sql
|
||||
if templates:
|
||||
write_template_tables[table] = {
|
||||
"templates": templates,
|
||||
}
|
||||
return write_template_tables
|
||||
|
||||
|
||||
def _write_template_operations(write_template_tables):
|
||||
operations = []
|
||||
for operation in WRITE_TEMPLATE_OPERATIONS:
|
||||
if any(
|
||||
operation in table["templates"] for table in write_template_tables.values()
|
||||
):
|
||||
operations.append(
|
||||
{
|
||||
"name": operation,
|
||||
"label": WRITE_TEMPLATE_LABELS[operation],
|
||||
}
|
||||
)
|
||||
return operations
|
||||
|
||||
|
||||
async def _create_table_template_sql(datasette, db, actor):
|
||||
if await datasette.allowed(
|
||||
action="create-table",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=actor,
|
||||
):
|
||||
return CREATE_TABLE_TEMPLATE_SQL
|
||||
return None
|
||||
|
||||
|
||||
def _analysis_changes_schema(analysis):
|
||||
return any(
|
||||
operation.operation in {"create", "alter", "drop"}
|
||||
for operation in analysis.operations
|
||||
)
|
||||
|
||||
|
||||
class ExecuteWriteView(BaseView):
|
||||
name = "execute-write"
|
||||
has_json_alternate = False
|
||||
|
||||
async def _render_form(
|
||||
self,
|
||||
request,
|
||||
db,
|
||||
*,
|
||||
sql="",
|
||||
parameter_values=None,
|
||||
analysis=None,
|
||||
analysis_error=None,
|
||||
execution_message=None,
|
||||
execution_links=None,
|
||||
execution_ok=None,
|
||||
execute_write_returns_rows=False,
|
||||
execute_write_columns=None,
|
||||
execute_write_display_rows=None,
|
||||
execute_write_truncated=False,
|
||||
status=200,
|
||||
):
|
||||
parameter_values = parameter_values or {}
|
||||
execution_links = execution_links or []
|
||||
execute_write_columns = execute_write_columns or []
|
||||
execute_write_display_rows = execute_write_display_rows or []
|
||||
parameter_names = []
|
||||
analysis_rows = []
|
||||
table_columns = await _table_columns(self.ds, db.name)
|
||||
hidden_table_names = set(await db.hidden_table_names())
|
||||
write_template_tables = await _write_template_tables(
|
||||
self.ds, db, table_columns, hidden_table_names, request.actor
|
||||
)
|
||||
write_template_operations = _write_template_operations(write_template_tables)
|
||||
write_create_table_template_sql = await _create_table_template_sql(
|
||||
self.ds, db, request.actor
|
||||
)
|
||||
if sql and analysis_error is None:
|
||||
try:
|
||||
parameter_names = _derived_query_parameters(sql)
|
||||
if analysis is None:
|
||||
params = {parameter: "" for parameter in parameter_names}
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
if _analysis_is_write(analysis):
|
||||
analysis_rows = await _analysis_rows_with_permissions(
|
||||
self.ds, analysis, request.actor
|
||||
)
|
||||
else:
|
||||
analysis_error = (
|
||||
"Use /-/query for read-only SQL; "
|
||||
"this endpoint only executes writes"
|
||||
)
|
||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
||||
analysis_error = getattr(ex, "message", str(ex))
|
||||
|
||||
allow_save_query = await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
) and await self.ds.allowed(
|
||||
action="store-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
)
|
||||
save_query_base_url = None
|
||||
save_query_url = None
|
||||
execute_disabled_reason = _execute_write_disabled_reason(
|
||||
sql, analysis_error, analysis_rows
|
||||
)
|
||||
if allow_save_query:
|
||||
save_query_base_url = self.ds.urls.database(db.name) + "/-/queries/store"
|
||||
if not execute_disabled_reason:
|
||||
save_query_url = save_query_base_url + "?" + urlencode({"sql": sql})
|
||||
|
||||
response = await self.render(
|
||||
["execute_write.html"],
|
||||
request,
|
||||
{
|
||||
"database": db.name,
|
||||
"database_color": db.color,
|
||||
"sql": sql,
|
||||
"parameter_names": parameter_names,
|
||||
"parameter_values": parameter_values,
|
||||
"analysis_error": analysis_error,
|
||||
"analysis_rows": analysis_rows,
|
||||
"execution_message": execution_message,
|
||||
"execution_links": execution_links,
|
||||
"execution_ok": execution_ok,
|
||||
"execute_write_returns_rows": execute_write_returns_rows,
|
||||
"execute_write_columns": execute_write_columns,
|
||||
"execute_write_display_rows": execute_write_display_rows,
|
||||
"execute_write_truncated": execute_write_truncated,
|
||||
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
|
||||
"execute_disabled": bool(execute_disabled_reason),
|
||||
"execute_disabled_reason": execute_disabled_reason,
|
||||
"table_columns": table_columns,
|
||||
"write_template_tables": write_template_tables,
|
||||
"write_template_operations": write_template_operations,
|
||||
"write_create_table_template_sql": write_create_table_template_sql,
|
||||
"save_query_url": save_query_url,
|
||||
"save_query_base_url": save_query_base_url,
|
||||
},
|
||||
)
|
||||
response.status = status
|
||||
return _block_framing(response)
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
await self.ds.ensure_permission(
|
||||
action="execute-write-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
)
|
||||
if not db.is_mutable:
|
||||
return _block_framing(
|
||||
Response.error(
|
||||
["Cannot execute write SQL because this database is immutable."],
|
||||
403,
|
||||
)
|
||||
)
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
sql=request.args.get("sql") or "",
|
||||
)
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
action="execute-write-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(
|
||||
Response.error(["Permission denied: need execute-write-sql"], 403)
|
||||
)
|
||||
if not db.is_mutable:
|
||||
return _block_framing(Response.error(["Database is immutable"], 403))
|
||||
|
||||
data = {}
|
||||
is_json = request.headers.get("content-type", "").startswith("application/json")
|
||||
sql = ""
|
||||
provided_params = {}
|
||||
try:
|
||||
data, is_json = await _json_or_form_payload(request)
|
||||
sql, provided_params = _coerce_execute_write_payload(data, is_json)
|
||||
parameter_names, params, analysis = await _prepare_execute_write(
|
||||
self.ds, db, sql, provided_params, request.actor
|
||||
)
|
||||
except QueryValidationError as ex:
|
||||
if _wants_json(request, is_json, data):
|
||||
return _block_framing(Response.error([ex.message], ex.status))
|
||||
if ex.flash:
|
||||
self.ds.add_message(request, ex.message, self.ds.ERROR)
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
sql=sql or "",
|
||||
parameter_values=provided_params,
|
||||
analysis_error=None if ex.flash else ex.message,
|
||||
execution_message=None if ex.flash else ex.message,
|
||||
execution_ok=False,
|
||||
status=ex.status,
|
||||
)
|
||||
|
||||
wants_json = _wants_json(request, is_json, data)
|
||||
try:
|
||||
execute_write_kwargs = {"request": request}
|
||||
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
|
||||
except sqlite3.DatabaseError as ex:
|
||||
message = str(ex)
|
||||
if wants_json:
|
||||
return _block_framing(Response.error([message], 400))
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
sql=sql,
|
||||
parameter_values=params,
|
||||
analysis=analysis,
|
||||
execution_message=message,
|
||||
execution_ok=False,
|
||||
status=400,
|
||||
)
|
||||
|
||||
if _analysis_changes_schema(analysis):
|
||||
await self.ds.refresh_schemas(force=True)
|
||||
|
||||
if cursor.rowcount == -1:
|
||||
message = "Query executed"
|
||||
else:
|
||||
message = "Query executed, {} row{} affected".format(
|
||||
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
||||
)
|
||||
if wants_json:
|
||||
data = {
|
||||
"ok": True,
|
||||
"message": message,
|
||||
"rowcount": cursor.rowcount,
|
||||
"rows": [],
|
||||
"truncated": False,
|
||||
"analysis": _analysis_rows(analysis),
|
||||
}
|
||||
if cursor.description is not None:
|
||||
data["rows"] = [dict(row) for row in cursor.fetchall()]
|
||||
data["truncated"] = cursor.truncated
|
||||
return _block_framing(Response.json(data))
|
||||
|
||||
inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor)
|
||||
execution_links = (
|
||||
[{"href": inserted_row_url, "label": "View row"}]
|
||||
if inserted_row_url
|
||||
else []
|
||||
)
|
||||
execute_write_returns_rows = cursor.description is not None
|
||||
execute_write_columns = []
|
||||
execute_write_display_rows = []
|
||||
if execute_write_returns_rows:
|
||||
execute_write_columns = [
|
||||
description[0] for description in cursor.description
|
||||
]
|
||||
execute_write_display_rows = await display_query_rows(
|
||||
self.ds,
|
||||
db.name,
|
||||
request,
|
||||
cursor.fetchall(),
|
||||
execute_write_columns,
|
||||
)
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
sql=sql,
|
||||
parameter_values={name: params.get(name, "") for name in parameter_names},
|
||||
analysis=analysis,
|
||||
execution_message=message,
|
||||
execution_links=execution_links,
|
||||
execution_ok=True,
|
||||
execute_write_returns_rows=execute_write_returns_rows,
|
||||
execute_write_columns=execute_write_columns,
|
||||
execute_write_display_rows=execute_write_display_rows,
|
||||
execute_write_truncated=cursor.truncated,
|
||||
)
|
||||
|
||||
|
||||
class ExecuteWriteAnalyzeView(BaseView):
|
||||
name = "execute-write-analyze"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
action="execute-write-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(
|
||||
Response.error(["Permission denied: need execute-write-sql"], 403)
|
||||
)
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
Response.error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
||||
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
||||
return _block_framing(Response.json(analysis))
|
||||
|
|
@ -6,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,
|
||||
|
|
|
|||
636
datasette/views/query_helpers.py
Normal file
636
datasette/views/query_helpers.py
Normal file
|
|
@ -0,0 +1,636 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.stored_queries import (
|
||||
StoredQuery,
|
||||
)
|
||||
from datasette.write_sql import (
|
||||
IgnoreWriteSqlOperation,
|
||||
QueryWriteRejected,
|
||||
RequireWriteSqlPermissions,
|
||||
decision_for_write_sql_operation,
|
||||
operation_is_write,
|
||||
)
|
||||
from datasette.utils import (
|
||||
parse_size_limit,
|
||||
named_parameters as derive_named_parameters,
|
||||
escape_sqlite,
|
||||
path_from_row_pks,
|
||||
sqlite3,
|
||||
validate_sql_select,
|
||||
InvalidSql,
|
||||
)
|
||||
from datasette.utils.asgi import Forbidden
|
||||
from datasette.utils.sql_analysis import Operation, SQLAnalysis
|
||||
|
||||
_query_name_re = re.compile(r"^[^/\.\n]+$")
|
||||
|
||||
_query_fields = {
|
||||
"sql",
|
||||
"title",
|
||||
"description",
|
||||
"hide_sql",
|
||||
"fragment",
|
||||
"parameters",
|
||||
"is_private",
|
||||
"on_success_message",
|
||||
"on_success_redirect",
|
||||
"on_error_message",
|
||||
"on_error_redirect",
|
||||
}
|
||||
|
||||
_query_create_fields = _query_fields | {"name", "mode", "csrftoken"}
|
||||
_query_update_fields = _query_fields
|
||||
_query_write_fields = {
|
||||
"on_success_message",
|
||||
"on_success_redirect",
|
||||
"on_error_message",
|
||||
"on_error_redirect",
|
||||
}
|
||||
|
||||
SQL_PARAMETER_FORM_PREFIX = "_sql_param_"
|
||||
|
||||
|
||||
class QueryValidationError(Exception):
|
||||
def __init__(self, message, status=400, *, flash=False):
|
||||
self.message = message
|
||||
self.status = status
|
||||
self.flash = flash
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _actor_id(actor):
|
||||
if isinstance(actor, dict):
|
||||
return actor.get("id")
|
||||
return None
|
||||
|
||||
|
||||
def _as_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, int):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"1", "true", "t", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _as_optional_bool(value, name):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.lower()
|
||||
if lowered in {"1", "true", "t", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "f", "no", "off"}:
|
||||
return False
|
||||
raise QueryValidationError("{} must be 0 or 1".format(name))
|
||||
|
||||
|
||||
def _query_list_limit(value, default, maximum):
|
||||
try:
|
||||
return parse_size_limit(value, default, maximum)
|
||||
except ValueError as ex:
|
||||
raise QueryValidationError(str(ex)) from ex
|
||||
|
||||
|
||||
def _derived_query_parameters(sql):
|
||||
parameters = []
|
||||
seen = set()
|
||||
for parameter in derive_named_parameters(sql):
|
||||
if parameter.startswith("_"):
|
||||
raise QueryValidationError("Magic parameters are not allowed")
|
||||
if parameter not in seen:
|
||||
parameters.append(parameter)
|
||||
seen.add(parameter)
|
||||
return parameters
|
||||
|
||||
|
||||
def _coerce_query_parameters(value, derived):
|
||||
if value is None:
|
||||
return derived
|
||||
if isinstance(value, str):
|
||||
parameters = [
|
||||
parameter.strip()
|
||||
for parameter in re.split(r"[\s,]+", value)
|
||||
if parameter.strip()
|
||||
]
|
||||
elif isinstance(value, list):
|
||||
parameters = value
|
||||
else:
|
||||
raise QueryValidationError("parameters must be a list of strings")
|
||||
if not all(isinstance(parameter, str) for parameter in parameters):
|
||||
raise QueryValidationError("parameters must be a list of strings")
|
||||
if any(parameter.startswith("_") for parameter in parameters):
|
||||
raise QueryValidationError("Magic parameters are not allowed")
|
||||
if set(parameters) != set(derived):
|
||||
raise QueryValidationError("parameters must match SQL named parameters")
|
||||
return parameters
|
||||
|
||||
|
||||
def _analysis_is_write(analysis: SQLAnalysis) -> bool:
|
||||
return any(operation_is_write(operation) for operation in analysis.operations)
|
||||
|
||||
|
||||
def _block_framing(response):
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
return response
|
||||
|
||||
|
||||
def _wants_json(request, is_json, data):
|
||||
return (
|
||||
is_json
|
||||
or request.headers.get("accept") == "application/json"
|
||||
or (isinstance(data, dict) and data.get("_json"))
|
||||
)
|
||||
|
||||
|
||||
def _query_create_form_error_message(message):
|
||||
return {
|
||||
"Query name is required": "URL is required",
|
||||
"Invalid query name": "Invalid URL",
|
||||
"Query name conflicts with a table or view": (
|
||||
"URL conflicts with an existing table or view"
|
||||
),
|
||||
"Query already exists": "A query already exists at that URL",
|
||||
}.get(message, message)
|
||||
|
||||
|
||||
async def _json_or_form_payload(request):
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if content_type.startswith("application/json"):
|
||||
body = await request.post_body()
|
||||
try:
|
||||
return json.loads(body or b"{}"), True
|
||||
except json.JSONDecodeError as e:
|
||||
raise QueryValidationError("Invalid JSON: {}".format(e))
|
||||
return await request.post_vars(), False
|
||||
|
||||
|
||||
async def _check_query_name(db, name, *, existing=False):
|
||||
if not name or not isinstance(name, str):
|
||||
raise QueryValidationError("Query name is required")
|
||||
if not _query_name_re.match(name):
|
||||
raise QueryValidationError("Invalid query name")
|
||||
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
|
||||
raise QueryValidationError("Query name conflicts with a table or view")
|
||||
|
||||
|
||||
async def _analyze_user_query(datasette, db, sql, *, actor):
|
||||
if not sql or not isinstance(sql, str):
|
||||
raise QueryValidationError("SQL is required")
|
||||
derived = _derived_query_parameters(sql)
|
||||
params = {parameter: "" for parameter in derived}
|
||||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
except sqlite3.DatabaseError as ex:
|
||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
||||
|
||||
is_write = _analysis_is_write(analysis)
|
||||
if is_write:
|
||||
try:
|
||||
await datasette.ensure_query_write_permissions(
|
||||
db.name, sql, actor=actor, analysis=analysis
|
||||
)
|
||||
except QueryWriteRejected as ex:
|
||||
raise QueryValidationError(ex.message, status=403, flash=True) from ex
|
||||
except Forbidden as ex:
|
||||
raise QueryValidationError(str(ex), status=403) from ex
|
||||
else:
|
||||
try:
|
||||
validate_sql_select(sql)
|
||||
except InvalidSql as ex:
|
||||
raise QueryValidationError(str(ex)) from ex
|
||||
return is_write, derived, analysis
|
||||
|
||||
|
||||
def _display_operations(analysis: SQLAnalysis) -> list[Operation]:
|
||||
operations = []
|
||||
for operation in analysis.operations:
|
||||
if isinstance(
|
||||
decision_for_write_sql_operation(operation), IgnoreWriteSqlOperation
|
||||
):
|
||||
continue
|
||||
operations.append(operation)
|
||||
return operations
|
||||
|
||||
|
||||
def _analysis_rows(analysis: SQLAnalysis) -> list[dict[str, object]]:
|
||||
rows = []
|
||||
for operation in _display_operations(analysis):
|
||||
decision = decision_for_write_sql_operation(operation)
|
||||
required_permission = (
|
||||
", ".join(permission.action for permission in decision.permissions)
|
||||
if isinstance(decision, RequireWriteSqlPermissions)
|
||||
else ""
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"operation": operation.operation,
|
||||
"database": operation.database,
|
||||
"table": operation.table or operation.target,
|
||||
"required_permission": required_permission,
|
||||
"source": operation.source,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def _analysis_rows_with_permissions(
|
||||
datasette, analysis: SQLAnalysis, actor
|
||||
) -> list[dict[str, object]]:
|
||||
rows = _analysis_rows(analysis)
|
||||
is_write = _analysis_is_write(analysis)
|
||||
for row, operation in zip(rows, _display_operations(analysis)):
|
||||
decision = decision_for_write_sql_operation(operation)
|
||||
if isinstance(decision, RequireWriteSqlPermissions):
|
||||
row["allowed"] = True
|
||||
for permission in decision.permissions:
|
||||
if not await datasette.allowed(
|
||||
action=permission.action,
|
||||
resource=permission.resource,
|
||||
actor=actor,
|
||||
):
|
||||
row["allowed"] = False
|
||||
break
|
||||
elif is_write:
|
||||
row["allowed"] = False
|
||||
else:
|
||||
row["allowed"] = None
|
||||
return rows
|
||||
|
||||
|
||||
def _execute_write_disabled_reason(sql, analysis_error, analysis_rows):
|
||||
if not (sql and sql.strip()):
|
||||
return "Enter writable SQL before executing."
|
||||
if analysis_error:
|
||||
return analysis_error
|
||||
if any(row.get("allowed") is False for row in analysis_rows):
|
||||
return "You do not have permission for every operation listed above."
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_execute_write_payload(data, is_json):
|
||||
if not isinstance(data, dict):
|
||||
raise QueryValidationError("JSON must be a dictionary")
|
||||
if is_json:
|
||||
invalid_keys = set(data) - {"sql", "params"}
|
||||
if invalid_keys:
|
||||
raise QueryValidationError(
|
||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
||||
)
|
||||
params = data.get("params") or {}
|
||||
else:
|
||||
params = {}
|
||||
for key, value in data.items():
|
||||
if key in {"sql", "csrftoken", "_json"}:
|
||||
continue
|
||||
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
|
||||
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
|
||||
params[key] = value
|
||||
if not isinstance(params, dict):
|
||||
raise QueryValidationError("params must be a dictionary")
|
||||
return data.get("sql"), params
|
||||
|
||||
|
||||
async def _prepare_execute_write(datasette, db, sql, params, actor):
|
||||
if not sql or not isinstance(sql, str):
|
||||
raise QueryValidationError("SQL is required")
|
||||
parameter_names = _derived_query_parameters(sql)
|
||||
extra_params = set(params) - set(parameter_names)
|
||||
if extra_params:
|
||||
raise QueryValidationError(
|
||||
"Unknown parameters: {}".format(", ".join(sorted(extra_params)))
|
||||
)
|
||||
params = {name: params.get(name, "") for name in parameter_names}
|
||||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
except sqlite3.DatabaseError as ex:
|
||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
||||
if not _analysis_is_write(analysis):
|
||||
raise QueryValidationError(
|
||||
"Use /-/query for read-only SQL; this endpoint only executes writes"
|
||||
)
|
||||
try:
|
||||
await datasette.ensure_query_write_permissions(
|
||||
db.name, sql, actor=actor, analysis=analysis
|
||||
)
|
||||
except QueryWriteRejected as ex:
|
||||
raise QueryValidationError(ex.message, status=403, flash=True) from ex
|
||||
except Forbidden as ex:
|
||||
raise QueryValidationError(str(ex), status=403) from ex
|
||||
return parameter_names, params, analysis
|
||||
|
||||
|
||||
async def _ensure_stored_query_execution_permissions(
|
||||
datasette, db, query: StoredQuery, actor
|
||||
):
|
||||
if query.is_trusted:
|
||||
return
|
||||
if query.is_write:
|
||||
await datasette.ensure_permission(
|
||||
action="execute-write-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=actor,
|
||||
)
|
||||
await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor)
|
||||
else:
|
||||
await datasette.ensure_permission(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
|
||||
async def _execute_write_analysis_data(datasette, db, sql, actor):
|
||||
parameter_names = []
|
||||
analysis_rows = []
|
||||
analysis_error = None
|
||||
if sql:
|
||||
try:
|
||||
parameter_names = _derived_query_parameters(sql)
|
||||
params = {parameter: "" for parameter in parameter_names}
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
if _analysis_is_write(analysis):
|
||||
analysis_rows = await _analysis_rows_with_permissions(
|
||||
datasette, analysis, actor
|
||||
)
|
||||
else:
|
||||
analysis_error = (
|
||||
"Use /-/query for read-only SQL; "
|
||||
"this endpoint only executes writes"
|
||||
)
|
||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
||||
analysis_error = getattr(ex, "message", str(ex))
|
||||
execute_disabled_reason = _execute_write_disabled_reason(
|
||||
sql, analysis_error, analysis_rows
|
||||
)
|
||||
return {
|
||||
"ok": analysis_error is None,
|
||||
"parameters": parameter_names,
|
||||
"analysis_error": analysis_error,
|
||||
"analysis_rows": analysis_rows,
|
||||
"execute_disabled": bool(execute_disabled_reason),
|
||||
"execute_disabled_reason": execute_disabled_reason,
|
||||
}
|
||||
|
||||
|
||||
async def _query_create_analysis_data(datasette, db, sql, actor):
|
||||
has_sql = bool(sql and sql.strip())
|
||||
parameter_names = []
|
||||
analysis_rows = []
|
||||
analysis_error = None
|
||||
analysis: SQLAnalysis | None = None
|
||||
if has_sql:
|
||||
try:
|
||||
parameter_names = _derived_query_parameters(sql)
|
||||
params = {parameter: "" for parameter in parameter_names}
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
analysis_rows = await _analysis_rows_with_permissions(
|
||||
datasette, analysis, actor
|
||||
)
|
||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
||||
analysis_error = getattr(ex, "message", str(ex))
|
||||
return {
|
||||
"ok": analysis_error is None,
|
||||
"parameters": parameter_names,
|
||||
"analysis_error": analysis_error,
|
||||
"analysis_rows": analysis_rows,
|
||||
"has_sql": has_sql,
|
||||
"analysis_is_write": _analysis_is_write(analysis) if analysis else False,
|
||||
"save_disabled": bool(
|
||||
(not has_sql)
|
||||
or analysis_error
|
||||
or any(row["allowed"] is False for row in analysis_rows)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _query_create_form_context(
|
||||
datasette,
|
||||
request,
|
||||
db,
|
||||
*,
|
||||
sql="",
|
||||
name="",
|
||||
title="",
|
||||
description="",
|
||||
is_private=True,
|
||||
):
|
||||
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
|
||||
return {
|
||||
"database": db.name,
|
||||
"database_color": db.color,
|
||||
"sql": sql,
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"is_private": is_private,
|
||||
**analysis_data,
|
||||
}
|
||||
|
||||
|
||||
async def _query_edit_form_context(
|
||||
datasette,
|
||||
request,
|
||||
db,
|
||||
existing: StoredQuery,
|
||||
*,
|
||||
sql=None,
|
||||
title=None,
|
||||
description=None,
|
||||
is_private=None,
|
||||
):
|
||||
sql = existing.sql if sql is None else sql
|
||||
title = existing.title if title is None else title
|
||||
description = existing.description if description is None else description
|
||||
is_private = existing.is_private if is_private is None else is_private
|
||||
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
|
||||
return {
|
||||
"database": db.name,
|
||||
"database_color": db.color,
|
||||
"name": existing.name,
|
||||
"sql": sql,
|
||||
"title": title or "",
|
||||
"description": description or "",
|
||||
"is_private": is_private,
|
||||
"query_url": datasette.urls.table(db.name, existing.name),
|
||||
**analysis_data,
|
||||
}
|
||||
|
||||
|
||||
async def _inserted_row_url(datasette, db, analysis, cursor):
|
||||
if cursor.rowcount != 1:
|
||||
return None
|
||||
lastrowid = getattr(cursor, "lastrowid", None)
|
||||
if lastrowid is None:
|
||||
return None
|
||||
direct_inserts = [
|
||||
operation
|
||||
for operation in analysis.operations
|
||||
if operation.operation == "insert"
|
||||
and operation.target_type == "table"
|
||||
and not operation.internal
|
||||
and operation.source is None
|
||||
and operation.database == db.name
|
||||
]
|
||||
if len(direct_inserts) != 1:
|
||||
return None
|
||||
table = direct_inserts[0].table
|
||||
if table is None:
|
||||
return None
|
||||
pks = await db.primary_keys(table)
|
||||
use_rowid = not pks
|
||||
select = (
|
||||
"rowid"
|
||||
if use_rowid
|
||||
else ", ".join(escape_sqlite(primary_key) for primary_key in pks)
|
||||
)
|
||||
try:
|
||||
result = await db.execute(
|
||||
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
|
||||
[lastrowid],
|
||||
)
|
||||
except sqlite3.DatabaseError:
|
||||
return None
|
||||
row = result.first()
|
||||
if row is None:
|
||||
return None
|
||||
row_path = path_from_row_pks(row, pks, use_rowid)
|
||||
return datasette.urls.row(db.name, table, row_path)
|
||||
|
||||
|
||||
def _apply_query_data_types(data):
|
||||
typed = dict(data)
|
||||
for key in ("hide_sql", "is_private"):
|
||||
if key in typed:
|
||||
typed[key] = _as_bool(typed[key])
|
||||
return typed
|
||||
|
||||
|
||||
async def _prepare_query_create(datasette, request, db, data):
|
||||
invalid_keys = set(data) - _query_create_fields
|
||||
if invalid_keys:
|
||||
raise QueryValidationError(
|
||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
||||
)
|
||||
|
||||
data = _apply_query_data_types(data)
|
||||
name = data.get("name")
|
||||
await _check_query_name(db, name)
|
||||
if await datasette.get_query(db.name, name) is not None:
|
||||
raise QueryValidationError("Query already exists")
|
||||
|
||||
is_write, derived, analysis = await _analyze_user_query(
|
||||
datasette,
|
||||
db,
|
||||
data.get("sql"),
|
||||
actor=request.actor,
|
||||
)
|
||||
if not is_write and any(data.get(field) for field in _query_write_fields):
|
||||
raise QueryValidationError("Writable query fields require writable SQL")
|
||||
|
||||
parameters = _coerce_query_parameters(
|
||||
data.get("parameters"),
|
||||
derived,
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"sql": data["sql"],
|
||||
"title": data.get("title"),
|
||||
"description": data.get("description"),
|
||||
"hide_sql": _as_bool(data.get("hide_sql")),
|
||||
"fragment": data.get("fragment"),
|
||||
"parameters": parameters,
|
||||
"is_write": is_write,
|
||||
"is_private": _as_bool(data.get("is_private", True)),
|
||||
"is_trusted": False,
|
||||
"source": "user",
|
||||
"owner_id": _actor_id(request.actor),
|
||||
"on_success_message": data.get("on_success_message"),
|
||||
"on_success_redirect": data.get("on_success_redirect"),
|
||||
"on_error_message": data.get("on_error_message"),
|
||||
"on_error_redirect": data.get("on_error_redirect"),
|
||||
"analysis": analysis,
|
||||
}
|
||||
|
||||
|
||||
async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update):
|
||||
invalid_keys = set(update) - _query_update_fields
|
||||
if invalid_keys:
|
||||
raise QueryValidationError(
|
||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
||||
)
|
||||
|
||||
update = _apply_query_data_types(update)
|
||||
sql = update.get("sql", existing.sql)
|
||||
query_is_write = existing.is_write
|
||||
derived = _derived_query_parameters(sql)
|
||||
parameters = None
|
||||
|
||||
if "sql" in update:
|
||||
query_is_write, derived, _ = await _analyze_user_query(
|
||||
datasette,
|
||||
db,
|
||||
sql,
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
if "parameters" in update:
|
||||
parameters = _coerce_query_parameters(
|
||||
update.get("parameters"),
|
||||
derived,
|
||||
)
|
||||
elif "sql" in update:
|
||||
parameters = derived
|
||||
|
||||
if not query_is_write and any(update.get(field) for field in _query_write_fields):
|
||||
raise QueryValidationError("Writable query fields require writable SQL")
|
||||
|
||||
field_values = {
|
||||
"sql": sql,
|
||||
"title": update.get("title"),
|
||||
"description": update.get("description"),
|
||||
"hide_sql": update.get("hide_sql"),
|
||||
"fragment": update.get("fragment"),
|
||||
"parameters": parameters,
|
||||
"is_write": query_is_write,
|
||||
"is_private": update.get("is_private"),
|
||||
"on_success_message": update.get("on_success_message"),
|
||||
"on_success_redirect": update.get("on_success_redirect"),
|
||||
"on_error_message": update.get("on_error_message"),
|
||||
"on_error_redirect": update.get("on_error_redirect"),
|
||||
}
|
||||
update_kwargs = {}
|
||||
for field_name, value in field_values.items():
|
||||
if field_name in update:
|
||||
update_kwargs[field_name] = value
|
||||
if parameters is not None:
|
||||
update_kwargs["parameters"] = parameters
|
||||
if "sql" in update:
|
||||
update_kwargs["is_write"] = query_is_write
|
||||
return update_kwargs
|
||||
|
||||
|
||||
async def _table_columns(datasette, database_name):
|
||||
internal_db = datasette.get_internal_database()
|
||||
result = await internal_db.execute(
|
||||
"select table_name, name from catalog_columns where database_name = ?",
|
||||
[database_name],
|
||||
)
|
||||
table_columns = {}
|
||||
for row in result.rows:
|
||||
table_columns.setdefault(row["table_name"], []).append(row["name"])
|
||||
# Add views
|
||||
db = datasette.get_database(database_name)
|
||||
for view_name in await db.view_names():
|
||||
table_columns[view_name] = []
|
||||
return table_columns
|
||||
|
|
@ -1,25 +1,398 @@
|
|||
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 .table import display_columns_and_rows, _get_extras
|
||||
from datasette.extras import extra_names_from_request, ExtraScope
|
||||
from . import Context, from_extra
|
||||
from .table import (
|
||||
display_columns_and_rows,
|
||||
_table_page_data,
|
||||
row_label_from_label_column,
|
||||
)
|
||||
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
|
||||
|
|
@ -47,6 +420,7 @@ class RowView(DataView):
|
|||
pks = resolved.pks
|
||||
|
||||
async def template_data():
|
||||
is_table = await db.table_exists(table)
|
||||
# Reorder columns so primary keys come first
|
||||
pk_set = set(pks)
|
||||
pk_cols = [d for d in results.description if d[0] in pk_set]
|
||||
|
|
@ -115,7 +489,60 @@ class RowView(DataView):
|
|||
"<strong>{}</strong>".format(cell["value"])
|
||||
)
|
||||
|
||||
label_column = await db.label_column_for_table(table) if is_table else None
|
||||
row_path = path_from_row_pks(rows[0], pks, False)
|
||||
pk_path = path_from_row_pks(rows[0], pks, False, False)
|
||||
row_label = row_label_from_label_column(expanded_rows[0], label_column)
|
||||
for display_row in display_rows:
|
||||
display_row.pk_path = pk_path
|
||||
display_row.row_path = row_path
|
||||
display_row.row_label = row_label
|
||||
|
||||
row_action_label = pk_path
|
||||
if row_label and row_label != pk_path:
|
||||
row_action_label = "{} {}".format(pk_path, row_label)
|
||||
|
||||
row_action_permissions = {}
|
||||
if is_table and db.is_mutable:
|
||||
row_action_permissions = await self.ds.allowed_many(
|
||||
actions=["update-row", "delete-row"],
|
||||
resource=TableResource(database=database, table=table),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
row_actions = []
|
||||
if row_action_permissions.get("update-row"):
|
||||
attrs = {
|
||||
"aria-label": "Edit row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "edit",
|
||||
}
|
||||
if row_label:
|
||||
attrs["data-row-label"] = row_label
|
||||
row_actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Edit row",
|
||||
"description": "Open a dialog to edit this row.",
|
||||
"attrs": attrs,
|
||||
}
|
||||
)
|
||||
if row_action_permissions.get("delete-row"):
|
||||
attrs = {
|
||||
"aria-label": "Delete row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "delete",
|
||||
}
|
||||
if row_label:
|
||||
attrs["data-row-label"] = row_label
|
||||
row_actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Delete row",
|
||||
"description": "Open a confirmation dialog to delete this row.",
|
||||
"attrs": attrs,
|
||||
}
|
||||
)
|
||||
for hook in pm.hook.row_actions(
|
||||
datasette=self.ds,
|
||||
actor=request.actor,
|
||||
|
|
@ -142,6 +569,17 @@ class RowView(DataView):
|
|||
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
|
||||
"_table.html",
|
||||
],
|
||||
"row_mutation_ui": any(row_action_permissions.values()),
|
||||
"table_page_data": await _table_page_data(
|
||||
datasette=self.ds,
|
||||
request=request,
|
||||
db=db,
|
||||
database_name=database,
|
||||
table_name=table,
|
||||
is_view=not is_table,
|
||||
table_insert_ui=None,
|
||||
table_alter_ui=None,
|
||||
),
|
||||
"row_actions": row_actions,
|
||||
"top_row": make_slot_function(
|
||||
"top_row",
|
||||
|
|
@ -164,60 +602,30 @@ class RowView(DataView):
|
|||
"primary_key_values": pk_values,
|
||||
}
|
||||
|
||||
# Handle _extra parameter (new style)
|
||||
extras = _get_extras(request)
|
||||
|
||||
# Also support legacy _extras parameter for backward compatibility
|
||||
if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
|
||||
extras.add("foreign_key_tables")
|
||||
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
|
||||
if "foreign_key_tables" in extras:
|
||||
data["foreign_key_tables"] = await self.foreign_key_tables(
|
||||
database, table, pk_values
|
||||
)
|
||||
|
||||
if "render_cell" in extras:
|
||||
# Call render_cell plugin hook for each cell
|
||||
ct_map = await self.ds.get_column_types(database, table)
|
||||
rendered_rows = []
|
||||
for row in rows:
|
||||
rendered_row = {}
|
||||
for value, column in zip(row, columns):
|
||||
ct = ct_map.get(column)
|
||||
plugin_display_value = None
|
||||
# Try column type render_cell first
|
||||
if ct:
|
||||
candidate = await ct.render_cell(
|
||||
value=value,
|
||||
column=column,
|
||||
table=table,
|
||||
database=database,
|
||||
datasette=self.ds,
|
||||
request=request,
|
||||
)
|
||||
if candidate is not None:
|
||||
plugin_display_value = candidate
|
||||
if plugin_display_value is None:
|
||||
for candidate in pm.hook.render_cell(
|
||||
row=row,
|
||||
value=value,
|
||||
column=column,
|
||||
table=table,
|
||||
pks=resolved.pks,
|
||||
database=database,
|
||||
datasette=self.ds,
|
||||
request=request,
|
||||
column_type=ct,
|
||||
):
|
||||
candidate = await await_me_maybe(candidate)
|
||||
if candidate is not None:
|
||||
plugin_display_value = candidate
|
||||
break
|
||||
if plugin_display_value:
|
||||
rendered_row[column] = str(plugin_display_value)
|
||||
rendered_rows.append(rendered_row)
|
||||
data["render_cell"] = rendered_rows
|
||||
row_extra_context = RowExtraContext(
|
||||
datasette=self.ds,
|
||||
request=request,
|
||||
db=db,
|
||||
database_name=database,
|
||||
table_name=table,
|
||||
private=private,
|
||||
rows=rows,
|
||||
columns=columns,
|
||||
pks=pks,
|
||||
pk_values=pk_values,
|
||||
sql=resolved.sql,
|
||||
params=resolved.params,
|
||||
extras=extras,
|
||||
extra_registry=table_extra_registry,
|
||||
foreign_key_tables=self.foreign_key_tables,
|
||||
)
|
||||
data.update(await resolve_row_extras(extras, row_extra_context))
|
||||
|
||||
return (
|
||||
data,
|
||||
|
|
@ -280,17 +688,40 @@ class RowError(Exception):
|
|||
self.error = error
|
||||
|
||||
|
||||
ROW_FLASH_LABEL_MAX_LENGTH = 80
|
||||
|
||||
|
||||
def _truncated_row_flash_label(label):
|
||||
label = " ".join(str(label).split())
|
||||
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
|
||||
return label
|
||||
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
|
||||
|
||||
|
||||
async def _row_flash_message(db, action, resolved, row=None):
|
||||
pk_label = ", ".join(resolved.pk_values)
|
||||
label_column = await db.label_column_for_table(resolved.table)
|
||||
label = row_label_from_label_column(row or resolved.row, label_column)
|
||||
if label:
|
||||
label = _truncated_row_flash_label(label)
|
||||
if label and label != pk_label:
|
||||
return "{} row {} ({})".format(action, pk_label, label)
|
||||
return "{} row {}".format(action, pk_label)
|
||||
|
||||
|
||||
async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
|
||||
|
||||
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(
|
||||
|
|
@ -298,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
|
||||
|
||||
|
|
@ -323,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(
|
||||
|
|
@ -334,6 +765,15 @@ class RowDeleteView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
if request.args.get("_redirect_to_table"):
|
||||
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
|
||||
self.ds.add_message(
|
||||
request,
|
||||
await _row_flash_message(resolved.db, "Deleted", resolved),
|
||||
self.ds.INFO,
|
||||
)
|
||||
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
|
||||
|
||||
return Response.json({"ok": True}, status=200)
|
||||
|
||||
|
||||
|
|
@ -350,22 +790,27 @@ class RowUpdateView(BaseView):
|
|||
if not ok:
|
||||
return resolved
|
||||
|
||||
body = await request.post_body()
|
||||
try:
|
||||
data = json.loads(body)
|
||||
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
|
||||
|
|
@ -374,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(
|
||||
|
|
@ -382,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(
|
||||
|
|
@ -392,14 +837,16 @@ 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
|
||||
if data.get("return"):
|
||||
results = await resolved.db.execute(
|
||||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
result["row"] = results.dicts()[0]
|
||||
returned_row = results.dicts()[0]
|
||||
result["rows"] = [returned_row]
|
||||
|
||||
await self.ds.track_event(
|
||||
UpdateRowEvent(
|
||||
|
|
@ -410,4 +857,19 @@ class RowUpdateView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
return Response.json(result, status=200)
|
||||
if request.args.get("_message"):
|
||||
message_row = returned_row
|
||||
if message_row is None:
|
||||
results = await resolved.db.execute(
|
||||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
message_row = results.first()
|
||||
self.ds.add_message(
|
||||
request,
|
||||
await _row_flash_message(
|
||||
resolved.db, "Updated", resolved, row=message_row
|
||||
),
|
||||
self.ds.INFO,
|
||||
)
|
||||
|
||||
return Response.json(result, status=200, default=CustomJSONEncoder().default)
|
||||
|
|
|
|||
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