mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 13:36:58 +02:00
Compare commits
232 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc6f207d37 | ||
|
|
49362a9b20 | ||
|
|
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 | ||
|
|
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 |
||
|
|
aa84fe008d | ||
|
|
0dc7bb19d9 | ||
|
|
b15ce18ddc |
||
|
|
a6031c9847 | ||
|
|
1cd53e1fc3 |
||
|
|
630e557cdb | ||
|
|
b3001c1e5a | ||
|
|
c9a7dc9be2 | ||
|
|
03eeeb9d92 | ||
|
|
ede942a32e | ||
|
|
df96e12737 | ||
|
|
d23b32c3e5 | ||
|
|
c0153386ef | ||
|
|
34cc320eab | ||
|
|
d72dd35378 | ||
|
|
290f27158f | ||
|
|
dabf8e4199 | ||
|
|
ade0ef8a60 | ||
|
|
2638200d26 |
151 changed files with 25260 additions and 2341 deletions
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@v6
|
||||
- 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
|
||||
|
|
|
|||
2
.github/workflows/documentation-links.yml
vendored
2
.github/workflows/documentation-links.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Read the Docs Pull Request Preview
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
|
|
|
|||
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@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Configure npm caching
|
||||
with:
|
||||
path: ~/.npm
|
||||
|
|
|
|||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
deploy_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/spellcheck.yml
vendored
2
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/stable-docs.yml
vendored
2
.github/workflows/stable-docs.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||
- name: Set up Git user
|
||||
|
|
|
|||
2
.github/workflows/test-coverage.yml
vendored
2
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/test-pyodide.yml
vendored
4
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-browsers
|
||||
|
|
|
|||
2
.github/workflows/test-sqlite-support.yml
vendored
2
.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@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
|||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
|
|
|
|||
2
.github/workflows/tmate.yml
vendored
2
.github/workflows/tmate.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
|||
build-metadata.json
|
||||
datasets.json
|
||||
|
||||
.playwright-mcp
|
||||
|
||||
scratchpad
|
||||
|
||||
.vscode
|
||||
|
|
@ -131,4 +133,4 @@ tests/*.dylib
|
|||
tests/*.so
|
||||
tests/*.dll
|
||||
|
||||
.idea
|
||||
.idea
|
||||
|
|
|
|||
123
datasette/_pytest_plugin.py
Normal file
123
datasette/_pytest_plugin.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""
|
||||
Pytest plugin that automatically closes any Datasette instances constructed
|
||||
during a pytest test — both in the test body and in function-scoped
|
||||
fixtures. Instances constructed by session-, module-, class- or package-
|
||||
scoped fixtures are left alone, because other tests in the session will
|
||||
still want to use them.
|
||||
|
||||
Registered as a pytest11 entry point in pyproject.toml so that downstream
|
||||
projects using Datasette get the same FD-safety net for their own tests.
|
||||
|
||||
Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the
|
||||
equivalent ini file).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import weakref
|
||||
|
||||
import pytest
|
||||
|
||||
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
|
||||
"datasette_active_instances", default=None
|
||||
)
|
||||
|
||||
_original_init = None
|
||||
|
||||
|
||||
def _install_tracking():
|
||||
# datasette.app is imported lazily here rather than at module level:
|
||||
# as a pytest11 entry point this module is imported during pytest
|
||||
# startup, before pytest-cov starts measuring, so a module-level
|
||||
# import would drag in all of datasette and make every import-time
|
||||
# line in the package invisible to coverage
|
||||
global _original_init
|
||||
if _original_init is not None:
|
||||
return
|
||||
from datasette.app import Datasette
|
||||
|
||||
_original_init = Datasette.__init__
|
||||
|
||||
def _tracking_init(self, *args, **kwargs):
|
||||
_original_init(self, *args, **kwargs)
|
||||
instances = _active_instances.get()
|
||||
if instances is not None:
|
||||
instances.append(weakref.ref(self))
|
||||
|
||||
Datasette.__init__ = _tracking_init
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
if _enabled(config):
|
||||
_install_tracking()
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addini(
|
||||
"datasette_autoclose",
|
||||
help=(
|
||||
"Automatically close Datasette instances created inside test "
|
||||
"bodies and function-scoped fixtures (default: true)."
|
||||
),
|
||||
default="true",
|
||||
)
|
||||
|
||||
|
||||
def _enabled(config) -> bool:
|
||||
value = config.getini("datasette_autoclose")
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() not in ("false", "0", "no", "off")
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_protocol(item, nextitem):
|
||||
"""Track Datasette instances across setup, call and teardown; close at end."""
|
||||
if not _enabled(item.config):
|
||||
yield
|
||||
return
|
||||
refs: list[weakref.ref] = []
|
||||
token = _active_instances.set(refs)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_active_instances.reset(token)
|
||||
for ref in reversed(refs):
|
||||
ds = ref()
|
||||
if ds is None:
|
||||
continue
|
||||
try:
|
||||
ds.close()
|
||||
except Exception as e:
|
||||
item.warn(
|
||||
pytest.PytestUnraisableExceptionWarning(
|
||||
f"Error closing Datasette instance: {e!r}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_fixture_setup(fixturedef, request):
|
||||
"""Exempt instances created by non-function-scoped fixtures.
|
||||
|
||||
Session-, module-, class- and package-scoped fixtures produce Datasette
|
||||
instances that must survive beyond the current test — other tests in
|
||||
the session will still use them. When such a fixture creates one or
|
||||
more Datasette instances during its setup, we snapshot the tracking
|
||||
list before the fixture runs and subtract off any instances that were
|
||||
added during its setup, so they don't get closed at test teardown.
|
||||
"""
|
||||
refs = _active_instances.get()
|
||||
if refs is None:
|
||||
yield
|
||||
return
|
||||
before_ids = {id(ref) for ref in refs}
|
||||
yield
|
||||
if fixturedef.scope != "function":
|
||||
new_refs = [ref for ref in refs if id(ref) not in before_ids]
|
||||
for new_ref in new_refs:
|
||||
try:
|
||||
refs.remove(new_ref)
|
||||
except ValueError:
|
||||
pass
|
||||
553
datasette/app.py
553
datasette/app.py
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import contextvars
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.permissions import Resource
|
||||
|
|
@ -42,12 +42,31 @@ from jinja2.exceptions import TemplateNotFound
|
|||
|
||||
from .events import Event
|
||||
from .column_types import SQLiteType
|
||||
from . import stored_queries, write_sql
|
||||
from .views import Context
|
||||
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
|
||||
from .views.database import (
|
||||
database_download,
|
||||
DatabaseView,
|
||||
TableCreateView,
|
||||
QueryView,
|
||||
)
|
||||
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
||||
from .views.stored_queries import (
|
||||
QueryCreateAnalyzeView,
|
||||
QueryDeleteView,
|
||||
QueryDefinitionView,
|
||||
QueryEditView,
|
||||
GlobalQueryListView,
|
||||
QueryListView,
|
||||
QueryParametersView,
|
||||
QueryStoreView,
|
||||
QueryUpdateView,
|
||||
)
|
||||
from .views.index import IndexView
|
||||
from .views.special import (
|
||||
JsonDataView,
|
||||
PatternPortfolioView,
|
||||
AutocompleteDebugView,
|
||||
AuthTokenView,
|
||||
ApiExplorerView,
|
||||
CreateTokenView,
|
||||
|
|
@ -58,16 +77,18 @@ from .views.special import (
|
|||
AllowedResourcesView,
|
||||
PermissionRulesView,
|
||||
PermissionCheckView,
|
||||
TablesView,
|
||||
JumpView,
|
||||
InstanceSchemaView,
|
||||
DatabaseSchemaView,
|
||||
TableSchemaView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableAutocompleteView,
|
||||
TableInsertView,
|
||||
TableUpsertView,
|
||||
TableSetColumnTypeView,
|
||||
TableDropView,
|
||||
TableFragmentView,
|
||||
table_view,
|
||||
)
|
||||
from .views.row import RowView, RowDeleteView, RowUpdateView
|
||||
|
|
@ -273,6 +294,15 @@ DEFAULT_NOT_SET = object()
|
|||
ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params"))
|
||||
|
||||
|
||||
def _permission_cache_key(actor, action, parent, child):
|
||||
# Key on the full serialized actor so actors differing in any field
|
||||
# (e.g. token restrictions) never share cache entries
|
||||
actor_key = (
|
||||
json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
|
||||
)
|
||||
return (actor_key, action, parent, child)
|
||||
|
||||
|
||||
async def favicon(request, send):
|
||||
await asgi_send_file(
|
||||
send,
|
||||
|
|
@ -326,6 +356,7 @@ class Datasette:
|
|||
default_deny=False,
|
||||
):
|
||||
self._startup_invoked = False
|
||||
self._closed = False
|
||||
assert config_dir is None or isinstance(
|
||||
config_dir, Path
|
||||
), "config_dir= should be a pathlib.Path"
|
||||
|
|
@ -570,6 +601,9 @@ class Datasette:
|
|||
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
|
||||
# a warning to user that they should delete their metadata.json file
|
||||
|
||||
async def _save_queries_from_config(self):
|
||||
await stored_queries.save_queries_from_config(self)
|
||||
|
||||
def get_jinja_environment(self, request: Request = None) -> Environment:
|
||||
environment = self._jinja_env
|
||||
if request:
|
||||
|
|
@ -613,15 +647,36 @@ class Datasette:
|
|||
"select database_name, schema_version from catalog_databases"
|
||||
)
|
||||
}
|
||||
# Delete stale entries for databases that are no longer attached
|
||||
stale_databases = set(current_schema_versions.keys()) - set(
|
||||
self.databases.keys()
|
||||
catalog_table_names = (
|
||||
"catalog_columns",
|
||||
"catalog_foreign_keys",
|
||||
"catalog_indexes",
|
||||
"catalog_views",
|
||||
"catalog_tables",
|
||||
"catalog_databases",
|
||||
)
|
||||
for stale_db_name in stale_databases:
|
||||
await internal_db.execute_write(
|
||||
"DELETE FROM catalog_databases WHERE database_name = ?",
|
||||
[stale_db_name],
|
||||
# Delete stale entries for databases that are no longer attached
|
||||
catalog_database_names = set(current_schema_versions.keys())
|
||||
for table in catalog_table_names[:-1]:
|
||||
catalog_database_names.update(
|
||||
row["database_name"]
|
||||
for row in await internal_db.execute(
|
||||
"select distinct database_name from {}".format(table)
|
||||
)
|
||||
if row["database_name"] is not None
|
||||
)
|
||||
stale_databases = catalog_database_names - set(self.databases.keys())
|
||||
if stale_databases:
|
||||
|
||||
def delete_stale_database_catalog(conn):
|
||||
for stale_db_name in stale_databases:
|
||||
for table in catalog_table_names:
|
||||
conn.execute(
|
||||
"DELETE FROM {} WHERE database_name = ?".format(table),
|
||||
[stale_db_name],
|
||||
)
|
||||
|
||||
await internal_db.execute_write_fn(delete_stale_database_catalog)
|
||||
for database_name, db in self.databases.items():
|
||||
schema_version = (await db.execute("PRAGMA schema_version")).first()[0]
|
||||
# Compare schema versions to see if we should skip it
|
||||
|
|
@ -709,6 +764,7 @@ class Datasette:
|
|||
await await_me_maybe(hook)
|
||||
# Ensure internal tables and metadata are populated before startup hooks
|
||||
await self._refresh_schemas()
|
||||
await self._save_queries_from_config()
|
||||
# Load column_types from config into internal DB
|
||||
await self._apply_column_types_config()
|
||||
for hook in pm.hook.startup(datasette=self):
|
||||
|
|
@ -834,6 +890,33 @@ class Datasette:
|
|||
new_databases.pop(name)
|
||||
self.databases = new_databases
|
||||
|
||||
def close(self):
|
||||
"""Release all resources held by this Datasette instance.
|
||||
|
||||
Closes every attached Database (including the internal database),
|
||||
shuts down the executor, and unlinks the temporary file used for
|
||||
the internal database if one was created. Idempotent and one-way.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
first_exception = None
|
||||
dbs = list(self.databases.values()) + [self._internal_database]
|
||||
for db in dbs:
|
||||
try:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
if first_exception is None:
|
||||
first_exception = e
|
||||
if self.executor is not None:
|
||||
try:
|
||||
self.executor.shutdown(wait=True, cancel_futures=True)
|
||||
except Exception as e:
|
||||
if first_exception is None:
|
||||
first_exception = e
|
||||
if first_exception is not None:
|
||||
raise first_exception
|
||||
|
||||
def setting(self, key):
|
||||
return self._settings.get(key, None)
|
||||
|
||||
|
|
@ -958,6 +1041,180 @@ class Datasette:
|
|||
[database_name, resource_name, column_name, key, value],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None:
|
||||
return stored_queries.query_row_to_stored_query(row)
|
||||
|
||||
@staticmethod
|
||||
def _query_options_json(options):
|
||||
return stored_queries.query_options_json(options)
|
||||
|
||||
async def add_query(
|
||||
self,
|
||||
database: str,
|
||||
name: str,
|
||||
sql: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
description_html: str | None = None,
|
||||
hide_sql: bool = False,
|
||||
fragment: str | None = None,
|
||||
parameters: Iterable[str] | None = None,
|
||||
is_write: bool = False,
|
||||
is_private: bool = False,
|
||||
is_trusted: bool = False,
|
||||
source: str = "plugin",
|
||||
owner_id: str | None = None,
|
||||
on_success_message: str | None = None,
|
||||
on_success_message_sql: str | None = None,
|
||||
on_success_redirect: str | None = None,
|
||||
on_error_message: str | None = None,
|
||||
on_error_redirect: str | None = None,
|
||||
replace: bool = True,
|
||||
) -> None:
|
||||
return await stored_queries.add_query(
|
||||
self,
|
||||
database,
|
||||
name,
|
||||
sql,
|
||||
title=title,
|
||||
description=description,
|
||||
description_html=description_html,
|
||||
hide_sql=hide_sql,
|
||||
fragment=fragment,
|
||||
parameters=parameters,
|
||||
is_write=is_write,
|
||||
is_private=is_private,
|
||||
is_trusted=is_trusted,
|
||||
source=source,
|
||||
owner_id=owner_id,
|
||||
on_success_message=on_success_message,
|
||||
on_success_message_sql=on_success_message_sql,
|
||||
on_success_redirect=on_success_redirect,
|
||||
on_error_message=on_error_message,
|
||||
on_error_redirect=on_error_redirect,
|
||||
replace=replace,
|
||||
)
|
||||
|
||||
async def update_query(
|
||||
self,
|
||||
database: str,
|
||||
name: str,
|
||||
*,
|
||||
sql=stored_queries.UNCHANGED,
|
||||
title=stored_queries.UNCHANGED,
|
||||
description=stored_queries.UNCHANGED,
|
||||
description_html=stored_queries.UNCHANGED,
|
||||
hide_sql=stored_queries.UNCHANGED,
|
||||
fragment=stored_queries.UNCHANGED,
|
||||
parameters=stored_queries.UNCHANGED,
|
||||
is_write=stored_queries.UNCHANGED,
|
||||
is_private=stored_queries.UNCHANGED,
|
||||
is_trusted=stored_queries.UNCHANGED,
|
||||
source=stored_queries.UNCHANGED,
|
||||
owner_id=stored_queries.UNCHANGED,
|
||||
on_success_message=stored_queries.UNCHANGED,
|
||||
on_success_message_sql=stored_queries.UNCHANGED,
|
||||
on_success_redirect=stored_queries.UNCHANGED,
|
||||
on_error_message=stored_queries.UNCHANGED,
|
||||
on_error_redirect=stored_queries.UNCHANGED,
|
||||
) -> None:
|
||||
return await stored_queries.update_query(
|
||||
self,
|
||||
database,
|
||||
name,
|
||||
sql=sql,
|
||||
title=title,
|
||||
description=description,
|
||||
description_html=description_html,
|
||||
hide_sql=hide_sql,
|
||||
fragment=fragment,
|
||||
parameters=parameters,
|
||||
is_write=is_write,
|
||||
is_private=is_private,
|
||||
is_trusted=is_trusted,
|
||||
source=source,
|
||||
owner_id=owner_id,
|
||||
on_success_message=on_success_message,
|
||||
on_success_message_sql=on_success_message_sql,
|
||||
on_success_redirect=on_success_redirect,
|
||||
on_error_message=on_error_message,
|
||||
on_error_redirect=on_error_redirect,
|
||||
)
|
||||
|
||||
async def remove_query(
|
||||
self, database: str, name: str, source: str | None = None
|
||||
) -> None:
|
||||
return await stored_queries.remove_query(self, database, name, source=source)
|
||||
|
||||
async def get_query(
|
||||
self, database: str, name: str
|
||||
) -> stored_queries.StoredQuery | None:
|
||||
return await stored_queries.get_query(self, database, name)
|
||||
|
||||
async def count_queries(
|
||||
self,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
) -> int:
|
||||
return await stored_queries.count_queries(
|
||||
self,
|
||||
database,
|
||||
actor=actor,
|
||||
q=q,
|
||||
is_write=is_write,
|
||||
is_private=is_private,
|
||||
is_trusted=is_trusted,
|
||||
source=source,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
async def list_queries(
|
||||
self,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
limit: int = 50,
|
||||
cursor: str | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
include_private: bool = False,
|
||||
) -> stored_queries.StoredQueryPage:
|
||||
return await stored_queries.list_queries(
|
||||
self,
|
||||
database,
|
||||
actor=actor,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
q=q,
|
||||
is_write=is_write,
|
||||
is_private=is_private,
|
||||
is_trusted=is_trusted,
|
||||
source=source,
|
||||
owner_id=owner_id,
|
||||
include_private=include_private,
|
||||
)
|
||||
|
||||
async def ensure_query_write_permissions(
|
||||
self, database, sql, *, actor=None, params=None, analysis=None
|
||||
):
|
||||
# Raise Forbidden or QueryWriteRejected if SQL should not run
|
||||
return await write_sql.ensure_query_write_permissions(
|
||||
self, database, sql, actor=actor, params=params, analysis=analysis
|
||||
)
|
||||
|
||||
# Column types API
|
||||
|
||||
async def _get_resource_column_details(self, database: str, resource: str):
|
||||
|
|
@ -1170,36 +1427,24 @@ class Datasette:
|
|||
|
||||
return db_plugin_config
|
||||
|
||||
def static_hash(self, filename):
|
||||
if not hasattr(self, "_static_hashes"):
|
||||
self._static_hashes = {}
|
||||
path = os.path.join(str(app_root), "datasette/static", filename)
|
||||
signature = (os.path.getmtime(path), os.path.getsize(path))
|
||||
cached = self._static_hashes.get(filename)
|
||||
if cached and cached["signature"] == signature:
|
||||
return cached["hash"]
|
||||
with open(path) as fp:
|
||||
static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6]
|
||||
self._static_hashes[filename] = {
|
||||
"signature": signature,
|
||||
"hash": static_hash,
|
||||
}
|
||||
return static_hash
|
||||
|
||||
def app_css_hash(self):
|
||||
if not hasattr(self, "_app_css_hash"):
|
||||
with open(os.path.join(str(app_root), "datasette/static/app.css")) as fp:
|
||||
self._app_css_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[
|
||||
:6
|
||||
]
|
||||
return self._app_css_hash
|
||||
|
||||
async def get_canned_queries(self, database_name, actor):
|
||||
queries = {}
|
||||
for more_queries in pm.hook.canned_queries(
|
||||
datasette=self,
|
||||
database=database_name,
|
||||
actor=actor,
|
||||
):
|
||||
more_queries = await await_me_maybe(more_queries)
|
||||
queries.update(more_queries or {})
|
||||
# Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
|
||||
for key in queries:
|
||||
if not isinstance(queries[key], dict):
|
||||
queries[key] = {"sql": queries[key]}
|
||||
# Also make sure "name" is available:
|
||||
queries[key]["name"] = key
|
||||
return queries
|
||||
|
||||
async def get_canned_query(self, database_name, query_name, actor):
|
||||
queries = await self.get_canned_queries(database_name, actor)
|
||||
query = queries.get(query_name)
|
||||
if query:
|
||||
return query
|
||||
return self.static_hash("app.css")
|
||||
|
||||
def _prepare_connection(self, conn, database):
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
|
@ -1584,46 +1829,124 @@ class Datasette:
|
|||
# For global actions, resource can be omitted:
|
||||
can_debug = await datasette.allowed(action="permissions-debug", actor=actor)
|
||||
"""
|
||||
from datasette.utils.actions_sql import check_permission_for_resource
|
||||
results = await self.allowed_many(
|
||||
actions=[action], resource=resource, actor=actor
|
||||
)
|
||||
return results[action]
|
||||
|
||||
# For global actions, resource remains None
|
||||
async def allowed_many(
|
||||
self,
|
||||
*,
|
||||
actions: Sequence[str],
|
||||
resource: "Resource" = None,
|
||||
actor: dict | None = None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Check several actions against one resource for one actor.
|
||||
|
||||
# Check if this action has also_requires - if so, check that action first
|
||||
action_obj = self.actions.get(action)
|
||||
if action_obj and action_obj.also_requires:
|
||||
# Must have the required action first
|
||||
if not await self.allowed(
|
||||
action=action_obj.also_requires,
|
||||
resource=resource,
|
||||
Resolves every action (plus any also_requires dependencies) with a
|
||||
single internal database query, instead of one or two queries per
|
||||
action. Results are stored in the request-scoped permission cache,
|
||||
so subsequent datasette.allowed() calls for the same checks within
|
||||
the same request are served from the cache.
|
||||
|
||||
Example:
|
||||
from datasette.resources import TableResource
|
||||
results = await datasette.allowed_many(
|
||||
actions=["edit-schema", "drop-table", "insert-row"],
|
||||
resource=TableResource(database="data", table="exercise"),
|
||||
actor=actor,
|
||||
):
|
||||
return False
|
||||
)
|
||||
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
||||
"""
|
||||
from datasette.utils.actions_sql import check_permissions_for_actions
|
||||
from datasette.permissions import (
|
||||
_permission_check_cache,
|
||||
_skip_permission_checks,
|
||||
)
|
||||
|
||||
# For global actions, resource is None
|
||||
parent = resource.parent if resource else None
|
||||
child = resource.child if resource else None
|
||||
|
||||
result = await check_permission_for_resource(
|
||||
datasette=self,
|
||||
actor=actor,
|
||||
action=action,
|
||||
parent=parent,
|
||||
child=child,
|
||||
)
|
||||
# Expand also_requires dependencies (transitively) so that each
|
||||
# dependency is resolved within the same batch
|
||||
expanded = []
|
||||
|
||||
# Log the permission check for debugging
|
||||
self._permission_checks.append(
|
||||
PermissionCheck(
|
||||
when=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
def add_action(name):
|
||||
if name in expanded:
|
||||
return
|
||||
action_obj = self.actions.get(name)
|
||||
if action_obj is None:
|
||||
raise ValueError(f"Unknown action: {name}")
|
||||
expanded.append(name)
|
||||
if action_obj.also_requires:
|
||||
add_action(action_obj.also_requires)
|
||||
|
||||
requested = list(dict.fromkeys(actions))
|
||||
for name in requested:
|
||||
add_action(name)
|
||||
|
||||
# Consult the request-scoped cache, unless permission checks are
|
||||
# being skipped (skip-mode verdicts must never be cached)
|
||||
skip = _skip_permission_checks.get()
|
||||
cache = None if skip else _permission_check_cache.get()
|
||||
|
||||
final = {}
|
||||
to_check = []
|
||||
for name in expanded:
|
||||
if cache is not None:
|
||||
key = _permission_cache_key(actor, name, parent, child)
|
||||
if key in cache:
|
||||
final[name] = cache[key]
|
||||
continue
|
||||
to_check.append(name)
|
||||
|
||||
raw = {}
|
||||
if to_check:
|
||||
raw = await check_permissions_for_actions(
|
||||
datasette=self,
|
||||
actor=actor,
|
||||
action=action,
|
||||
actions=to_check,
|
||||
parent=parent,
|
||||
child=child,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
def resolve(name):
|
||||
# final verdict = own rules AND verdict of also_requires chain
|
||||
if name in final:
|
||||
return final[name]
|
||||
result = raw[name]
|
||||
action_obj = self.actions.get(name)
|
||||
if result and action_obj.also_requires:
|
||||
result = resolve(action_obj.also_requires)
|
||||
final[name] = result
|
||||
return result
|
||||
|
||||
for name in expanded:
|
||||
resolve(name)
|
||||
|
||||
# Cache the freshly computed checks
|
||||
if cache is not None:
|
||||
for name in to_check:
|
||||
cache[_permission_cache_key(actor, name, parent, child)] = final[name]
|
||||
|
||||
# Log every check (including cache hits) for the debug page,
|
||||
# dependencies before the actions that required them
|
||||
when = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
for name in reversed(expanded):
|
||||
self._permission_checks.append(
|
||||
PermissionCheck(
|
||||
when=when,
|
||||
actor=actor,
|
||||
action=name,
|
||||
parent=parent,
|
||||
child=child,
|
||||
result=final[name],
|
||||
)
|
||||
)
|
||||
|
||||
return {name: final[name] for name in requested}
|
||||
|
||||
async def ensure_permission(
|
||||
self,
|
||||
|
|
@ -1696,6 +2019,11 @@ class Datasette:
|
|||
|
||||
other_table = fk["other_table"]
|
||||
other_column = fk["other_column"]
|
||||
if other_column is None:
|
||||
other_pks = await db.primary_keys(other_table)
|
||||
if len(other_pks) != 1:
|
||||
return {}
|
||||
other_column = other_pks[0]
|
||||
visible, _ = await self.check_visibility(
|
||||
actor,
|
||||
action="view-table",
|
||||
|
|
@ -1992,6 +2320,8 @@ class Datasette:
|
|||
and "ds_actor" in request.cookies
|
||||
and request.actor,
|
||||
"app_css_hash": self.app_css_hash(),
|
||||
"edit_tools_js_hash": self.static_hash("edit-tools.js"),
|
||||
"table_js_hash": self.static_hash("table.js"),
|
||||
"zip": zip,
|
||||
"body_scripts": body_scripts,
|
||||
"format_bytes": format_bytes,
|
||||
|
|
@ -2173,8 +2503,12 @@ class Datasette:
|
|||
r"/-/api$",
|
||||
)
|
||||
add_route(
|
||||
TablesView.as_view(self),
|
||||
r"/-/tables(\.(?P<format>json))?$",
|
||||
JumpView.as_view(self),
|
||||
r"/-/jump(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
GlobalQueryListView.as_view(self),
|
||||
r"/-/queries(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
InstanceSchemaView.as_view(self),
|
||||
|
|
@ -2212,6 +2546,10 @@ class Datasette:
|
|||
wrap_view(PatternPortfolioView, self),
|
||||
r"/-/patterns$",
|
||||
)
|
||||
add_route(
|
||||
AutocompleteDebugView.as_view(self),
|
||||
r"/-/debug/autocomplete$",
|
||||
)
|
||||
add_route(
|
||||
wrap_view(database_download, self),
|
||||
r"/(?P<database>[^\/\.]+)\.db$",
|
||||
|
|
@ -2221,14 +2559,54 @@ class Datasette:
|
|||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
)
|
||||
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
||||
add_route(
|
||||
QueryListView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
QueryCreateAnalyzeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries/analyze$",
|
||||
)
|
||||
add_route(
|
||||
QueryStoreView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries/store$",
|
||||
)
|
||||
add_route(
|
||||
ExecuteWriteAnalyzeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/execute-write/analyze$",
|
||||
)
|
||||
add_route(
|
||||
ExecuteWriteView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/execute-write$",
|
||||
)
|
||||
add_route(
|
||||
DatabaseSchemaView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
||||
)
|
||||
add_route(
|
||||
QueryParametersView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
|
||||
)
|
||||
add_route(
|
||||
wrap_view(QueryView, self),
|
||||
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
||||
)
|
||||
add_route(
|
||||
QueryDefinitionView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
|
||||
)
|
||||
add_route(
|
||||
QueryEditView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/edit$",
|
||||
)
|
||||
add_route(
|
||||
QueryUpdateView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
|
||||
)
|
||||
add_route(
|
||||
QueryDeleteView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
|
||||
)
|
||||
add_route(
|
||||
wrap_view(table_view, self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
|
|
@ -2249,6 +2627,14 @@ class Datasette:
|
|||
TableSetColumnTypeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
||||
)
|
||||
add_route(
|
||||
TableFragmentView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
|
||||
)
|
||||
add_route(
|
||||
TableAutocompleteView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/autocomplete$",
|
||||
)
|
||||
add_route(
|
||||
TableDropView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
|
||||
|
|
@ -2310,10 +2696,13 @@ class Datasette:
|
|||
if not database.is_mutable:
|
||||
await database.table_counts(limit=60 * 60 * 1000)
|
||||
|
||||
async def _close_on_shutdown():
|
||||
self.close()
|
||||
|
||||
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self)
|
||||
if self.setting("trace_debug"):
|
||||
asgi = AsgiTracer(asgi)
|
||||
asgi = AsgiLifespan(asgi)
|
||||
asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown])
|
||||
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup])
|
||||
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
||||
asgi = wrapper(asgi)
|
||||
|
|
@ -2332,7 +2721,16 @@ class DatasetteRouter:
|
|||
if raw_path:
|
||||
path = raw_path.decode("ascii")
|
||||
path = path.partition("?")[0]
|
||||
return await self.route_path(scope, receive, send, path)
|
||||
# Give each request a fresh permission check cache, so repeated
|
||||
# datasette.allowed() checks within the request are memoized but
|
||||
# results never persist beyond it
|
||||
from datasette.permissions import _permission_check_cache
|
||||
|
||||
cache_token = _permission_check_cache.set({})
|
||||
try:
|
||||
return await self.route_path(scope, receive, send, path)
|
||||
finally:
|
||||
_permission_check_cache.reset(cache_token)
|
||||
|
||||
async def route_path(self, scope, receive, send, path):
|
||||
# Strip off base_url if present before routing
|
||||
|
|
@ -2595,19 +2993,22 @@ def wrap_view_function(view_fn, datasette):
|
|||
|
||||
|
||||
def permanent_redirect(path, forward_query_string=False, forward_rest=False):
|
||||
return wrap_view(
|
||||
lambda request, send: Response.redirect(
|
||||
def view(request, send):
|
||||
redirect_path = (
|
||||
path
|
||||
+ (request.url_vars["rest"] if forward_rest else "")
|
||||
+ (
|
||||
("?" + request.query_string)
|
||||
if forward_query_string and request.query_string
|
||||
else ""
|
||||
),
|
||||
status=301,
|
||||
),
|
||||
datasette=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
route_path = request.scope.get("route_path")
|
||||
if route_path and request.path.endswith(route_path):
|
||||
redirect_path = request.path[: -len(route_path)] + redirect_path
|
||||
return Response.redirect(redirect_path, status=301)
|
||||
|
||||
return wrap_view(view, datasette=None)
|
||||
|
||||
|
||||
_curly_re = re.compile(r"({.*?})")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import asyncio
|
||||
import atexit
|
||||
from collections import namedtuple
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import janus
|
||||
import queue
|
||||
import sqlite_utils
|
||||
import sys
|
||||
|
|
@ -25,14 +25,24 @@ 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"))
|
||||
|
||||
|
||||
class DatasetteClosedError(RuntimeError):
|
||||
"""Raised when using a Datasette or Database instance after close()."""
|
||||
|
||||
|
||||
_SHUTDOWN = object()
|
||||
|
||||
|
||||
class Database:
|
||||
# For table counts stop at this many rows:
|
||||
count_limit = 10000
|
||||
|
|
@ -75,6 +85,9 @@ class Database:
|
|||
self._cached_table_counts = None
|
||||
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
|
||||
|
|
@ -83,6 +96,16 @@ class Database:
|
|||
if not is_temp_disk:
|
||||
self.mode = mode
|
||||
|
||||
def _check_not_closed(self):
|
||||
if self._closed:
|
||||
raise DatasetteClosedError(
|
||||
"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:
|
||||
|
|
@ -148,9 +171,62 @@ class Database:
|
|||
return conn
|
||||
|
||||
def close(self):
|
||||
# Close all connections - useful to avoid running out of file handles in tests
|
||||
"""Release all resources held by this database.
|
||||
|
||||
Idempotent. After close() further calls to execute()/execute_fn()/
|
||||
execute_write()/execute_write_fn() raise DatasetteClosedError.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
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.
|
||||
write_thread = self._write_thread
|
||||
if write_thread is not None and self._write_queue is not None:
|
||||
self._write_queue.put(_SHUTDOWN)
|
||||
write_thread.join(timeout=10)
|
||||
if write_thread.is_alive():
|
||||
sys.stderr.write(
|
||||
"Datasette: write thread for {!r} did not exit within 10s\n".format(
|
||||
self.name
|
||||
)
|
||||
)
|
||||
sys.stderr.flush()
|
||||
for future in pending_execute_futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
pass
|
||||
# Close anything still tracked in _all_file_connections
|
||||
for connection in self._all_file_connections:
|
||||
connection.close()
|
||||
try:
|
||||
connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._all_file_connections = []
|
||||
# Drop per-thread cached read connections we can reach
|
||||
try:
|
||||
delattr(connections, self._thread_local_id)
|
||||
except AttributeError:
|
||||
pass
|
||||
# Close non-threaded-mode cached connections if still open
|
||||
if self._read_connection is not None:
|
||||
try:
|
||||
self._read_connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._read_connection = None
|
||||
if self._write_connection is not None:
|
||||
try:
|
||||
self._write_connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._write_connection = None
|
||||
if self.is_temp_disk:
|
||||
self._cleanup_temp_file()
|
||||
|
||||
|
|
@ -162,15 +238,32 @@ 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)
|
||||
return results
|
||||
|
||||
async def execute_write_script(self, sql, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
def _inner(conn):
|
||||
return conn.executescript(sql)
|
||||
|
||||
|
|
@ -181,6 +274,8 @@ class Database:
|
|||
return results
|
||||
|
||||
async def execute_write_many(self, sql, params_seq, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
def _inner(conn):
|
||||
count = 0
|
||||
|
||||
|
|
@ -202,13 +297,15 @@ class Database:
|
|||
return results
|
||||
|
||||
async def execute_isolated_fn(self, fn):
|
||||
# Open a new connection just for the duration of this function
|
||||
self._check_not_closed()
|
||||
# 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:
|
||||
|
|
@ -216,12 +313,28 @@ 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()
|
||||
pending_events = []
|
||||
|
||||
def track_event(event):
|
||||
|
|
@ -248,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
|
||||
|
|
@ -263,15 +379,21 @@ class Database:
|
|||
def _wrap_fn_with_hooks(self, fn, request, transaction, track_event):
|
||||
from .plugins import pm
|
||||
|
||||
# Wrap fn so it receives track_event if its signature supports it
|
||||
# Wrap fn so it receives track_event if its signature supports it.
|
||||
# Historically fn was called positionally, so any single-parameter
|
||||
# name (conn, connection, db, ...) worked. Preserve that by only
|
||||
# switching to keyword dependency injection when the callback
|
||||
# explicitly opts in by declaring a `track_event` parameter.
|
||||
original_fn = fn
|
||||
|
||||
def fn_with_track_event(conn):
|
||||
return call_with_supported_arguments(
|
||||
original_fn, conn=conn, track_event=track_event
|
||||
)
|
||||
if "track_event" in inspect.signature(original_fn).parameters:
|
||||
|
||||
fn = fn_with_track_event
|
||||
def fn_with_track_event(conn):
|
||||
return call_with_supported_arguments(
|
||||
original_fn, conn=conn, track_event=track_event
|
||||
)
|
||||
|
||||
fn = fn_with_track_event
|
||||
|
||||
wrappers = pm.hook.write_wrapper(
|
||||
datasette=self.ds,
|
||||
|
|
@ -302,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
|
||||
|
|
@ -327,17 +446,22 @@ class Database:
|
|||
conn_exception = e
|
||||
while True:
|
||||
task = self._write_queue.get()
|
||||
if task is _SHUTDOWN:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
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:
|
||||
|
|
@ -345,20 +469,25 @@ 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()
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
if self._read_connection is None:
|
||||
|
|
@ -375,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,
|
||||
|
|
@ -389,6 +521,7 @@ class Database:
|
|||
log_sql_errors=True,
|
||||
):
|
||||
"""Executes sql against db_name in a thread"""
|
||||
self._check_not_closed()
|
||||
page_size = page_size or self.ds.page_size
|
||||
|
||||
def sql_operation_in_thread(conn):
|
||||
|
|
@ -594,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:
|
||||
|
|
@ -772,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
|
||||
|
|
@ -792,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
|
||||
|
|
@ -816,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,26 @@ 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="store-query",
|
||||
abbr="sq",
|
||||
description="Create stored queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="execute-sql",
|
||||
),
|
||||
# Table-level actions (child-level)
|
||||
Action(
|
||||
name="view-table",
|
||||
|
|
@ -104,4 +118,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
|
||||
118
datasette/extras.py
Normal file
118
datasette/extras.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import ClassVar
|
||||
|
||||
from asyncinject import Registry
|
||||
|
||||
|
||||
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 _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
|
||||
|
||||
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()
|
||||
|
|
@ -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,10 @@ 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_query_actions",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
"datasette.events",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.utils import (
|
||||
value_as_boolean,
|
||||
remove_infinites,
|
||||
|
|
@ -108,7 +109,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
|
||||
# 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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -362,6 +362,32 @@ form.nav-menu-logout {
|
|||
.nav-menu-inner a {
|
||||
display: block;
|
||||
}
|
||||
.nav-menu-inner button.button-as-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.nav-menu-inner .keyboard-shortcut {
|
||||
float: right;
|
||||
box-sizing: border-box;
|
||||
min-width: 1.4em;
|
||||
margin-left: 0.75rem;
|
||||
padding: 0 0.35em;
|
||||
border: 1px solid rgba(255,255,244,0.6);
|
||||
border-radius: 3px;
|
||||
background: rgba(255,255,244,0.12);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.nav-menu-inner .keyboard-shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table/database actions menu */
|
||||
.page-action-menu {
|
||||
|
|
@ -680,6 +706,11 @@ button.core[type=button] {
|
|||
color: #666;
|
||||
padding-right: 0.25em;
|
||||
}
|
||||
/* The label may wrap (word-break: break-all on the li) but the count should
|
||||
stay on one line - https://github.com/simonw/datasette/issues/2754 */
|
||||
.facet-count {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.facet-info li,
|
||||
.facet-info ul {
|
||||
margin: 0;
|
||||
|
|
@ -761,9 +792,9 @@ p.zero-results {
|
|||
|
||||
dialog.mobile-column-actions-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
|
|
@ -818,7 +849,8 @@ dialog.mobile-column-actions-dialog::backdrop {
|
|||
}
|
||||
|
||||
.mobile-column-actions-dialog .list-wrap {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
|
|
@ -988,9 +1020,9 @@ dialog.mobile-column-actions-dialog::backdrop {
|
|||
|
||||
dialog.set-column-type-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
|
|
@ -1077,7 +1109,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
padding: 14px 16px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
background: #fcfbf9;
|
||||
background: #fbfdff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
@ -1160,6 +1192,607 @@ dialog.set-column-type-dialog::backdrop {
|
|||
cursor: wait;
|
||||
}
|
||||
|
||||
.row-mutation-status {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 8px 10px;
|
||||
border-left: 4px solid #54AC8E;
|
||||
background: rgba(103,201,141,0.12);
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.row-mutation-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-mutation-status-error {
|
||||
border-left-color: #D0021B;
|
||||
background: rgba(208,2,27,0.12);
|
||||
}
|
||||
|
||||
.table-row-toolbar {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
button.table-insert-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
button.table-insert-row svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
max-width: 95vw;
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-delete-message,
|
||||
.row-delete-error {
|
||||
margin: 0;
|
||||
padding: 16px 24px 0;
|
||||
}
|
||||
|
||||
.row-delete-message {
|
||||
color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.row-delete-id {
|
||||
display: inline;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-delete-error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-footer {
|
||||
padding: 18px 20px 14px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-primary:hover {
|
||||
background: #1949b8;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-width: 95vw;
|
||||
max-height: min(780px, calc(100vh - 32px));
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title .row-dialog-action,
|
||||
.row-delete-dialog .modal-title .row-dialog-action {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title code,
|
||||
.row-delete-dialog .modal-title code {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title .row-dialog-label,
|
||||
.row-delete-dialog .modal-title .row-dialog-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-edit-form {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-error {
|
||||
margin: 0;
|
||||
padding: 12px 24px 0;
|
||||
}
|
||||
|
||||
.row-edit-summary,
|
||||
.row-edit-loading {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-edit-error {
|
||||
border-left: 4px solid #b91c1c;
|
||||
border-radius: 4px;
|
||||
background: #fff1f1;
|
||||
color: #7f1d1d;
|
||||
font-size: 0.9rem;
|
||||
margin: 12px 24px 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.row-edit-error:focus {
|
||||
outline: 3px solid rgba(185, 28, 28, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.row-edit-fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.row-edit-label {
|
||||
padding-top: 8px;
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-control-wrap {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea.row-edit-input {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.row-edit-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.row-edit-input[aria-invalid="true"] {
|
||||
border-color: #b42318;
|
||||
background: #fff8f7;
|
||||
}
|
||||
|
||||
.row-edit-input[aria-invalid="true"]:focus {
|
||||
border-color: #b42318;
|
||||
outline-color: rgba(180, 35, 24, 0.16);
|
||||
}
|
||||
|
||||
.row-edit-input[readonly] {
|
||||
color: var(--muted);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-default {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7.25rem;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 7px 8px 7px 10px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-default[hidden],
|
||||
.row-edit-custom-value[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-default-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-default-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.row-edit-custom-value {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7.25rem;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 45px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.row-edit-default-button {
|
||||
appearance: none;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.2;
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.row-edit-default-button:hover,
|
||||
.row-edit-default-button:focus {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.row-edit-default-button:focus {
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row-edit-field-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.row-edit-field-validation-error {
|
||||
color: #b42318;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.row-edit-field-validation-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-field-meta-autocomplete {
|
||||
line-height: 1.2;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.row-edit-fk-pk {
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.row-edit-fk-link {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
datasette-autocomplete {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
datasette-autocomplete input[type="text"],
|
||||
.debug-autocomplete-form input[type="text"] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-list {
|
||||
background: #fff;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
right: auto;
|
||||
top: auto;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-list[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-option {
|
||||
cursor: pointer;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-option:hover,
|
||||
.datasette-autocomplete-option[aria-selected="true"] {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.datasette-autocomplete-option[aria-selected="true"] {
|
||||
background: var(--paper);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-status {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.debug-autocomplete-demo {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.debug-autocomplete-selected {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-primary:hover {
|
||||
background: #1949b8;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row-link-with-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row-inline-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-inline-action {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(74, 85, 104, 0.24);
|
||||
background: transparent;
|
||||
color: #4a5568;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row-inline-action:hover,
|
||||
.row-inline-action:focus {
|
||||
background: rgba(74, 85, 104, 0.07);
|
||||
}
|
||||
|
||||
.row-inline-action:focus {
|
||||
outline: 3px solid #b3d4ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row-inline-action-icon {
|
||||
display: block;
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
dialog.mobile-column-actions-dialog {
|
||||
width: 95vw;
|
||||
|
|
@ -1207,6 +1840,68 @@ dialog.set-column-type-dialog::backdrop {
|
|||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-header,
|
||||
.row-delete-message,
|
||||
.row-delete-error {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-header,
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-fields {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-edit-error {
|
||||
margin-left: 18px;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-inline-action {
|
||||
min-height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.row-inline-action-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
@ -1261,6 +1956,10 @@ dialog.set-column-type-dialog::backdrop {
|
|||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.row-inline-actions {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
width: 100px;
|
||||
}
|
||||
|
|
@ -1271,6 +1970,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
width: 140px;
|
||||
}
|
||||
button.choose-columns-mobile,
|
||||
button.table-insert-row,
|
||||
button.column-actions-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1307,6 +2007,15 @@ dialog.set-column-type-dialog::backdrop {
|
|||
button.choose-columns-mobile {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.table-row-toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
button.table-insert-row {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
svg.dropdown-menu-icon {
|
||||
|
|
@ -1352,18 +2061,32 @@ svg.dropdown-menu-icon {
|
|||
.dropdown-menu a:link,
|
||||
.dropdown-menu a:visited,
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu a:focus
|
||||
.dropdown-menu a:active {
|
||||
.dropdown-menu a:focus,
|
||||
.dropdown-menu a:active,
|
||||
.dropdown-menu button.action-menu-button {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 4px 8px 2px 8px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropdown-menu a:hover {
|
||||
.dropdown-menu button.action-menu-button {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu button.action-menu-button:hover,
|
||||
.dropdown-menu button.action-menu-button:focus {
|
||||
background-color: #eee;
|
||||
}
|
||||
.dropdown-menu .dropdown-description {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
|
|
@ -1382,11 +2105,15 @@ svg.dropdown-menu-icon {
|
|||
border-bottom: 5px solid #666;
|
||||
}
|
||||
|
||||
.canned-query-edit-sql {
|
||||
.stored-query-edit-sql {
|
||||
padding-left: 0.5em;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.save-query {
|
||||
display: inline-block;
|
||||
margin-left: 0.45em;
|
||||
}
|
||||
|
||||
.blob-download {
|
||||
display: block;
|
||||
|
|
|
|||
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,
|
||||
|
|
|
|||
1995
datasette/static/edit-tools.js
Normal file
1995
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);
|
||||
}
|
||||
|
|
|
|||
581
datasette/stored_queries.py
Normal file
581
datasette/stored_queries.py
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
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,
|
||||
"params": list(query.parameters),
|
||||
"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,
|
||||
"has_more": page.has_more,
|
||||
"limit": page.limit,
|
||||
}
|
||||
|
||||
|
||||
async def save_queries_from_config(datasette: Any) -> None:
|
||||
# Apply configured query entries from datasette.yaml to the internal table.
|
||||
await datasette.get_internal_database().execute_write(
|
||||
"DELETE FROM queries WHERE source = 'config'"
|
||||
)
|
||||
for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items():
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
if not isinstance(query_config, dict):
|
||||
query_config = {"sql": query_config}
|
||||
await datasette.add_query(
|
||||
dbname,
|
||||
query_name,
|
||||
query_config["sql"],
|
||||
title=query_config.get("title"),
|
||||
description=query_config.get("description"),
|
||||
description_html=query_config.get("description_html"),
|
||||
hide_sql=bool(query_config.get("hide_sql")),
|
||||
fragment=query_config.get("fragment"),
|
||||
parameters=query_config.get("params"),
|
||||
is_write=bool(query_config.get("write")),
|
||||
is_trusted=bool(query_config.get("is_trusted", True)),
|
||||
source="config",
|
||||
on_success_message=query_config.get("on_success_message"),
|
||||
on_success_message_sql=query_config.get("on_success_message_sql"),
|
||||
on_success_redirect=query_config.get("on_success_redirect"),
|
||||
on_error_message=query_config.get("on_error_message"),
|
||||
on_error_redirect=query_config.get("on_error_redirect"),
|
||||
)
|
||||
|
||||
|
||||
def query_row_to_stored_query(
|
||||
row: Any, private: bool | None = None
|
||||
) -> StoredQuery | None:
|
||||
if row is None:
|
||||
return None
|
||||
parameters = json.loads(row["parameters"] or "[]")
|
||||
options = json.loads(row["options"] or "{}")
|
||||
return StoredQuery(
|
||||
database=row["database_name"],
|
||||
name=row["name"],
|
||||
sql=row["sql"],
|
||||
title=row["title"],
|
||||
description=row["description"],
|
||||
description_html=row["description_html"],
|
||||
hide_sql=bool(options.get("hide_sql")),
|
||||
fragment=options.get("fragment"),
|
||||
parameters=parameters,
|
||||
is_write=bool(row["is_write"]),
|
||||
is_private=bool(row["is_private"]),
|
||||
is_trusted=bool(row["is_trusted"]),
|
||||
source=row["source"],
|
||||
owner_id=row["owner_id"],
|
||||
on_success_message=options.get("on_success_message"),
|
||||
on_success_message_sql=options.get("on_success_message_sql"),
|
||||
on_success_redirect=options.get("on_success_redirect"),
|
||||
on_error_message=options.get("on_error_message"),
|
||||
on_error_redirect=options.get("on_error_redirect"),
|
||||
private=private,
|
||||
)
|
||||
|
||||
|
||||
def query_options_json(options: dict[str, Any]) -> str:
|
||||
options_dict = {}
|
||||
for field in QUERY_OPTION_FIELDS:
|
||||
value = options.get(field)
|
||||
if field == "hide_sql":
|
||||
if value:
|
||||
options_dict[field] = True
|
||||
elif value is not None:
|
||||
options_dict[field] = value
|
||||
return json.dumps(options_dict, sort_keys=True)
|
||||
|
||||
|
||||
async def add_query(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
name: str,
|
||||
sql: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
description_html: str | None = None,
|
||||
hide_sql: bool = False,
|
||||
fragment: str | None = None,
|
||||
parameters: Iterable[str] | None = None,
|
||||
is_write: bool = False,
|
||||
is_private: bool = False,
|
||||
is_trusted: bool = False,
|
||||
source: str = "plugin",
|
||||
owner_id: str | None = None,
|
||||
on_success_message: str | None = None,
|
||||
on_success_message_sql: str | None = None,
|
||||
on_success_redirect: str | None = None,
|
||||
on_error_message: str | None = None,
|
||||
on_error_redirect: str | None = None,
|
||||
replace: bool = True,
|
||||
) -> None:
|
||||
parameters_json = json.dumps(list(parameters or []))
|
||||
options_json = query_options_json(
|
||||
{
|
||||
"hide_sql": hide_sql,
|
||||
"fragment": fragment,
|
||||
"on_success_message": on_success_message,
|
||||
"on_success_message_sql": on_success_message_sql,
|
||||
"on_success_redirect": on_success_redirect,
|
||||
"on_error_message": on_error_message,
|
||||
"on_error_redirect": on_error_redirect,
|
||||
}
|
||||
)
|
||||
sql_statement = """
|
||||
INSERT INTO queries (
|
||||
database_name, name, sql, title, description, description_html,
|
||||
options, parameters, is_write, is_private, is_trusted, source, owner_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
if replace:
|
||||
sql_statement += """
|
||||
ON CONFLICT(database_name, name) DO UPDATE SET
|
||||
sql = excluded.sql,
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
description_html = excluded.description_html,
|
||||
options = excluded.options,
|
||||
parameters = excluded.parameters,
|
||||
is_write = excluded.is_write,
|
||||
is_private = excluded.is_private,
|
||||
is_trusted = excluded.is_trusted,
|
||||
source = excluded.source,
|
||||
owner_id = excluded.owner_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
await datasette.get_internal_database().execute_write(
|
||||
sql_statement,
|
||||
[
|
||||
database,
|
||||
name,
|
||||
sql,
|
||||
title,
|
||||
description,
|
||||
description_html,
|
||||
options_json,
|
||||
parameters_json,
|
||||
int(bool(is_write)),
|
||||
int(bool(is_private)),
|
||||
int(bool(is_trusted)),
|
||||
source,
|
||||
owner_id,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def update_query(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
name: str,
|
||||
*,
|
||||
sql=UNCHANGED,
|
||||
title=UNCHANGED,
|
||||
description=UNCHANGED,
|
||||
description_html=UNCHANGED,
|
||||
hide_sql=UNCHANGED,
|
||||
fragment=UNCHANGED,
|
||||
parameters=UNCHANGED,
|
||||
is_write=UNCHANGED,
|
||||
is_private=UNCHANGED,
|
||||
is_trusted=UNCHANGED,
|
||||
source=UNCHANGED,
|
||||
owner_id=UNCHANGED,
|
||||
on_success_message=UNCHANGED,
|
||||
on_success_message_sql=UNCHANGED,
|
||||
on_success_redirect=UNCHANGED,
|
||||
on_error_message=UNCHANGED,
|
||||
on_error_redirect=UNCHANGED,
|
||||
) -> None:
|
||||
fields = {
|
||||
"sql": sql,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"description_html": description_html,
|
||||
"parameters": parameters,
|
||||
"is_write": is_write,
|
||||
"is_private": is_private,
|
||||
"is_trusted": is_trusted,
|
||||
"source": source,
|
||||
"owner_id": owner_id,
|
||||
}
|
||||
option_fields = {
|
||||
"hide_sql": hide_sql,
|
||||
"fragment": fragment,
|
||||
"on_success_message": on_success_message,
|
||||
"on_success_message_sql": on_success_message_sql,
|
||||
"on_success_redirect": on_success_redirect,
|
||||
"on_error_message": on_error_message,
|
||||
"on_error_redirect": on_error_redirect,
|
||||
}
|
||||
updates = []
|
||||
params = []
|
||||
for field, value in fields.items():
|
||||
if value is UNCHANGED:
|
||||
continue
|
||||
if field in {"is_write", "is_private", "is_trusted"}:
|
||||
value = int(bool(value))
|
||||
elif field == "parameters":
|
||||
value = json.dumps(list(value or []))
|
||||
updates.append(f"{field} = ?")
|
||||
params.append(value)
|
||||
changed_options = {
|
||||
field: value for field, value in option_fields.items() if value is not UNCHANGED
|
||||
}
|
||||
if changed_options:
|
||||
rows = await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT options FROM queries
|
||||
WHERE database_name = ? AND name = ?
|
||||
""",
|
||||
[database, name],
|
||||
)
|
||||
row = rows.first()
|
||||
options = json.loads(row["options"] or "{}") if row is not None else {}
|
||||
for field, value in changed_options.items():
|
||||
if field == "hide_sql":
|
||||
if value:
|
||||
options[field] = True
|
||||
else:
|
||||
options.pop(field, None)
|
||||
elif value is None:
|
||||
options.pop(field, None)
|
||||
else:
|
||||
options[field] = value
|
||||
updates.append("options = ?")
|
||||
params.append(json.dumps(options, sort_keys=True))
|
||||
if not updates:
|
||||
return
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.extend([database, name])
|
||||
await datasette.get_internal_database().execute_write(
|
||||
"""
|
||||
UPDATE queries
|
||||
SET {}
|
||||
WHERE database_name = ? AND name = ?
|
||||
""".format(", ".join(updates)),
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
async def remove_query(
|
||||
datasette: Any, database: str, name: str, source: str | None = None
|
||||
) -> None:
|
||||
sql = "DELETE FROM queries WHERE database_name = ? AND name = ?"
|
||||
params = [database, name]
|
||||
if source is not None:
|
||||
sql += " AND source = ?"
|
||||
params.append(source)
|
||||
await datasette.get_internal_database().execute_write(sql, params)
|
||||
|
||||
|
||||
async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None:
|
||||
rows = await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT * FROM queries
|
||||
WHERE database_name = ? AND name = ?
|
||||
""",
|
||||
[database, name],
|
||||
)
|
||||
return query_row_to_stored_query(rows.first())
|
||||
|
||||
|
||||
async def count_queries(
|
||||
datasette: Any,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
) -> int:
|
||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
||||
action="view-query",
|
||||
actor=actor,
|
||||
parent=database,
|
||||
)
|
||||
params = dict(allowed_params)
|
||||
where_clauses = []
|
||||
if database is not None:
|
||||
params["query_database"] = database
|
||||
where_clauses.append("q.database_name = :query_database")
|
||||
|
||||
if q:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.name LIKE :query_search
|
||||
OR q.title LIKE :query_search
|
||||
OR q.description LIKE :query_search
|
||||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
if is_private is not None:
|
||||
where_clauses.append("q.is_private = :query_is_private")
|
||||
params["query_is_private"] = int(bool(is_private))
|
||||
if is_trusted is not None:
|
||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
||||
params["query_is_trusted"] = int(bool(is_trusted))
|
||||
if source is not None:
|
||||
where_clauses.append("q.source = :query_source")
|
||||
params["query_source"] = source
|
||||
if owner_id is not None:
|
||||
where_clauses.append("q.owner_id = :query_owner_id")
|
||||
params["query_owner_id"] = owner_id
|
||||
|
||||
row = (
|
||||
await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM queries q
|
||||
JOIN (
|
||||
{allowed_sql}
|
||||
) allowed
|
||||
ON allowed.parent = q.database_name
|
||||
AND allowed.child = q.name
|
||||
WHERE {where}
|
||||
""".format(
|
||||
allowed_sql=allowed_sql,
|
||||
where=" AND ".join(where_clauses) or "1 = 1",
|
||||
),
|
||||
params,
|
||||
)
|
||||
).first()
|
||||
return row["count"]
|
||||
|
||||
|
||||
async def list_queries(
|
||||
datasette: Any,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
limit: int = 50,
|
||||
cursor: str | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
include_private: bool = False,
|
||||
) -> StoredQueryPage:
|
||||
limit = min(max(1, int(limit)), 1000)
|
||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
||||
action="view-query",
|
||||
actor=actor,
|
||||
parent=database,
|
||||
include_is_private=include_private,
|
||||
)
|
||||
params = dict(allowed_params)
|
||||
params.update({"limit": limit + 1})
|
||||
sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))"
|
||||
where_clauses = []
|
||||
order_by = "q.database_name, sort_key, q.name"
|
||||
if database is not None:
|
||||
params["query_database"] = database
|
||||
where_clauses.append("q.database_name = :query_database")
|
||||
order_by = "sort_key, q.name"
|
||||
|
||||
if cursor:
|
||||
try:
|
||||
components = urlsafe_components(cursor)
|
||||
except ValueError:
|
||||
components = []
|
||||
if database is None and len(components) == 3:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.database_name > :cursor_database
|
||||
OR (
|
||||
q.database_name = :cursor_database
|
||||
AND (
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
{sort_key_sql} = :cursor_sort_key
|
||||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_database"] = components[0]
|
||||
params["cursor_sort_key"] = components[1]
|
||||
params["cursor_name"] = components[2]
|
||||
elif database is not None and len(components) == 2:
|
||||
where_clauses.append("""
|
||||
(
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
{sort_key_sql} = :cursor_sort_key
|
||||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_sort_key"] = components[0]
|
||||
params["cursor_name"] = components[1]
|
||||
|
||||
if q:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.name LIKE :query_search
|
||||
OR q.title LIKE :query_search
|
||||
OR q.description LIKE :query_search
|
||||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
if is_private is not None:
|
||||
where_clauses.append("q.is_private = :query_is_private")
|
||||
params["query_is_private"] = int(bool(is_private))
|
||||
if is_trusted is not None:
|
||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
||||
params["query_is_trusted"] = int(bool(is_trusted))
|
||||
if source is not None:
|
||||
where_clauses.append("q.source = :query_source")
|
||||
params["query_source"] = source
|
||||
if owner_id is not None:
|
||||
where_clauses.append("q.owner_id = :query_owner_id")
|
||||
params["query_owner_id"] = owner_id
|
||||
|
||||
private_select = ", allowed.is_private AS private" if include_private else ""
|
||||
rows = list(
|
||||
(
|
||||
await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT q.*, {sort_key_sql} AS sort_key{private_select}
|
||||
FROM queries q
|
||||
JOIN (
|
||||
{allowed_sql}
|
||||
) allowed
|
||||
ON allowed.parent = q.database_name
|
||||
AND allowed.child = q.name
|
||||
WHERE {where}
|
||||
ORDER BY {order_by}
|
||||
LIMIT :limit
|
||||
""".format(
|
||||
allowed_sql=allowed_sql,
|
||||
private_select=private_select,
|
||||
sort_key_sql=sort_key_sql,
|
||||
where=" AND ".join(where_clauses) or "1 = 1",
|
||||
order_by=order_by,
|
||||
),
|
||||
params,
|
||||
)
|
||||
).rows
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
queries = []
|
||||
for row in rows:
|
||||
query = query_row_to_stored_query(
|
||||
row, private=bool(row["private"]) if include_private else None
|
||||
)
|
||||
assert query is not None
|
||||
queries.append(query)
|
||||
|
||||
next_token = None
|
||||
if has_more and rows:
|
||||
last_row = rows[-1]
|
||||
if database is None:
|
||||
next_token = "{},{},{}".format(
|
||||
tilde_encode(last_row["database_name"]),
|
||||
tilde_encode(last_row["sort_key"]),
|
||||
tilde_encode(last_row["name"]),
|
||||
)
|
||||
else:
|
||||
next_token = "{},{}".format(
|
||||
tilde_encode(last_row["sort_key"]),
|
||||
tilde_encode(last_row["name"]),
|
||||
)
|
||||
return StoredQueryPage(
|
||||
queries=queries,
|
||||
next=next_token,
|
||||
has_more=has_more,
|
||||
limit=limit,
|
||||
)
|
||||
|
|
@ -1,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>
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
|
||||
<div class="above-table-panel"> </div>
|
||||
{% if display_rows %}
|
||||
{% if display_columns %}
|
||||
<div class="table-wrapper">
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
{% if not display_rows %}
|
||||
<p class="zero-results">0 records</p>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -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%">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -72,6 +71,6 @@
|
|||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/tables"></navigation-search>
|
||||
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||
|
|
@ -25,9 +26,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 +58,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 %}
|
||||
|
|
@ -87,5 +95,11 @@
|
|||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
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="{{ urls.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 %}
|
||||
299
datasette/templates/execute_write.html
Normal file
299
datasette/templates/execute_write.html
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
{% 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-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_template_tables %}
|
||||
<div class="execute-write-template-menu">
|
||||
<details>
|
||||
<summary>Start with a template</summary>
|
||||
<p class="execute-write-template-controls">
|
||||
<label for="execute-write-template-table">Table</label>
|
||||
<select id="execute-write-template-table">
|
||||
{% for table_name, table in write_template_tables|dictsort %}
|
||||
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% for operation in write_template_operations %}
|
||||
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
|
||||
{% endfor %}
|
||||
</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"><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>
|
||||
|
||||
<script>
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (executeWriteSqlInput && !executeWriteSqlInput.value) {
|
||||
executeWriteSqlInput.value = "\n\n\n";
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const 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_template_tables %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const tableSelect = document.querySelector("#execute-write-template-table");
|
||||
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
||||
|
||||
function dataKey(operation) {
|
||||
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
|
||||
}
|
||||
|
||||
function selectedOption() {
|
||||
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
|
||||
}
|
||||
|
||||
function templateSql(operation) {
|
||||
const option = selectedOption();
|
||||
return option ? option.dataset[dataKey(operation)] || "" : "";
|
||||
}
|
||||
|
||||
function updateTemplateButtons() {
|
||||
templateButtons.forEach((button) => {
|
||||
button.hidden = !templateSql(button.dataset.sqlTemplate);
|
||||
});
|
||||
}
|
||||
|
||||
templateButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const sql = templateSql(button.dataset.sqlTemplate);
|
||||
if (!sql) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sql", sql);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
});
|
||||
if (tableSelect) {
|
||||
tableSelect.addEventListener("change", updateTemplateButtons);
|
||||
}
|
||||
updateTemplateButtons();
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -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,7 +202,7 @@
|
|||
<h3>3 rows
|
||||
where characteristic_id = 2
|
||||
</h3>
|
||||
<form class="filters" action="/fixtures/roadside_attraction_characteristics" method="get">
|
||||
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
|
|
@ -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="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in columns %}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}" defer></script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||
<style>
|
||||
|
|
@ -141,7 +146,6 @@
|
|||
{% if all_columns %}
|
||||
<column-chooser></column-chooser>
|
||||
<button class="choose-columns-mobile small-screen-only" onclick="openColumnChooser()">Choose columns</button>
|
||||
{% if display_rows %}
|
||||
<button type="button" class="column-actions-mobile small-screen-only">
|
||||
<svg 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">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
|
|
@ -149,7 +153,6 @@
|
|||
</svg>
|
||||
<span>Column actions</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<script>
|
||||
window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }};
|
||||
</script>
|
||||
|
|
@ -160,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 %}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,38 @@ class TokenRestrictions:
|
|||
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
||||
return self
|
||||
|
||||
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
|
||||
"""
|
||||
Return the abbreviated ``_r`` dictionary shape for this set of
|
||||
restrictions, using action abbreviations registered with ``datasette``.
|
||||
Returns ``None`` if no restrictions are set.
|
||||
"""
|
||||
if not (self.all or self.database or self.resource):
|
||||
return None
|
||||
|
||||
def abbreviate_action(action):
|
||||
action_obj = datasette.actions.get(action)
|
||||
if not action_obj:
|
||||
return action
|
||||
return action_obj.abbr or action
|
||||
|
||||
result: dict = {}
|
||||
if self.all:
|
||||
result["a"] = [abbreviate_action(a) for a in self.all]
|
||||
if self.database:
|
||||
result["d"] = {
|
||||
database: [abbreviate_action(a) for a in actions]
|
||||
for database, actions in self.database.items()
|
||||
}
|
||||
if self.resource:
|
||||
result["r"] = {}
|
||||
for database, resources in self.resource.items():
|
||||
for resource, actions in resources.items():
|
||||
result["r"].setdefault(database, {})[resource] = [
|
||||
abbreviate_action(a) for a in actions
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
class TokenHandler:
|
||||
"""
|
||||
|
|
@ -104,31 +136,12 @@ class SignedTokenHandler(TokenHandler):
|
|||
|
||||
token = {"a": actor_id, "t": int(time.time())}
|
||||
|
||||
def abbreviate_action(action):
|
||||
action_obj = datasette.actions.get(action)
|
||||
if not action_obj:
|
||||
return action
|
||||
return action_obj.abbr or action
|
||||
|
||||
if expires_after:
|
||||
token["d"] = expires_after
|
||||
if restrictions and (
|
||||
restrictions.all or restrictions.database or restrictions.resource
|
||||
):
|
||||
token["_r"] = {}
|
||||
if restrictions.all:
|
||||
token["_r"]["a"] = [abbreviate_action(a) for a in restrictions.all]
|
||||
if restrictions.database:
|
||||
token["_r"]["d"] = {}
|
||||
for database, actions in restrictions.database.items():
|
||||
token["_r"]["d"][database] = [abbreviate_action(a) for a in actions]
|
||||
if restrictions.resource:
|
||||
token["_r"]["r"] = {}
|
||||
for database, resources in restrictions.resource.items():
|
||||
for resource, actions in resources.items():
|
||||
token["_r"]["r"].setdefault(database, {})[resource] = [
|
||||
abbreviate_action(a) for a in actions
|
||||
]
|
||||
if restrictions is not None:
|
||||
abbreviated = restrictions.abbreviated(datasette)
|
||||
if abbreviated is not None:
|
||||
token["_r"] = abbreviated
|
||||
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
||||
|
||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -155,9 +155,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
|
||||
|
|
@ -404,6 +410,10 @@ def escape_css_string(s):
|
|||
def escape_sqlite(s):
|
||||
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
||||
return s
|
||||
elif "]" in s:
|
||||
# SQLite does not support escaping ] inside [bracket] quoting, so fall
|
||||
# back to double-quote quoting (doubling any embedded ") - #2677
|
||||
return '"{}"'.format(s.replace('"', '""'))
|
||||
else:
|
||||
return f"[{s}]"
|
||||
|
||||
|
|
@ -831,7 +841,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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -155,6 +155,10 @@ class Request:
|
|||
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 +334,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,
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -112,6 +112,28 @@ async def initialize_metadata_tables(db):
|
|||
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);
|
||||
"""))
|
||||
|
||||
|
||||
|
|
|
|||
550
datasette/utils/sql_analysis.py
Normal file
550
datasette/utils/sql_analysis.py
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
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_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 = {
|
||||
(key.database, key.table)
|
||||
for key in operations
|
||||
if key.operation == "drop" and key.target_type == "table"
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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.0a27"
|
||||
__version__ = "1.0a33"
|
||||
__version_info__ = tuple(__version__.split("."))
|
||||
|
|
|
|||
|
|
@ -153,7 +153,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(
|
||||
|
|
@ -347,13 +353,21 @@ class DataView(BaseView):
|
|||
if it_can_render:
|
||||
renderers[key] = self.ds.urls.path(
|
||||
path_with_format(
|
||||
request=request, format=key, extra_qs={**url_labels_extra}
|
||||
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, format="csv", extra_qs=url_csv_args)
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@ import sqlite_utils
|
|||
import textwrap
|
||||
|
||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.resources import DatabaseResource, QueryResource
|
||||
from datasette.stored_queries import stored_query_to_dict
|
||||
from datasette.write_sql import QueryWriteRejected
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
|
|
@ -35,6 +38,12 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
|||
from datasette.plugins import pm
|
||||
|
||||
from .base import BaseView, DatasetteError, View, _error, stream_csv
|
||||
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
||||
from .table_extras import (
|
||||
QueryExtraContext,
|
||||
resolve_query_extras,
|
||||
table_extra_registry,
|
||||
)
|
||||
from . import Context
|
||||
|
||||
|
||||
|
|
@ -57,12 +66,16 @@ class DatabaseView(View):
|
|||
|
||||
sql = (request.args.get("sql") or "").strip()
|
||||
if sql:
|
||||
redirect_url = "/" + request.url_vars.get("database") + "/-/query"
|
||||
redirect_url = datasette.urls.database(database) + "/-/query"
|
||||
if request.url_vars.get("format"):
|
||||
redirect_url += "." + request.url_vars.get("format")
|
||||
redirect_url = path_with_format(
|
||||
path=redirect_url, format=request.url_vars.get("format")
|
||||
)
|
||||
redirect_url += "?" + request.query_string
|
||||
return Response.redirect(redirect_url)
|
||||
return await QueryView()(request, datasette)
|
||||
response = Response.redirect(redirect_url)
|
||||
if datasette.cors:
|
||||
add_cors_headers(response.headers)
|
||||
return response
|
||||
|
||||
if format_ not in ("html", "json"):
|
||||
raise NotFound("Invalid format: {}".format(format_))
|
||||
|
|
@ -90,26 +103,34 @@ class DatabaseView(View):
|
|||
|
||||
tables = await get_tables(datasette, request, db, allowed_dict)
|
||||
|
||||
# Get allowed queries using the new permission system
|
||||
allowed_query_page = await datasette.allowed_resources(
|
||||
"view-query",
|
||||
request.actor,
|
||||
parent=database,
|
||||
include_is_private=True,
|
||||
limit=1000,
|
||||
queries_page = await datasette.list_queries(
|
||||
database,
|
||||
actor=request.actor,
|
||||
limit=5,
|
||||
include_private=True,
|
||||
)
|
||||
stored_queries = queries_page.queries
|
||||
queries_more = queries_page.has_more
|
||||
queries_count = (
|
||||
await datasette.count_queries(database, actor=request.actor)
|
||||
if queries_more
|
||||
else len(stored_queries)
|
||||
)
|
||||
|
||||
# Build canned_queries list by looking up each allowed query
|
||||
all_queries = await datasette.get_canned_queries(database, request.actor)
|
||||
canned_queries = []
|
||||
for query_resource in allowed_query_page.resources:
|
||||
query_name = query_resource.child
|
||||
if query_name in all_queries:
|
||||
canned_queries.append(
|
||||
dict(all_queries[query_name], private=query_resource.private)
|
||||
)
|
||||
|
||||
async def database_actions():
|
||||
# Resolve the registered database-level actions for this
|
||||
# database in one batched query, seeding the request permission
|
||||
# cache so that allowed() calls made inside the plugin hooks
|
||||
# below are served from the cache
|
||||
await datasette.allowed_many(
|
||||
actions=[
|
||||
name
|
||||
for name, action in datasette.actions.items()
|
||||
if action.resource_class is DatabaseResource
|
||||
],
|
||||
resource=DatabaseResource(database),
|
||||
actor=request.actor,
|
||||
)
|
||||
links = []
|
||||
for hook in pm.hook.database_actions(
|
||||
datasette=datasette,
|
||||
|
|
@ -138,7 +159,9 @@ class DatabaseView(View):
|
|||
"tables": tables,
|
||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
||||
"views": sql_views,
|
||||
"queries": canned_queries,
|
||||
"queries": [stored_query_to_dict(query) for query in stored_queries],
|
||||
"queries_more": queries_more,
|
||||
"queries_count": queries_count,
|
||||
"allow_execute_sql": allow_execute_sql,
|
||||
"table_columns": (
|
||||
await _table_columns(datasette, database) if allow_execute_sql else {}
|
||||
|
|
@ -155,7 +178,13 @@ class DatabaseView(View):
|
|||
assert format_ == "html"
|
||||
alternate_url_json = datasette.absolute_url(
|
||||
request,
|
||||
datasette.urls.path(path_with_format(request=request, format="json")),
|
||||
datasette.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format="json",
|
||||
)
|
||||
),
|
||||
)
|
||||
templates = (f"database-{to_css_class(database)}.html", "database.html")
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
|
|
@ -171,7 +200,9 @@ class DatabaseView(View):
|
|||
tables=tables,
|
||||
hidden_count=len([t for t in tables if t["hidden"]]),
|
||||
views=sql_views,
|
||||
queries=canned_queries,
|
||||
queries=stored_queries,
|
||||
queries_more=queries_more,
|
||||
queries_count=queries_count,
|
||||
allow_execute_sql=allow_execute_sql,
|
||||
table_columns=(
|
||||
await _table_columns(datasette, database)
|
||||
|
|
@ -219,7 +250,11 @@ class DatabaseContext(Context):
|
|||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
||||
views: list = field(metadata={"help": "List of view objects in the database"})
|
||||
queries: list = field(metadata={"help": "List of canned query objects"})
|
||||
queries: list = field(metadata={"help": "List of stored query objects"})
|
||||
queries_more: bool = field(
|
||||
metadata={"help": "Boolean indicating if more stored queries are available"}
|
||||
)
|
||||
queries_count: int = field(metadata={"help": "Count of visible stored queries"})
|
||||
allow_execute_sql: bool = field(
|
||||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||
)
|
||||
|
|
@ -264,8 +299,8 @@ class QueryContext(Context):
|
|||
query: dict = field(
|
||||
metadata={"help": "The SQL query object containing the `sql` string"}
|
||||
)
|
||||
canned_query: str = field(
|
||||
metadata={"help": "The name of the canned query if this is a canned query"}
|
||||
stored_query: str = field(
|
||||
metadata={"help": "The name of the stored query if this is a stored query"}
|
||||
)
|
||||
private: bool = field(
|
||||
metadata={"help": "Boolean indicating if this is a private database"}
|
||||
|
|
@ -273,13 +308,13 @@ class QueryContext(Context):
|
|||
# urls: dict = field(
|
||||
# metadata={"help": "Object containing URL helpers like `database()`"}
|
||||
# )
|
||||
canned_query_write: bool = field(
|
||||
stored_query_write: bool = field(
|
||||
metadata={
|
||||
"help": "Boolean indicating if this is a canned query that allows writes"
|
||||
"help": "Boolean indicating if this is a stored query that allows writes"
|
||||
}
|
||||
)
|
||||
metadata: dict = field(
|
||||
metadata={"help": "Metadata about the database or the canned query"}
|
||||
metadata={"help": "Metadata about the database or the stored query"}
|
||||
)
|
||||
db_is_immutable: bool = field(
|
||||
metadata={"help": "Boolean indicating if this database is immutable"}
|
||||
|
|
@ -300,12 +335,15 @@ class QueryContext(Context):
|
|||
allow_execute_sql: bool = field(
|
||||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||
)
|
||||
save_query_url: str = field(
|
||||
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
||||
)
|
||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||
named_parameter_values: dict = field(
|
||||
metadata={"help": "Dictionary of parameter names/values"}
|
||||
)
|
||||
edit_sql_url: str = field(
|
||||
metadata={"help": "URL to edit the SQL for a canned query"}
|
||||
metadata={"help": "URL to edit the SQL for a stored query"}
|
||||
)
|
||||
display_rows: list = field(metadata={"help": "List of result rows to display"})
|
||||
columns: list = field(metadata={"help": "List of column names"})
|
||||
|
|
@ -329,8 +367,8 @@ class QueryContext(Context):
|
|||
top_query: callable = field(
|
||||
metadata={"help": "Callable to render the top_query slot"}
|
||||
)
|
||||
top_canned_query: callable = field(
|
||||
metadata={"help": "Callable to render the top_canned_query slot"}
|
||||
top_stored_query: callable = field(
|
||||
metadata={"help": "Callable to render the top_stored_query slot"}
|
||||
)
|
||||
query_actions: callable = field(
|
||||
metadata={
|
||||
|
|
@ -421,21 +459,47 @@ class QueryView(View):
|
|||
|
||||
db = await datasette.resolve_database(request)
|
||||
|
||||
# We must be a canned query
|
||||
# We must be a stored query
|
||||
table_found = False
|
||||
try:
|
||||
await datasette.resolve_table(request)
|
||||
table_found = True
|
||||
except TableNotFound as table_not_found:
|
||||
canned_query = await datasette.get_canned_query(
|
||||
table_not_found.database_name, table_not_found.table, request.actor
|
||||
stored_query = await datasette.get_query(
|
||||
table_not_found.database_name, table_not_found.table
|
||||
)
|
||||
if canned_query is None:
|
||||
if stored_query is None:
|
||||
raise
|
||||
if table_found:
|
||||
# That should not have happened
|
||||
raise DatasetteError("Unexpected table found on POST", status=404)
|
||||
|
||||
if not await datasette.allowed(
|
||||
action="view-query",
|
||||
resource=QueryResource(database=db.name, query=stored_query.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise Forbidden("You do not have permission to view this query")
|
||||
|
||||
try:
|
||||
await _ensure_stored_query_execution_permissions(
|
||||
datasette, db, stored_query, request.actor
|
||||
)
|
||||
except QueryWriteRejected as ex:
|
||||
if request.headers.get("accept") == "application/json" or request.args.get(
|
||||
"_json"
|
||||
):
|
||||
return Response.json(
|
||||
{
|
||||
"ok": False,
|
||||
"message": ex.message,
|
||||
"redirect": None,
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
datasette.add_message(request, ex.message, datasette.ERROR)
|
||||
return Response.redirect(stored_query.on_error_redirect or request.path)
|
||||
|
||||
# If database is immutable, return an error
|
||||
if not db.is_mutable:
|
||||
raise Forbidden("Database is immutable")
|
||||
|
|
@ -460,20 +524,18 @@ class QueryView(View):
|
|||
or request.args.get("_json")
|
||||
or params.get("_json")
|
||||
)
|
||||
params_for_query = MagicParameters(
|
||||
canned_query["sql"], params, request, datasette
|
||||
)
|
||||
params_for_query = MagicParameters(stored_query.sql, params, request, datasette)
|
||||
await params_for_query.execute_params()
|
||||
ok = None
|
||||
redirect_url = None
|
||||
try:
|
||||
cursor = await db.execute_write(
|
||||
canned_query["sql"], params_for_query, request=request
|
||||
stored_query.sql, params_for_query, request=request
|
||||
)
|
||||
# success message can come from on_success_message or on_success_message_sql
|
||||
message = None
|
||||
message_type = datasette.INFO
|
||||
on_success_message_sql = canned_query.get("on_success_message_sql")
|
||||
on_success_message_sql = stored_query.on_success_message_sql
|
||||
if on_success_message_sql:
|
||||
try:
|
||||
message_result = (
|
||||
|
|
@ -485,18 +547,21 @@ class QueryView(View):
|
|||
message = "Error running on_success_message_sql: {}".format(ex)
|
||||
message_type = datasette.ERROR
|
||||
if not message:
|
||||
message = canned_query.get(
|
||||
"on_success_message"
|
||||
) or "Query executed, {} row{} affected".format(
|
||||
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
||||
)
|
||||
if stored_query.on_success_message:
|
||||
message = stored_query.on_success_message
|
||||
elif cursor.rowcount == -1:
|
||||
message = "Query executed"
|
||||
else:
|
||||
message = "Query executed, {} row{} affected".format(
|
||||
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
||||
)
|
||||
|
||||
redirect_url = canned_query.get("on_success_redirect")
|
||||
redirect_url = stored_query.on_success_redirect
|
||||
ok = True
|
||||
except Exception as ex:
|
||||
message = canned_query.get("on_error_message") or str(ex)
|
||||
message = stored_query.on_error_message or str(ex)
|
||||
message_type = datasette.ERROR
|
||||
redirect_url = canned_query.get("on_error_redirect")
|
||||
redirect_url = stored_query.on_error_redirect
|
||||
ok = False
|
||||
if should_return_json:
|
||||
return Response.json(
|
||||
|
|
@ -529,53 +594,59 @@ class QueryView(View):
|
|||
# Create lookup dict for quick access
|
||||
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
|
||||
|
||||
# Are we a canned query?
|
||||
canned_query = None
|
||||
canned_query_write = False
|
||||
# Are we a stored query?
|
||||
stored_query = None
|
||||
stored_query_write = False
|
||||
if "table" in request.url_vars:
|
||||
try:
|
||||
await datasette.resolve_table(request)
|
||||
except TableNotFound as table_not_found:
|
||||
# Was this actually a canned query?
|
||||
canned_query = await datasette.get_canned_query(
|
||||
table_not_found.database_name, table_not_found.table, request.actor
|
||||
# Was this actually a stored query?
|
||||
stored_query = await datasette.get_query(
|
||||
table_not_found.database_name, table_not_found.table
|
||||
)
|
||||
if canned_query is None:
|
||||
if stored_query is None:
|
||||
raise
|
||||
canned_query_write = bool(canned_query.get("write"))
|
||||
stored_query_write = stored_query.is_write
|
||||
|
||||
private = False
|
||||
if canned_query:
|
||||
# Respect canned query permissions
|
||||
if stored_query:
|
||||
# Respect stored query permissions
|
||||
visible, private = await datasette.check_visibility(
|
||||
request.actor,
|
||||
action="view-query",
|
||||
resource=QueryResource(database=database, query=canned_query["name"]),
|
||||
resource=QueryResource(database=database, query=stored_query.name),
|
||||
)
|
||||
if not visible:
|
||||
raise Forbidden("You do not have permission to view this query")
|
||||
if not stored_query_write:
|
||||
await _ensure_stored_query_execution_permissions(
|
||||
datasette, db, stored_query, request.actor
|
||||
)
|
||||
|
||||
else:
|
||||
await datasette.ensure_permission(
|
||||
visible, private = await datasette.check_visibility(
|
||||
request.actor,
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
)
|
||||
if not visible:
|
||||
raise Forbidden("execute-sql")
|
||||
|
||||
# Flattened because of ?sql=&name1=value1&name2=value2 feature
|
||||
params = {key: request.args.get(key) for key in request.args}
|
||||
sql = None
|
||||
|
||||
if canned_query:
|
||||
sql = canned_query["sql"]
|
||||
if stored_query:
|
||||
sql = stored_query.sql
|
||||
elif "sql" in params:
|
||||
sql = params.pop("sql")
|
||||
|
||||
# Extract any :named parameters
|
||||
named_parameters = []
|
||||
if canned_query and canned_query.get("params"):
|
||||
named_parameters = canned_query["params"]
|
||||
if not named_parameters:
|
||||
if stored_query and stored_query.parameters:
|
||||
named_parameters = stored_query.parameters
|
||||
if not named_parameters and sql:
|
||||
named_parameters = derive_named_parameters(sql)
|
||||
named_parameter_values = {
|
||||
named_parameter: params.get(named_parameter) or ""
|
||||
|
|
@ -600,13 +671,13 @@ class QueryView(View):
|
|||
|
||||
params_for_query = params
|
||||
|
||||
if not canned_query_write:
|
||||
if sql and not stored_query_write:
|
||||
try:
|
||||
if not canned_query:
|
||||
if not stored_query:
|
||||
# For regular queries we only allow SELECT, plus other rules
|
||||
validate_sql_select(sql)
|
||||
else:
|
||||
# Canned queries can run magic parameters
|
||||
# Stored queries can run magic parameters
|
||||
params_for_query = MagicParameters(sql, params, request, datasette)
|
||||
await params_for_query.execute_params()
|
||||
results = await datasette.execute(
|
||||
|
|
@ -642,8 +713,17 @@ class QueryView(View):
|
|||
except DatasetteError:
|
||||
raise
|
||||
|
||||
async def query_metadata():
|
||||
if stored_query:
|
||||
metadata = stored_query_to_dict(stored_query)
|
||||
metadata.pop("source", None)
|
||||
return metadata
|
||||
return await datasette.get_database_metadata(database)
|
||||
|
||||
# Handle formats from plugins
|
||||
if format_ == "csv":
|
||||
if not sql:
|
||||
raise DatasetteError("?sql= is required", status=400)
|
||||
|
||||
async def fetch_data_for_csv(request, _next=None):
|
||||
results = await db.execute(sql, params, truncate=True)
|
||||
|
|
@ -652,6 +732,25 @@ class QueryView(View):
|
|||
|
||||
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
||||
elif format_ in datasette.renderers.keys():
|
||||
data = {"ok": True, "rows": rows, "columns": columns}
|
||||
extras = extra_names_from_request(request)
|
||||
if extras:
|
||||
query_extra_context = QueryExtraContext(
|
||||
datasette=datasette,
|
||||
request=request,
|
||||
db=db,
|
||||
database_name=database,
|
||||
private=private,
|
||||
rows=rows,
|
||||
columns=columns,
|
||||
sql=sql,
|
||||
params=named_parameter_values,
|
||||
query_name=stored_query.name if stored_query else None,
|
||||
metadata=await query_metadata(),
|
||||
extras=extras,
|
||||
extra_registry=table_extra_registry,
|
||||
)
|
||||
data.update(await resolve_query_extras(extras, query_extra_context))
|
||||
# Dispatch request to the correct output format renderer
|
||||
# (CSV is not handled here due to streaming)
|
||||
result = call_with_supported_arguments(
|
||||
|
|
@ -660,7 +759,7 @@ class QueryView(View):
|
|||
columns=columns,
|
||||
rows=rows,
|
||||
sql=sql,
|
||||
query_name=canned_query["name"] if canned_query else None,
|
||||
query_name=stored_query.name if stored_query else None,
|
||||
database=database,
|
||||
table=None,
|
||||
request=request,
|
||||
|
|
@ -669,7 +768,7 @@ class QueryView(View):
|
|||
error=query_error,
|
||||
# These will be deprecated in Datasette 1.0:
|
||||
args=request.args,
|
||||
data={"ok": True, "rows": rows, "columns": columns},
|
||||
data=data,
|
||||
)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
|
@ -692,19 +791,33 @@ class QueryView(View):
|
|||
elif format_ == "html":
|
||||
headers = {}
|
||||
templates = [f"query-{to_css_class(database)}.html", "query.html"]
|
||||
if canned_query:
|
||||
if stored_query:
|
||||
templates.insert(
|
||||
0,
|
||||
f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html",
|
||||
f"query-{to_css_class(database)}-{to_css_class(stored_query.name)}.html",
|
||||
)
|
||||
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
alternate_url_json = datasette.absolute_url(
|
||||
request,
|
||||
datasette.urls.path(path_with_format(request=request, format="json")),
|
||||
datasette.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format="json",
|
||||
)
|
||||
),
|
||||
)
|
||||
data = {}
|
||||
data = {
|
||||
"ok": query_error is None,
|
||||
"rows": rows,
|
||||
"columns": columns,
|
||||
"query": {"sql": sql, "params": params},
|
||||
"query_name": stored_query.name if stored_query else None,
|
||||
"database": database,
|
||||
"table": None,
|
||||
}
|
||||
headers.update(
|
||||
{
|
||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||
|
|
@ -712,8 +825,7 @@ class QueryView(View):
|
|||
)
|
||||
}
|
||||
)
|
||||
metadata = await datasette.get_database_metadata(database)
|
||||
|
||||
metadata = await query_metadata()
|
||||
renderers = {}
|
||||
for key, (_, can_render) in datasette.renderers.items():
|
||||
it_can_render = call_with_supported_arguments(
|
||||
|
|
@ -731,7 +843,11 @@ class QueryView(View):
|
|||
it_can_render = await await_me_maybe(it_can_render)
|
||||
if it_can_render:
|
||||
renderers[key] = datasette.urls.path(
|
||||
path_with_format(request=request, format=key)
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format=key,
|
||||
)
|
||||
)
|
||||
|
||||
allow_execute_sql = await datasette.allowed(
|
||||
|
|
@ -739,9 +855,14 @@ class QueryView(View):
|
|||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
)
|
||||
allow_store_query = await datasette.allowed(
|
||||
action="store-query",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
show_hide_hidden = ""
|
||||
if canned_query and canned_query.get("hide_sql"):
|
||||
if stored_query and stored_query.hide_sql:
|
||||
if bool(params.get("_show_sql")):
|
||||
show_hide_link = path_with_removed_args(request, {"_show_sql"})
|
||||
show_hide_text = "hide"
|
||||
|
|
@ -769,24 +890,38 @@ class QueryView(View):
|
|||
# - No magic parameters, so no :_ in the SQL string
|
||||
edit_sql_url = None
|
||||
is_validated_sql = False
|
||||
try:
|
||||
validate_sql_select(sql)
|
||||
is_validated_sql = True
|
||||
except InvalidSql:
|
||||
pass
|
||||
if allow_execute_sql and is_validated_sql and ":_" not in sql:
|
||||
edit_sql_url = (
|
||||
datasette.urls.database(database)
|
||||
+ "/-/query"
|
||||
+ "?"
|
||||
+ urlencode(
|
||||
{
|
||||
**{
|
||||
"sql": sql,
|
||||
},
|
||||
**named_parameter_values,
|
||||
}
|
||||
if sql:
|
||||
try:
|
||||
validate_sql_select(sql)
|
||||
is_validated_sql = True
|
||||
except InvalidSql:
|
||||
pass
|
||||
if allow_execute_sql and is_validated_sql and ":_" not in sql:
|
||||
edit_sql_url = (
|
||||
datasette.urls.database(database)
|
||||
+ "/-/query"
|
||||
+ "?"
|
||||
+ urlencode(
|
||||
{
|
||||
**{
|
||||
"sql": sql,
|
||||
},
|
||||
**named_parameter_values,
|
||||
}
|
||||
)
|
||||
)
|
||||
save_query_url = None
|
||||
if (
|
||||
not stored_query
|
||||
and allow_execute_sql
|
||||
and allow_store_query
|
||||
and is_validated_sql
|
||||
and ":_" not in sql
|
||||
):
|
||||
save_query_url = (
|
||||
datasette.urls.database(database)
|
||||
+ "/-/queries/store?"
|
||||
+ urlencode({"sql": sql})
|
||||
)
|
||||
|
||||
async def query_actions():
|
||||
|
|
@ -795,7 +930,7 @@ class QueryView(View):
|
|||
datasette=datasette,
|
||||
actor=request.actor,
|
||||
database=database,
|
||||
query_name=canned_query["name"] if canned_query else None,
|
||||
query_name=stored_query.name if stored_query else None,
|
||||
request=request,
|
||||
sql=sql,
|
||||
params=params,
|
||||
|
|
@ -815,16 +950,17 @@ class QueryView(View):
|
|||
"sql": sql,
|
||||
"params": params,
|
||||
},
|
||||
canned_query=canned_query["name"] if canned_query else None,
|
||||
stored_query=stored_query.name if stored_query else None,
|
||||
private=private,
|
||||
canned_query_write=canned_query_write,
|
||||
stored_query_write=stored_query_write,
|
||||
db_is_immutable=not db.is_mutable,
|
||||
error=query_error,
|
||||
hide_sql=hide_sql,
|
||||
show_hide_link=datasette.urls.path(show_hide_link),
|
||||
show_hide_text=show_hide_text,
|
||||
editable=not canned_query,
|
||||
editable=not stored_query,
|
||||
allow_execute_sql=allow_execute_sql,
|
||||
save_query_url=save_query_url,
|
||||
tables=await get_tables(datasette, request, db, allowed_dict),
|
||||
named_parameter_values=named_parameter_values,
|
||||
edit_sql_url=edit_sql_url,
|
||||
|
|
@ -840,11 +976,14 @@ class QueryView(View):
|
|||
renderers=renderers,
|
||||
url_csv=datasette.urls.path(
|
||||
path_with_format(
|
||||
request=request, format="csv", extra_qs={"_size": "max"}
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format="csv",
|
||||
extra_qs={"_size": "max"},
|
||||
)
|
||||
),
|
||||
show_hide_hidden=markupsafe.Markup(show_hide_hidden),
|
||||
metadata=canned_query or metadata,
|
||||
metadata=metadata,
|
||||
alternate_url_json=alternate_url_json,
|
||||
select_templates=[
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
|
|
@ -853,12 +992,12 @@ class QueryView(View):
|
|||
top_query=make_slot_function(
|
||||
"top_query", datasette, request, database=database, sql=sql
|
||||
),
|
||||
top_canned_query=make_slot_function(
|
||||
"top_canned_query",
|
||||
top_stored_query=make_slot_function(
|
||||
"top_stored_query",
|
||||
datasette,
|
||||
request,
|
||||
database=database,
|
||||
query_name=canned_query["name"] if canned_query else None,
|
||||
query_name=stored_query.name if stored_query else None,
|
||||
),
|
||||
query_actions=query_actions,
|
||||
),
|
||||
|
|
@ -954,9 +1093,8 @@ class TableCreateView(BaseView):
|
|||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
|
||||
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)])
|
||||
|
||||
|
|
@ -1171,22 +1309,6 @@ class TableCreateView(BaseView):
|
|||
return Response.json(details, status=201)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def display_rows(datasette, database, request, rows, columns):
|
||||
display_rows = []
|
||||
truncate_cells = datasette.setting("truncate_cells_html")
|
||||
|
|
|
|||
474
datasette/views/execute_write.py
Normal file
474
datasette/views/execute_write.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import re
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.utils import sqlite3
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
from .base import BaseView, _error
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
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,
|
||||
"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(
|
||||
_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(
|
||||
_error(["Permission denied: need execute-write-sql"], 403)
|
||||
)
|
||||
if not db.is_mutable:
|
||||
return _block_framing(_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(_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(_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 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(
|
||||
_error(["Permission denied: need execute-write-sql"], 403)
|
||||
)
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
_error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
||||
)
|
||||
)
|
||||
638
datasette/views/query_helpers.py
Normal file
638
datasette/views/query_helpers.py
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
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 (
|
||||
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",
|
||||
"params",
|
||||
"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=50):
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
return min(max(1, int(value)), 1000)
|
||||
except ValueError as ex:
|
||||
raise QueryValidationError("_size must be an integer") 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", data.get("params")),
|
||||
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 or "params" in update:
|
||||
parameters = _coerce_query_parameters(
|
||||
update.get("parameters", update.get("params")),
|
||||
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
|
||||
|
|
@ -7,6 +7,7 @@ from datasette.utils import (
|
|||
await_me_maybe,
|
||||
CustomRow,
|
||||
make_slot_function,
|
||||
path_from_row_pks,
|
||||
to_css_class,
|
||||
escape_sqlite,
|
||||
)
|
||||
|
|
@ -14,7 +15,13 @@ 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
|
||||
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):
|
||||
|
|
@ -47,6 +54,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 +123,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 +203,16 @@ 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(
|
||||
self.ds,
|
||||
request,
|
||||
db,
|
||||
database,
|
||||
table,
|
||||
not is_table,
|
||||
None,
|
||||
),
|
||||
"row_actions": row_actions,
|
||||
"top_row": make_slot_function(
|
||||
"top_row",
|
||||
|
|
@ -164,60 +235,27 @@ 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)
|
||||
|
||||
# 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,6 +318,27 @@ 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
|
||||
|
||||
|
|
@ -334,6 +393,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,9 +418,8 @@ 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)])
|
||||
|
||||
|
|
@ -395,11 +462,13 @@ class RowUpdateView(BaseView):
|
|||
return _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["row"] = returned_row
|
||||
|
||||
await self.ds.track_event(
|
||||
UpdateRowEvent(
|
||||
|
|
@ -410,4 +479,19 @@ class RowUpdateView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import json
|
||||
import logging
|
||||
from datasette.jump import JumpSQL, namespace_sql_params
|
||||
from datasette.plugins import pm
|
||||
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
||||
from datasette.resources import DatabaseResource, TableResource
|
||||
from datasette.utils.asgi import Response, Forbidden
|
||||
from datasette.utils import (
|
||||
actor_matches_allow,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
tilde_encode,
|
||||
tilde_decode,
|
||||
)
|
||||
|
|
@ -64,7 +67,7 @@ class JsonDataView(BaseView):
|
|||
context = {
|
||||
"filename": self.filename,
|
||||
"data": data,
|
||||
"data_json": json.dumps(data, indent=4, default=repr),
|
||||
"data_json": json.dumps(data, indent=2, default=repr),
|
||||
}
|
||||
# Add has_debug_permission if this view requires permissions-debug
|
||||
if self.permission == "permissions-debug":
|
||||
|
|
@ -88,6 +91,110 @@ class PatternPortfolioView(View):
|
|||
)
|
||||
|
||||
|
||||
class AutocompleteDebugView(BaseView):
|
||||
name = "autocomplete_debug"
|
||||
has_json_alternate = False
|
||||
|
||||
async def _suggested_tables(self, request):
|
||||
scanned = 0
|
||||
reached_scan_limit = False
|
||||
suggestions = []
|
||||
for database_name, db in self.ds.databases.items():
|
||||
if scanned >= 100 or len(suggestions) >= 5:
|
||||
break
|
||||
remaining = 100 - scanned
|
||||
results = await db.execute(
|
||||
"select name from sqlite_master where type = 'table' order by name limit ?",
|
||||
[remaining],
|
||||
)
|
||||
for row in results.rows:
|
||||
table_name = row["name"]
|
||||
scanned += 1
|
||||
if scanned >= 100:
|
||||
reached_scan_limit = True
|
||||
visible, _ = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
action="view-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
)
|
||||
if not visible:
|
||||
if scanned >= 100:
|
||||
break
|
||||
continue
|
||||
label_column = await db.label_column_for_table(table_name)
|
||||
if label_column:
|
||||
suggestions.append(
|
||||
{
|
||||
"database": database_name,
|
||||
"table": table_name,
|
||||
"label_column": label_column,
|
||||
"url": self.ds.urls.path(
|
||||
"-/debug/autocomplete?"
|
||||
+ urllib.parse.urlencode(
|
||||
{
|
||||
"database": database_name,
|
||||
"table": table_name,
|
||||
}
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
if len(suggestions) >= 5:
|
||||
break
|
||||
if scanned >= 100:
|
||||
break
|
||||
return suggestions, scanned, reached_scan_limit
|
||||
|
||||
async def get(self, request):
|
||||
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
|
||||
database_name = request.args.get("database")
|
||||
table_name = request.args.get("table")
|
||||
context = {
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
}
|
||||
|
||||
if database_name or table_name:
|
||||
if not database_name or not table_name:
|
||||
context["error"] = "Both database and table are required."
|
||||
elif database_name not in self.ds.databases:
|
||||
context["error"] = "Database not found."
|
||||
else:
|
||||
db = self.ds.databases[database_name]
|
||||
if not await db.table_exists(table_name):
|
||||
context["error"] = "Table not found."
|
||||
else:
|
||||
await self.ds.ensure_permission(
|
||||
action="view-table",
|
||||
resource=TableResource(
|
||||
database=database_name,
|
||||
table=table_name,
|
||||
),
|
||||
actor=request.actor,
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"autocomplete_url": "{}/-/autocomplete".format(
|
||||
self.ds.urls.table(database_name, table_name)
|
||||
),
|
||||
"label_column": await db.label_column_for_table(table_name),
|
||||
}
|
||||
)
|
||||
else:
|
||||
suggestions, scanned, reached_scan_limit = await self._suggested_tables(
|
||||
request
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"suggestions": suggestions,
|
||||
"scanned": scanned,
|
||||
"reached_scan_limit": reached_scan_limit,
|
||||
}
|
||||
)
|
||||
|
||||
return await self.render(["debug_autocomplete.html"], request, context)
|
||||
|
||||
|
||||
class AuthTokenView(BaseView):
|
||||
name = "auth_token"
|
||||
has_json_alternate = False
|
||||
|
|
@ -494,11 +601,13 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
|
|||
if action_obj.resource_class is None:
|
||||
resource_obj = None
|
||||
elif action_obj.takes_parent and action_obj.takes_child:
|
||||
# Child-level resource (e.g., TableResource, QueryResource)
|
||||
resource_obj = action_obj.resource_class(database=parent, table=child)
|
||||
# Child-level resource (e.g., TableResource, QueryResource). The child
|
||||
# argument is named differently per resource class (table, query, ...),
|
||||
# so pass positionally - https://github.com/simonw/datasette/issues/2756
|
||||
resource_obj = action_obj.resource_class(parent, child)
|
||||
elif action_obj.takes_parent:
|
||||
# Parent-level resource (e.g., DatabaseResource)
|
||||
resource_obj = action_obj.resource_class(database=parent)
|
||||
resource_obj = action_obj.resource_class(parent)
|
||||
else:
|
||||
# This shouldn't happen given validation in Action.__post_init__
|
||||
return {"error": f"Invalid action configuration: {action}"}, 500
|
||||
|
|
@ -889,14 +998,15 @@ class ApiExplorerView(BaseView):
|
|||
raise Forbidden("You do not have permission to view this instance")
|
||||
|
||||
def api_path(link):
|
||||
return "/-/api#{}".format(
|
||||
return "{}#{}".format(
|
||||
self.ds.urls.path("/-/api"),
|
||||
urllib.parse.urlencode(
|
||||
{
|
||||
key: json.dumps(value, indent=2) if key == "json" else value
|
||||
for key, value in link.items()
|
||||
if key in ("path", "method", "json")
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
return await self.render(
|
||||
|
|
@ -910,75 +1020,183 @@ class ApiExplorerView(BaseView):
|
|||
)
|
||||
|
||||
|
||||
class TablesView(BaseView):
|
||||
class JumpView(BaseView):
|
||||
"""
|
||||
Simple endpoint that uses the new allowed_resources() API.
|
||||
Returns JSON list of all tables the actor can view.
|
||||
|
||||
Supports ?q=foo+bar to filter tables matching .*foo.*bar.* pattern,
|
||||
ordered by shortest name first.
|
||||
Endpoint for the jump menu. Returns JSON navigation items the actor can use.
|
||||
"""
|
||||
|
||||
name = "tables"
|
||||
name = "jump"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
# Get search query parameter
|
||||
q = request.args.get("q", "").strip()
|
||||
async def _fragments(self, request):
|
||||
fragments = []
|
||||
for hook in pm.hook.jump_items_sql(
|
||||
datasette=self.ds,
|
||||
actor=request.actor,
|
||||
request=request,
|
||||
):
|
||||
value = await await_me_maybe(hook)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, JumpSQL):
|
||||
fragments.append(value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for fragment in value:
|
||||
if fragment is not None:
|
||||
assert isinstance(
|
||||
fragment, JumpSQL
|
||||
), "jump_items_sql must return JumpSQL instances"
|
||||
fragments.append(fragment)
|
||||
else:
|
||||
raise TypeError("jump_items_sql must return JumpSQL instances")
|
||||
return fragments
|
||||
|
||||
# Get SQL for allowed resources using the permission system
|
||||
permission_sql, params = await self.ds.allowed_resources_sql(
|
||||
action="view-table", actor=request.actor
|
||||
)
|
||||
def _resolve_url(self, url):
|
||||
if not url or url.startswith("/"):
|
||||
return url
|
||||
|
||||
# Build query based on whether we have a search query
|
||||
if q:
|
||||
# Build SQL LIKE pattern from search terms
|
||||
# Split search terms by whitespace and build pattern: %term1%term2%term3%
|
||||
terms = q.split()
|
||||
pattern = "%" + "%".join(terms) + "%"
|
||||
descriptor = json.loads(url)
|
||||
if not isinstance(descriptor, dict):
|
||||
raise TypeError("jump item url JSON must be an object")
|
||||
method_name = descriptor.get("method")
|
||||
if not isinstance(method_name, str) or not method_name:
|
||||
raise TypeError("jump item url JSON must include a method")
|
||||
if method_name.startswith("_"):
|
||||
raise AttributeError(f"datasette.urls has no method named {method_name!r}")
|
||||
try:
|
||||
method = getattr(self.ds.urls, method_name)
|
||||
except AttributeError as ex:
|
||||
raise AttributeError(
|
||||
f"datasette.urls has no method named {method_name!r}"
|
||||
) from ex
|
||||
if not callable(method):
|
||||
raise TypeError(f"datasette.urls.{method_name} is not callable")
|
||||
kwargs = {key: value for key, value in descriptor.items() if key != "method"}
|
||||
try:
|
||||
return method(**kwargs)
|
||||
except TypeError as ex:
|
||||
raise TypeError(
|
||||
f"Invalid arguments for datasette.urls.{method_name}(): {ex}"
|
||||
) from ex
|
||||
|
||||
# Build query with CTE to filter by search pattern
|
||||
sql = f"""
|
||||
WITH allowed_tables AS (
|
||||
{permission_sql}
|
||||
)
|
||||
SELECT parent, child
|
||||
FROM allowed_tables
|
||||
WHERE child LIKE :pattern COLLATE NOCASE
|
||||
ORDER BY length(child), child
|
||||
"""
|
||||
all_params = {**params, "pattern": pattern}
|
||||
def _sort_key(self, row, q):
|
||||
display_label = row["display_name"] or row["label"]
|
||||
display_label_lower = display_label.lower()
|
||||
q_lower = q.lower()
|
||||
if display_label_lower == q_lower:
|
||||
relevance = 0
|
||||
elif display_label_lower.startswith(q_lower):
|
||||
relevance = 1
|
||||
else:
|
||||
# No search query - return all tables, ordered by name
|
||||
# Fetch 101 to detect if we need to truncate
|
||||
sql = f"""
|
||||
WITH allowed_tables AS (
|
||||
{permission_sql}
|
||||
relevance = 2
|
||||
type_sort = {
|
||||
"database": 10,
|
||||
"table": 20,
|
||||
"view": 25,
|
||||
"query": 30,
|
||||
}.get(row["type"], 50)
|
||||
return (relevance, type_sort, len(display_label), row["label"])
|
||||
|
||||
async def _rows_for_database(self, database_name, indexed_fragments, q, pattern):
|
||||
params = {"q": q, "pattern": pattern}
|
||||
union_parts = []
|
||||
for index, fragment in indexed_fragments:
|
||||
fragment_sql, fragment_params = namespace_sql_params(
|
||||
fragment.sql,
|
||||
fragment.params or {},
|
||||
f"jump_{index}",
|
||||
)
|
||||
SELECT parent, child
|
||||
FROM allowed_tables
|
||||
ORDER BY parent, child
|
||||
LIMIT 101
|
||||
"""
|
||||
all_params = params
|
||||
union_parts.append(f"""
|
||||
SELECT
|
||||
type,
|
||||
label,
|
||||
description,
|
||||
url,
|
||||
search_text,
|
||||
display_name
|
||||
FROM (
|
||||
{fragment_sql}
|
||||
)
|
||||
""")
|
||||
params.update(fragment_params)
|
||||
sql = f"""
|
||||
WITH jump_items AS (
|
||||
{" UNION ALL ".join(union_parts)}
|
||||
)
|
||||
SELECT
|
||||
type,
|
||||
label,
|
||||
description,
|
||||
url,
|
||||
search_text,
|
||||
display_name
|
||||
FROM jump_items
|
||||
WHERE :q = ''
|
||||
OR search_text LIKE :pattern COLLATE NOCASE
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lower(COALESCE(display_name, label)) = lower(:q) THEN 0
|
||||
WHEN lower(COALESCE(display_name, label)) LIKE lower(:q || '%') THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
CASE type
|
||||
WHEN 'database' THEN 10
|
||||
WHEN 'table' THEN 20
|
||||
WHEN 'view' THEN 25
|
||||
WHEN 'query' THEN 30
|
||||
ELSE 50
|
||||
END,
|
||||
length(COALESCE(display_name, label)),
|
||||
label
|
||||
LIMIT 101
|
||||
"""
|
||||
db = (
|
||||
self.ds.get_internal_database()
|
||||
if database_name is None
|
||||
else self.ds.get_database(database_name)
|
||||
)
|
||||
result = await db.execute(sql, params)
|
||||
return list(result.rows)
|
||||
|
||||
# Execute against internal database
|
||||
result = await self.ds.get_internal_database().execute(sql, all_params)
|
||||
async def get(self, request):
|
||||
q = request.args.get("q", "").strip()
|
||||
terms = q.split()
|
||||
pattern = "%" + "%".join(terms) + "%" if terms else "%"
|
||||
fragments = await self._fragments(request)
|
||||
|
||||
# Build response with truncation
|
||||
rows = list(result.rows)
|
||||
truncated = len(rows) > 100
|
||||
if truncated:
|
||||
fragments_by_database = {}
|
||||
for index, fragment in enumerate(fragments):
|
||||
fragments_by_database.setdefault(fragment.database, []).append(
|
||||
(index, fragment)
|
||||
)
|
||||
|
||||
rows = []
|
||||
truncated = False
|
||||
for database_name, indexed_fragments in fragments_by_database.items():
|
||||
database_rows = await self._rows_for_database(
|
||||
database_name, indexed_fragments, q, pattern
|
||||
)
|
||||
if len(database_rows) > 100:
|
||||
truncated = True
|
||||
database_rows = database_rows[:100]
|
||||
rows.extend(database_rows)
|
||||
rows.sort(key=lambda row: self._sort_key(row, q))
|
||||
|
||||
if len(rows) > 100:
|
||||
truncated = True
|
||||
rows = rows[:100]
|
||||
|
||||
matches = [
|
||||
{
|
||||
"name": f"{row['parent']}: {row['child']}",
|
||||
"url": self.ds.urls.table(row["parent"], row["child"]),
|
||||
matches = []
|
||||
for row in rows:
|
||||
match = {
|
||||
"name": row["label"],
|
||||
"url": self._resolve_url(row["url"]),
|
||||
"type": row["type"],
|
||||
"description": row["description"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
if row["display_name"]:
|
||||
match["display_name"] = row["display_name"]
|
||||
matches.append(match)
|
||||
|
||||
return Response.json({"matches": matches, "truncated": truncated})
|
||||
|
||||
|
|
|
|||
644
datasette/views/stored_queries.py
Normal file
644
datasette/views/stored_queries.py
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
from datasette.resources import DatabaseResource, QueryResource
|
||||
from datasette.stored_queries import stored_query_to_dict
|
||||
from datasette.utils import sqlite3, tilde_decode
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
from .base import BaseView, _error
|
||||
from .query_helpers import (
|
||||
QueryValidationError,
|
||||
_as_bool,
|
||||
_as_optional_bool,
|
||||
_block_framing,
|
||||
_derived_query_parameters,
|
||||
_json_or_form_payload,
|
||||
_prepare_query_create,
|
||||
_prepare_query_update,
|
||||
_query_create_analysis_data,
|
||||
_query_create_form_context,
|
||||
_query_create_form_error_message,
|
||||
_query_edit_form_context,
|
||||
_query_list_limit,
|
||||
)
|
||||
|
||||
|
||||
class QueryParametersView(BaseView):
|
||||
name = "query-parameters"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
_error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
try:
|
||||
parameters = _derived_query_parameters(request.args.get("sql") or "")
|
||||
except QueryValidationError as ex:
|
||||
return _block_framing(_error([ex.message], ex.status))
|
||||
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
|
||||
|
||||
|
||||
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
|
||||
set_args = set_args or {}
|
||||
remove_args = set(remove_args or ())
|
||||
skip = set(set_args) | remove_args | {"_next"}
|
||||
pairs = [
|
||||
(key, value)
|
||||
for key, value in parse_qsl(query_string, keep_blank_values=True)
|
||||
if key not in skip
|
||||
]
|
||||
for key, value in set_args.items():
|
||||
if value not in (None, ""):
|
||||
pairs.append((key, value))
|
||||
return path + (("?" + urlencode(pairs)) if pairs else "")
|
||||
|
||||
|
||||
class QueryListView(BaseView):
|
||||
name = "query-list"
|
||||
|
||||
async def database_name(self, request):
|
||||
return (await self.ds.resolve_database(request)).name
|
||||
|
||||
def query_list_path(self, database):
|
||||
return self.ds.urls.database(database) + "/-/queries"
|
||||
|
||||
async def get(self, request):
|
||||
database = await self.database_name(request)
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
try:
|
||||
limit = _query_list_limit(
|
||||
request.args.get("_size"),
|
||||
default=20 if format_ == "html" else 50,
|
||||
)
|
||||
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
|
||||
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
|
||||
except QueryValidationError as ex:
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
page = await self.ds.list_queries(
|
||||
database,
|
||||
actor=request.actor,
|
||||
limit=limit,
|
||||
cursor=request.args.get("_next"),
|
||||
q=request.args.get("q") or None,
|
||||
is_write=is_write,
|
||||
is_private=is_private,
|
||||
source=request.args.get("source") or None,
|
||||
owner_id=request.args.get("owner_id") or None,
|
||||
include_private=True,
|
||||
)
|
||||
query_list_path = self.query_list_path(database)
|
||||
next_url = None
|
||||
if page.next:
|
||||
pairs = [
|
||||
(key, value)
|
||||
for key, value in parse_qsl(
|
||||
request.query_string, keep_blank_values=True
|
||||
)
|
||||
if key != "_next"
|
||||
]
|
||||
pairs.append(("_next", page.next))
|
||||
next_url = "{}?{}".format(
|
||||
query_list_path,
|
||||
urlencode(pairs),
|
||||
)
|
||||
|
||||
current_filters = {
|
||||
"actor": request.actor,
|
||||
"q": request.args.get("q") or None,
|
||||
"is_write": is_write,
|
||||
"is_private": is_private,
|
||||
"source": request.args.get("source") or None,
|
||||
"owner_id": request.args.get("owner_id") or None,
|
||||
}
|
||||
|
||||
async def facet_count(field, value):
|
||||
if current_filters[field] is not None and current_filters[field] != value:
|
||||
return 0
|
||||
filters = dict(current_filters)
|
||||
filters[field] = value
|
||||
return await self.ds.count_queries(database, **filters)
|
||||
|
||||
def facet_href(field, value):
|
||||
if current_filters[field] == value:
|
||||
return _query_list_url(
|
||||
query_list_path,
|
||||
request.query_string,
|
||||
remove_args=[field],
|
||||
)
|
||||
if current_filters[field] is not None:
|
||||
return None
|
||||
return _query_list_url(
|
||||
query_list_path,
|
||||
request.query_string,
|
||||
set_args={field: str(int(value))},
|
||||
)
|
||||
|
||||
async def facet_item(label, field, value):
|
||||
count = await facet_count(field, value)
|
||||
active = current_filters[field] == value
|
||||
if not active and not count:
|
||||
return None
|
||||
return {
|
||||
"label": label,
|
||||
"count": count,
|
||||
"href": facet_href(field, value) if active or count else None,
|
||||
"active": active,
|
||||
}
|
||||
|
||||
async def facet_items(items):
|
||||
return [
|
||||
item
|
||||
for item in [
|
||||
await facet_item(label, field, value)
|
||||
for label, field, value in items
|
||||
]
|
||||
if item is not None
|
||||
]
|
||||
|
||||
facets = [
|
||||
{
|
||||
"title": "Mode",
|
||||
"items": await facet_items(
|
||||
[
|
||||
("Read-only", "is_write", False),
|
||||
("Writable", "is_write", True),
|
||||
]
|
||||
),
|
||||
},
|
||||
{
|
||||
"title": "Visibility",
|
||||
"items": await facet_items(
|
||||
[
|
||||
("Not private", "is_private", False),
|
||||
("Private", "is_private", True),
|
||||
]
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
data = {
|
||||
"ok": True,
|
||||
"database": database,
|
||||
"database_color": (
|
||||
self.ds.get_database(database).color if database is not None else None
|
||||
),
|
||||
"queries": page.queries,
|
||||
"next": page.next,
|
||||
"next_url": next_url,
|
||||
"has_more": page.has_more,
|
||||
"limit": page.limit,
|
||||
"show_private_note": any(query.is_private for query in page.queries),
|
||||
"show_trusted_note": any(query.is_trusted for query in page.queries),
|
||||
"query_list_path": query_list_path,
|
||||
"show_database": database is None,
|
||||
"facets": facets,
|
||||
"filters": {
|
||||
"q": request.args.get("q") or "",
|
||||
"is_write": request.args.get("is_write") or "",
|
||||
"is_private": request.args.get("is_private") or "",
|
||||
"source": request.args.get("source") or "",
|
||||
"owner_id": request.args.get("owner_id") or "",
|
||||
},
|
||||
}
|
||||
if format_ == "json":
|
||||
return Response.json(
|
||||
{
|
||||
**data,
|
||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
||||
}
|
||||
)
|
||||
return await self.render(
|
||||
["query_list.html"],
|
||||
request,
|
||||
data,
|
||||
)
|
||||
|
||||
|
||||
class GlobalQueryListView(QueryListView):
|
||||
name = "global-query-list"
|
||||
|
||||
async def database_name(self, request):
|
||||
return None
|
||||
|
||||
def query_list_path(self, database):
|
||||
return self.ds.urls.path("/-/queries")
|
||||
|
||||
|
||||
class QueryCreateView(BaseView):
|
||||
name = "query-create"
|
||||
has_json_alternate = False
|
||||
|
||||
async def _render_form(
|
||||
self,
|
||||
request,
|
||||
db,
|
||||
*,
|
||||
sql="",
|
||||
name="",
|
||||
title="",
|
||||
description="",
|
||||
is_private=True,
|
||||
status=200,
|
||||
):
|
||||
response = await self.render(
|
||||
["query_create.html"],
|
||||
request,
|
||||
await _query_create_form_context(
|
||||
self.ds,
|
||||
request,
|
||||
db,
|
||||
sql=sql,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
is_private=is_private,
|
||||
),
|
||||
)
|
||||
response.status = status
|
||||
return response
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
await self.ds.ensure_permission(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
)
|
||||
await self.ds.ensure_permission(
|
||||
action="store-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
return await self._render_form(request, db, sql=request.args.get("sql") or "")
|
||||
|
||||
|
||||
class QueryCreateAnalyzeView(BaseView):
|
||||
name = "query-create-analyze"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
||||
if not await self.ds.allowed(
|
||||
action="store-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(_error(["Permission denied: need store-query"], 403))
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
_error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class QueryStoreView(QueryCreateView):
|
||||
name = "query-store"
|
||||
|
||||
async def _error_response(self, request, db, query_data, message, status):
|
||||
message = _query_create_form_error_message(message)
|
||||
self.ds.add_message(request, message, self.ds.ERROR)
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
sql=query_data.get("sql") or "",
|
||||
name=query_data.get("name") or "",
|
||||
title=query_data.get("title") or "",
|
||||
description=query_data.get("description") or "",
|
||||
is_private=_as_bool(query_data.get("is_private", True)),
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need execute-sql"], 403)
|
||||
if not await self.ds.allowed(
|
||||
action="store-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need store-query"], 403)
|
||||
|
||||
is_json = False
|
||||
query_data = {}
|
||||
try:
|
||||
data, is_json = await _json_or_form_payload(request)
|
||||
if not isinstance(data, dict):
|
||||
raise QueryValidationError("JSON must be a dictionary")
|
||||
query_data = data.get("query") if is_json else data
|
||||
if not isinstance(query_data, dict):
|
||||
raise QueryValidationError("JSON must contain a query dictionary")
|
||||
prepared = await _prepare_query_create(self.ds, request, db, query_data)
|
||||
except QueryValidationError as ex:
|
||||
if not is_json and isinstance(query_data, dict):
|
||||
return await self._error_response(
|
||||
request, db, query_data, ex.message, ex.status
|
||||
)
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
prepared.pop("analysis")
|
||||
name = prepared.pop("name")
|
||||
try:
|
||||
await self.ds.add_query(db.name, name, replace=False, **prepared)
|
||||
except sqlite3.IntegrityError as ex:
|
||||
if not is_json and isinstance(query_data, dict):
|
||||
return await self._error_response(request, db, query_data, str(ex), 400)
|
||||
return _error([str(ex)], 400)
|
||||
|
||||
query = await self.ds.get_query(db.name, name)
|
||||
assert query is not None
|
||||
if is_json:
|
||||
return Response.json(
|
||||
{"ok": True, "query": stored_query_to_dict(query)}, status=201
|
||||
)
|
||||
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
||||
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
|
||||
|
||||
|
||||
class QueryDefinitionView(BaseView):
|
||||
name = "query-definition"
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
query_name = tilde_decode(request.url_vars["query"])
|
||||
query = await self.ds.get_query(db.name, query_name)
|
||||
if query is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="view-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
|
||||
|
||||
|
||||
class QueryUpdateView(BaseView):
|
||||
name = "query-update"
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
query_name = tilde_decode(request.url_vars["query"])
|
||||
existing = await self.ds.get_query(db.name, query_name)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="update-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need update-query"], 403)
|
||||
if existing.is_trusted:
|
||||
return _error(["Trusted queries cannot be updated using the API"], 403)
|
||||
|
||||
try:
|
||||
data, _ = await _json_or_form_payload(request)
|
||||
if not isinstance(data, dict):
|
||||
raise QueryValidationError("JSON must be a dictionary")
|
||||
invalid_keys = set(data) - {"update", "return"}
|
||||
if invalid_keys:
|
||||
raise QueryValidationError(
|
||||
"Invalid keys: {}".format(", ".join(invalid_keys))
|
||||
)
|
||||
update = data.get("update")
|
||||
if not isinstance(update, dict):
|
||||
raise QueryValidationError("JSON must contain an update dictionary")
|
||||
if "sql" in update and not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise QueryValidationError(
|
||||
"Permission denied: need execute-sql", status=403
|
||||
)
|
||||
update_kwargs = await _prepare_query_update(
|
||||
self.ds, request, db, existing, update
|
||||
)
|
||||
except QueryValidationError as ex:
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
||||
if data.get("return"):
|
||||
query = await self.ds.get_query(db.name, query_name)
|
||||
assert query is not None
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"query": stored_query_to_dict(query),
|
||||
}
|
||||
)
|
||||
return Response.json({"ok": True})
|
||||
|
||||
|
||||
class QueryEditView(BaseView):
|
||||
name = "query-edit"
|
||||
has_json_alternate = False
|
||||
|
||||
async def _load(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
query_name = tilde_decode(request.url_vars["query"])
|
||||
existing = await self.ds.get_query(db.name, query_name)
|
||||
return db, query_name, existing
|
||||
|
||||
async def _render_form(
|
||||
self,
|
||||
request,
|
||||
db,
|
||||
existing,
|
||||
*,
|
||||
sql=None,
|
||||
title=None,
|
||||
description=None,
|
||||
is_private=None,
|
||||
status=200,
|
||||
):
|
||||
response = await self.render(
|
||||
["query_edit.html"],
|
||||
request,
|
||||
await _query_edit_form_context(
|
||||
self.ds,
|
||||
request,
|
||||
db,
|
||||
existing,
|
||||
sql=sql,
|
||||
title=title,
|
||||
description=description,
|
||||
is_private=is_private,
|
||||
),
|
||||
)
|
||||
response.status = status
|
||||
return response
|
||||
|
||||
async def get(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
await self.ds.ensure_permission(
|
||||
action="update-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
if existing.is_trusted:
|
||||
return _error(["Trusted queries cannot be edited"], 403)
|
||||
return await self._render_form(request, db, existing)
|
||||
|
||||
async def post(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="update-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need update-query"], 403)
|
||||
if existing.is_trusted:
|
||||
return _error(["Trusted queries cannot be edited"], 403)
|
||||
|
||||
data, _ = await _json_or_form_payload(request)
|
||||
if not isinstance(data, dict):
|
||||
return _error(["Invalid form submission"], 400)
|
||||
sql = data.get("sql")
|
||||
sql = existing.sql if sql is None else sql.strip()
|
||||
title = data.get("title") or ""
|
||||
description = data.get("description") or ""
|
||||
is_private = _as_bool(data.get("is_private"))
|
||||
|
||||
update = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"is_private": is_private,
|
||||
}
|
||||
if sql != existing.sql:
|
||||
if not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
self.ds.add_message(
|
||||
request,
|
||||
"Permission denied: need execute-sql to change the SQL",
|
||||
self.ds.ERROR,
|
||||
)
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
existing,
|
||||
sql=sql,
|
||||
title=title,
|
||||
description=description,
|
||||
is_private=is_private,
|
||||
status=403,
|
||||
)
|
||||
update["sql"] = sql
|
||||
|
||||
try:
|
||||
update_kwargs = await _prepare_query_update(
|
||||
self.ds, request, db, existing, update
|
||||
)
|
||||
except QueryValidationError as ex:
|
||||
self.ds.add_message(request, ex.message, self.ds.ERROR)
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
existing,
|
||||
sql=sql,
|
||||
title=title,
|
||||
description=description,
|
||||
is_private=is_private,
|
||||
status=ex.status,
|
||||
)
|
||||
|
||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
||||
self.ds.add_message(request, "Query updated", self.ds.INFO)
|
||||
return Response.redirect(
|
||||
self.ds.urls.path(self.ds.urls.table(db.name, query_name))
|
||||
)
|
||||
|
||||
|
||||
class QueryDeleteView(BaseView):
|
||||
name = "query-delete"
|
||||
has_json_alternate = False
|
||||
|
||||
async def _load(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
query_name = tilde_decode(request.url_vars["query"])
|
||||
existing = await self.ds.get_query(db.name, query_name)
|
||||
return db, query_name, existing
|
||||
|
||||
async def get(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
await self.ds.ensure_permission(
|
||||
action="delete-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
return await self.render(
|
||||
["query_delete.html"],
|
||||
request,
|
||||
{
|
||||
"database": db.name,
|
||||
"database_color": db.color,
|
||||
"query": stored_query_to_dict(existing),
|
||||
"query_url": self.ds.urls.table(db.name, query_name),
|
||||
},
|
||||
)
|
||||
|
||||
async def post(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="delete-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need delete-query"], 403)
|
||||
|
||||
data, is_json = await _json_or_form_payload(request)
|
||||
await self.ds.remove_query(db.name, query_name)
|
||||
if is_json:
|
||||
return Response.json({"ok": True})
|
||||
self.ds.add_message(
|
||||
request,
|
||||
"Query “{}” deleted".format(existing.title or query_name),
|
||||
self.ds.INFO,
|
||||
)
|
||||
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))
|
||||
File diff suppressed because it is too large
Load diff
1233
datasette/views/table_extras.py
Normal file
1233
datasette/views/table_extras.py
Normal file
File diff suppressed because it is too large
Load diff
253
datasette/write_sql.py
Normal file
253
datasette/write_sql.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .permissions import Resource
|
||||
from .resources import DatabaseResource, TableResource
|
||||
from .utils import named_parameters, sqlite3
|
||||
from .utils.asgi import Forbidden
|
||||
from .utils.sql_analysis import Operation, SQLAnalysis
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import Datasette
|
||||
|
||||
|
||||
class QueryWriteRejected(Exception):
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PermissionRequirement:
|
||||
action: str
|
||||
resource: Resource
|
||||
|
||||
|
||||
PermissionRequirements = tuple[PermissionRequirement, ...]
|
||||
|
||||
|
||||
class WriteSqlOperationDecision:
|
||||
"""What Datasette should do with one operation in user-supplied write SQL."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IgnoreWriteSqlOperation(WriteSqlOperationDecision):
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequireWriteSqlPermissions(WriteSqlOperationDecision):
|
||||
permissions: PermissionRequirements
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RejectWriteSqlOperation(WriteSqlOperationDecision):
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnsupportedWriteSqlOperation(WriteSqlOperationDecision):
|
||||
message: str
|
||||
|
||||
|
||||
def row_mutation_requirements(database: str, table: str) -> PermissionRequirements:
|
||||
resource = TableResource(database=database, table=table)
|
||||
return tuple(
|
||||
PermissionRequirement(action=action, resource=resource)
|
||||
for action in ("insert-row", "update-row", "delete-row")
|
||||
)
|
||||
|
||||
|
||||
def decision_for_write_sql_operation(
|
||||
operation: Operation,
|
||||
) -> WriteSqlOperationDecision:
|
||||
unsupported_message = (
|
||||
f"Unsupported SQL operation: {operation.operation} {operation.target_type}"
|
||||
)
|
||||
if operation.internal:
|
||||
return IgnoreWriteSqlOperation("internal SQLite operation")
|
||||
if operation.operation == "select":
|
||||
return IgnoreWriteSqlOperation("select statement")
|
||||
if operation.operation == "vacuum":
|
||||
return RejectWriteSqlOperation("VACUUM is not allowed in user-supplied SQL")
|
||||
if operation.operation in {"insert", "update", "delete"}:
|
||||
if operation.table_kind == "virtual":
|
||||
return RejectWriteSqlOperation(
|
||||
"Writes to virtual tables are not allowed in user-supplied SQL"
|
||||
)
|
||||
if operation.table_kind == "shadow":
|
||||
return RejectWriteSqlOperation(
|
||||
"Writes to shadow tables are not allowed in user-supplied SQL"
|
||||
)
|
||||
if operation.operation == "function":
|
||||
return IgnoreWriteSqlOperation("SQL function")
|
||||
if (
|
||||
operation.operation == "read"
|
||||
and operation.target_type == "table"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="view-table",
|
||||
resource=TableResource(
|
||||
database=operation.database, table=operation.table
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation in {"insert", "update"}
|
||||
and operation.target_type == "table"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
row_mutation_requirements(
|
||||
database=operation.database,
|
||||
table=operation.table,
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation == "delete"
|
||||
and operation.target_type == "table"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="delete-row",
|
||||
resource=TableResource(
|
||||
database=operation.database, table=operation.table
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if operation.operation == "create" and operation.target_type == "table":
|
||||
if operation.database is None:
|
||||
return UnsupportedWriteSqlOperation(unsupported_message)
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="create-table",
|
||||
resource=DatabaseResource(database=operation.database),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation == "alter"
|
||||
and operation.target_type == "table"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="alter-table",
|
||||
resource=TableResource(
|
||||
database=operation.database, table=operation.table
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation == "drop"
|
||||
and operation.target_type == "table"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="drop-table",
|
||||
resource=TableResource(
|
||||
database=operation.database, table=operation.table
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation in {"create", "drop"}
|
||||
and operation.target_type == "index"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="alter-table",
|
||||
resource=TableResource(
|
||||
database=operation.database, table=operation.table
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
return UnsupportedWriteSqlOperation(unsupported_message)
|
||||
|
||||
|
||||
def operation_is_write(operation: Operation) -> bool:
|
||||
return operation.operation in {
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"create",
|
||||
"alter",
|
||||
"drop",
|
||||
"begin",
|
||||
"commit",
|
||||
"rollback",
|
||||
"savepoint",
|
||||
"attach",
|
||||
"detach",
|
||||
"pragma",
|
||||
"analyze",
|
||||
"reindex",
|
||||
"vacuum",
|
||||
"unknown",
|
||||
}
|
||||
|
||||
|
||||
async def ensure_query_write_permissions(
|
||||
datasette: Datasette,
|
||||
database: str,
|
||||
sql: str,
|
||||
*,
|
||||
actor: dict[str, object] | None = None,
|
||||
params: dict[str, object] | None = None,
|
||||
analysis: SQLAnalysis | None = None,
|
||||
) -> SQLAnalysis:
|
||||
db = datasette.get_database(database)
|
||||
if analysis is None:
|
||||
if params is None:
|
||||
params = {name: "" for name in named_parameters(sql)}
|
||||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
except sqlite3.DatabaseError as ex:
|
||||
raise Forbidden(f"Could not analyze query: {ex}") from ex
|
||||
|
||||
for operation in analysis.operations:
|
||||
decision = decision_for_write_sql_operation(operation)
|
||||
if isinstance(decision, IgnoreWriteSqlOperation):
|
||||
continue
|
||||
if isinstance(decision, RejectWriteSqlOperation):
|
||||
raise QueryWriteRejected(decision.message)
|
||||
if isinstance(decision, UnsupportedWriteSqlOperation):
|
||||
raise Forbidden(decision.message)
|
||||
permissions = decision.permissions
|
||||
if operation.database != database:
|
||||
raise Forbidden("Writable queries may not access attached databases")
|
||||
for permission in permissions:
|
||||
if not await datasette.allowed(
|
||||
action=permission.action,
|
||||
resource=permission.resource,
|
||||
actor=actor,
|
||||
):
|
||||
raise Forbidden(
|
||||
f"Permission denied: need {permission.action} "
|
||||
f"on {permission.resource}"
|
||||
)
|
||||
return analysis
|
||||
|
|
@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
|
|||
How permissions are resolved
|
||||
----------------------------
|
||||
|
||||
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
|
||||
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
|
||||
|
||||
|
|
@ -468,7 +468,7 @@ You can control the following:
|
|||
* Access to the entire Datasette instance
|
||||
* Access to specific databases
|
||||
* Access to specific tables and views
|
||||
* Access to specific :ref:`canned_queries`
|
||||
* Access to specific :ref:`queries <queries>`
|
||||
|
||||
If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within.
|
||||
|
||||
|
|
@ -641,12 +641,12 @@ This works for SQL views as well - you can list their names in the ``"tables"``
|
|||
|
||||
.. _authentication_permissions_query:
|
||||
|
||||
Access to specific canned queries
|
||||
---------------------------------
|
||||
Access to specific queries
|
||||
--------------------------
|
||||
|
||||
:ref:`canned_queries` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.
|
||||
:ref:`Queries <queries>` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.
|
||||
|
||||
To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
|
||||
To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
|
||||
|
||||
.. [[[cog
|
||||
config_example(cog, """
|
||||
|
|
@ -1020,7 +1020,7 @@ You can also restrict permissions such that they can only be used within specifi
|
|||
|
||||
The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database.
|
||||
|
||||
Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <canned_queries>` - within a specific database::
|
||||
Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <queries>` - within a specific database::
|
||||
|
||||
datasette create-token root --resource mydatabase mytable insert-row
|
||||
|
||||
|
|
@ -1285,12 +1285,46 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
|
|||
view-query
|
||||
----------
|
||||
|
||||
Actor is allowed to view (and execute) a :ref:`canned query <canned_queries>` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
|
||||
Actor is allowed to view a stored query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted stored query also requires ``execute-sql`` or the relevant write permissions; :ref:`trusted stored queries <trusted_stored_queries>` can execute with ``view-query`` alone.
|
||||
|
||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``query`` is the name of the canned query (string)
|
||||
|
||||
``query`` is the name of the query (string)
|
||||
|
||||
.. _actions_store_query:
|
||||
|
||||
store-query
|
||||
-----------
|
||||
|
||||
Actor is allowed to create stored queries against a database.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
.. _actions_update_query:
|
||||
|
||||
update-query
|
||||
------------
|
||||
|
||||
Actor is allowed to update a stored query.
|
||||
|
||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``query`` is the name of the query (string)
|
||||
|
||||
.. _actions_delete_query:
|
||||
|
||||
delete-query
|
||||
------------
|
||||
|
||||
Actor is allowed to delete a stored query.
|
||||
|
||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``query`` is the name of the query (string)
|
||||
|
||||
.. _actions_insert_row:
|
||||
|
||||
|
|
@ -1379,13 +1413,23 @@ Actor is allowed to drop a database table.
|
|||
execute-sql
|
||||
-----------
|
||||
|
||||
Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
|
||||
Actor is allowed to run arbitrary read-only SQL queries against a specific database using the :ref:`custom SQL query page <pages_custom_sql_queries>`, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`.
|
||||
|
||||
.. _actions_execute_write_sql:
|
||||
|
||||
execute-write-sql
|
||||
-----------------
|
||||
|
||||
Actor is allowed to run arbitrary writable SQL queries against a specific database using the :ref:`write SQL queries page <pages_execute_write>`, subject to table-level write permissions such as ``insert-row``, ``update-row`` and ``delete-row``. SQL functions are allowed and are not separately restricted by Datasette permissions.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
.. _actions_permissions_debug:
|
||||
|
||||
permissions-debug
|
||||
|
|
@ -1398,4 +1442,4 @@ Actor is allowed to view the ``/-/permissions`` debug tools.
|
|||
debug-menu
|
||||
----------
|
||||
|
||||
Controls if the various debug pages are displayed in the navigation menu.
|
||||
Controls if the various debug pages are displayed in the jump menu.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,159 @@
|
|||
=========
|
||||
Changelog
|
||||
=========
|
||||
.. _unreleased:
|
||||
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
The big feature in this alpha is tools to **insert, edit and delete** rows within the Datasette interface. These features are available on table pages, and edit and delete are also available as action items on the row page.
|
||||
|
||||
The edit interface takes :ref:`custom column types <table_configuration_column_types>` into account. Plugins that define their own column types can use JavaScript to customize how those column types are presented in the edit interface.
|
||||
|
||||
- ``datasette.allowed_many()`` method for :ref:`resolving multiple permission checks at once <datasette_allowed_many>`. (:pr:`2775`)
|
||||
- Permission checks are now cached on a per-request basis, speeding up table pages with multiple plugins that check permissions in order to populate the :ref:`table actions menu <plugin_hook_table_actions>`.
|
||||
- Fixed a warning about ``gen.throw(*sys.exc_info())``. (:issue:`2776`)
|
||||
- New default custom column type ``textarea`` for multi-line text content. This is rendered as a ``<textarea>`` input in the edit UI.
|
||||
- The ``json`` column type now implements client-side validation in the edit UI.
|
||||
- The :ref:`makeColumnField() <javascript_plugins_makeColumnField>` JavaScript plugin hook allows plugins to define custom fields in the edit interface for their custom column types.
|
||||
- New UI for inserting, editing, and deleting rows within Datasette. (:issue:`2780`)
|
||||
- New ``/<database>/<table>/-/autocomplete?q=term`` :ref:`autocomplete JSON API <TableAutocompleteView>` for rapid autocomplete search against the contents of a table. This is used by the edit interface to select related rows for foreign keys. You can try it out on the ``/-/debug/autocomplete`` debug page.
|
||||
- New ``/<database>/<table>/-/fragment`` :ref:`HTML fragment endpoint <TableFragmentView>` for returning the HTML used to display a specific row.
|
||||
- ``await request.json()`` utility method for consuming the request body as JSON. (:issue:`2767`)
|
||||
- Database, table, query and row action menus can now be modified by plugins to :ref:`display buttons in addition to links <plugin_actions>`. (:issue:`2782`)
|
||||
|
||||
.. _v1_0_a33:
|
||||
|
||||
1.0a33 (2026-06-11)
|
||||
-------------------
|
||||
|
||||
Stored queries can now be edited and deleted through the web interface, and the JSON API ``?_extra=`` mechanism has been extended to cover row and query pages in addition to tables. This release also fixes two security issues: an identifier-quoting bug involving table and column names that contain ``]``, and an open redirect.
|
||||
|
||||
Editing and deleting stored queries
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The stored query page gained a "Query actions" menu with **Edit this query** and **Delete this query** links for actors with the necessary permissions. The owner of a query can always edit or delete it; for queries that are not private, any actor with the :ref:`update-query <actions_update_query>` or :ref:`delete-query <actions_delete_query>` permission can do so too. Private queries remain editable and deletable only by their owner. See :ref:`stored_queries` for details. (:issue:`2735`)
|
||||
|
||||
``?_extra=`` support for row and query pages
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Row and query JSON pages now support the same ``?_extra=`` mechanism as table pages. Row pages can request extras such as ``foreign_key_tables``, ``query``, ``metadata`` and ``database_color``; arbitrary SQL and stored query pages can request extras such as ``columns``, ``query``, ``metadata`` and ``private``. The implementation was refactored into a registry of extra classes shared by all three page types.
|
||||
|
||||
New generated reference documentation describes every ``?_extra=`` parameter available on table, row and query JSON pages, with example output captured from a live Datasette instance at documentation build time. See :ref:`json_api_extra` for the full list.
|
||||
|
||||
You can explore the new extras using this `Datasette extras API explorer tool <https://tools.simonwillison.net/datasette-extras-explorer>`__.
|
||||
|
||||
Other improvements and fixes to the extras mechanism:
|
||||
|
||||
- Extras that exist to serve the HTML interface (``filters``, ``actions``, ``display_rows``) are no longer advertised or reachable through the JSON API, where requesting them previously returned a 500 serialization error.
|
||||
- The pre-1.0 ``?_extras=`` (plural) parameter on row pages has been removed - use ``?_extra=foreign_key_tables`` instead.
|
||||
|
||||
Security fixes
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
- Fixed an identifier-quoting bug in ``datasette.utils.escape_sqlite()``. Datasette uses this helper when constructing SQL around table and column names; identifiers containing ``]`` could break out of SQLite bracket quoting and alter the generated SQL, for example by adding a ``UNION SELECT``. Identifiers containing ``]`` are now quoted using double quotes instead. (:issue:`2677`)
|
||||
- Fixed an open redirect vulnerability. Requesting a path such as ``/\example.com/`` produced a redirect with a ``Location: /\example.com`` header - browsers normalize backslashes to forward slashes, turning that into the protocol-relative URL ``//example.com`` and redirecting the user off-site. Any run of leading slashes and backslashes in a redirect path is now collapsed to a single slash. (:issue:`2680`)
|
||||
|
||||
Bug fixes
|
||||
~~~~~~~~~
|
||||
|
||||
- ``can_render()`` callbacks registered by the :ref:`register_output_renderer() <plugin_register_output_renderer>` plugin hook now receive the result ``rows`` and ``columns`` for stored queries. Previously renderers that inspect the available columns - such as `datasette-atom <https://github.com/simonw/datasette-atom>`__ and `datasette-ics <https://github.com/simonw/datasette-ics>`__ - never appeared as export options on stored query pages. (:issue:`2711`)
|
||||
- Fixed a 500 error from the :ref:`/-/check <PermissionCheckView>` permission debugging endpoint when checking query actions such as ``view-query``, ``update-query`` and ``delete-query``. (:issue:`2756`)
|
||||
- Write queries that use a named parameter called ``:sql`` no longer fail with an error. (:issue:`2761`)
|
||||
- :ref:`db.execute_isolated_fn() <database_execute_isolated_fn>` now works against immutable databases, using a read-only connection that bypasses the write thread. It previously always attempted to open a writable connection, which would fail - breaking features built on top of it, such as the SQL analysis step used when storing a query. An exception raised while opening the connection for an isolated function no longer crashes the write thread. (:issue:`2768`)
|
||||
- Facet counts are now displayed on the same line as the facet value instead of wrapping onto a second line. (:issue:`2754`)
|
||||
- Datasette's pytest plugin no longer imports the rest of Datasette at pytest startup time. This means plugin test suites using ``pytest-cov`` now correctly record coverage of code that runs when ``datasette`` modules are first imported.
|
||||
|
||||
.. _v1_0_a32:
|
||||
|
||||
1.0a32 (2026-05-31)
|
||||
-------------------
|
||||
|
||||
SQLite INSERT ... RETURNING clauses are now supported by ``/db/-/execute-write``, plus several fixes relating to the :ref:`base_url setting <setting_base_url>`.
|
||||
|
||||
- ``INSERT``/``UPDATE``/``DELETE`` statements that use SQLite's ``RETURNING`` clause now work correctly in the new ``/db/-/execute-write`` interface. Datasette fetches returned rows before committing the write transaction, displays them in the HTML UI and includes them in the ``"rows"`` key for the JSON API response. (:issue:`2762`, :pr:`2763`)
|
||||
- ``Database.execute_write()`` now returns an ``ExecuteWriteResult`` object instead of the raw ``sqlite3.Cursor`` returned by ``conn.execute()``. The new object exposes ``.rowcount``, ``.lastrowid``, ``.description``, ``.truncated`` and ``.fetchall()``, and adds ``return_all=`` and ``returning_limit=`` options for controlling how rows from ``RETURNING`` statements are buffered. (:pr:`2763`)
|
||||
- Fixed the ``/-/jump`` navigation search endpoint when Datasette is served with a configured ``base_url``. (:issue:`2757`)
|
||||
- Fixed JSON and CSV export links, plus ``Link:`` alternate headers, on table, row and query pages when ``base_url`` is configured. These could previously be prefixed twice. (:issue:`2759`)
|
||||
- Fixed several other ``base_url`` handling bugs, including the API explorer form actions and share links, the ``/-/patterns`` development page, permanent redirects such as ``/-`` to ``/-/`` and database query redirects from ``/<database>?sql=...`` to ``/<database>/-/query?sql=...``.
|
||||
|
||||
.. _v1_0_a31:
|
||||
|
||||
1.0a31 (2026-05-28)
|
||||
-------------------
|
||||
|
||||
Datasette now offers users with the necessary permissions the ability to both **execute write queries** against their database and to **save stored queries** (renamed from "canned queries") both privately and for use by other members of their Datasette instance.
|
||||
|
||||
The ability to write is controlled by the new ``execute-write-sql`` permission, but the user also needs the relevant ``insert-row``/``update-row``/``delete-row``/``create-table``/etc permissions for the query they are trying to execute.
|
||||
|
||||
Write SQL UI
|
||||
~~~~~~~~~~~~
|
||||
|
||||
- New "Write to this database" interface at ``/<database>/-/execute-write`` for running arbitrary writable SQL against mutable databases. The form extracts named parameters, analyzes the SQL, shows the table operations that will be attempted, includes starter templates for ``INSERT``, ``UPDATE`` and ``DELETE`` statements and links to a newly inserted row when a single-row insert succeeds. This is also available as a :ref:`JSON API <ExecuteWriteView>`. (:issue:`2742`)
|
||||
- Added the new :ref:`execute-write-sql <actions_execute_write_sql>` permission for running arbitrary writable SQL. Execution is also gated by table-level permissions such as :ref:`insert-row <actions_insert_row>`, :ref:`update-row <actions_update_row>` and :ref:`delete-row <actions_delete_row>`, and writes to attached databases are rejected. (:issue:`2742`)
|
||||
- The write SQL analyzer now uses a deny-by-default model for unsupported operations. Reads from source tables require :ref:`view-table <actions_view_table>` permission, schema changes require :ref:`create-table <actions_create_table>`, :ref:`alter-table <actions_alter_table>` or :ref:`drop-table <actions_drop_table>` as appropriate, and row mutation statements require the full ``insert-row``, ``update-row`` and ``delete-row`` permission set. SQL functions are allowed and are not separately permission-gated. (:issue:`2748`)
|
||||
- User-supplied write SQL rejects both ``VACUUM`` operations and writes to SQLite virtual or shadow tables. These restrictions also apply to untrusted stored write queries; trusted queries in ``datasette.yml`` skip these filters. (:issue:`2748`)
|
||||
|
||||
Stored queries
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
- The previous "canned queries" feature has been renamed and expanded into :ref:`stored queries <stored_queries>`. Queries configured in ``datasette.yaml`` are now loaded into a new ``queries`` table in Datasette's :ref:`internal database <internals_internal_schema>`, alongside user-created stored queries. (:issue:`2735`)
|
||||
- New stored query management API methods available to plugins: ``datasette.add_query()``, ``datasette.update_query()``, ``datasette.remove_query()``, ``datasette.get_query()``, ``datasette.list_queries()`` and ``datasette.count_queries()``. These replace the removed ``datasette.get_canned_query()`` and ``datasette.get_canned_queries()`` methods. (:issue:`2735`)
|
||||
- Users with :ref:`store-query <actions_store_query>` and :ref:`execute-sql <actions_execute_sql>` permission can create stored queries from the SQL query page or the new ``GET /<database>/-/queries/store`` form. (:issue:`2735`)
|
||||
- The database page now shows a count and preview of stored queries, capped at five, and links to new paginated query lists at ``/-/queries`` and ``/<database>/-/queries``. Those pages support search. (:issue:`2735`)
|
||||
- Stored queries created by users default to private and untrusted. Private stored queries can only be viewed, updated or deleted by their owner, even if another actor has broad ``view-query``, ``update-query`` or ``delete-query`` permission. Untrusted stored queries execute using the permissions of the actor running them. See :ref:`stored_queries` and :ref:`trusted_stored_queries` for details. (:issue:`2735`)
|
||||
- Configured queries from ``datasette.yaml`` are trusted by default, so they can execute with ``view-query`` permission alone. They can opt out of that behavior using ``is_trusted: false`` but cannot be made private; private queries are only available for user-created stored queries. (:issue:`2735`)
|
||||
- New ``store-query``, ``update-query`` and ``delete-query`` permissions, plus updated semantics for :ref:`view-query <actions_view_query>`. Trusted stored queries can still execute with ``view-query`` alone; untrusted read queries also require :ref:`execute-sql <actions_execute_sql>` and untrusted writable queries require :ref:`execute-write-sql <actions_execute_write_sql>` plus the relevant table-level write permissions. (:issue:`2735`)
|
||||
|
||||
Plugin API changes
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() <plugin_hook_top_stored_query>`. (:issue:`2747`)
|
||||
- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new :ref:`stored query management methods <datasette_stored_queries>` together with :ref:`startup() <plugin_hook_startup>` to register queries. (:issue:`2735`)
|
||||
|
||||
Bug fixes
|
||||
~~~~~~~~~
|
||||
|
||||
- Fixed a bug where visiting ``/<database>/-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`)
|
||||
- The ``datasette inspect`` command now correctly records row counts for tables with more than 10,000 rows. (:issue:`2712`)
|
||||
|
||||
.. _v1_0_a30:
|
||||
|
||||
1.0a30 (2026-05-24)
|
||||
-------------------
|
||||
|
||||
The "Jump to" menu, activated by hitting ``/`` or through the application menu, can now be extended by plugins.
|
||||
|
||||
- New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`)
|
||||
- The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``.
|
||||
- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL. ``JumpSQL`` queries run against Datasette's internal database by default, or can target another database using the optional ``database=`` argument. (:issue:`2731`)
|
||||
- ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog.
|
||||
- New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query.
|
||||
- Debug menu links now appear in the jump-to menu instead of the top-right app menu, with descriptions for each debug item.
|
||||
- Dropped Janus as a dependency, previously used to manage the write queue. This should not have any impact on plugin developers or end-users. (:issue:`1752`)
|
||||
- Fixed a bug where stale tables and other related resources were not removed from ``catalog_*`` tables when a database was removed. (:issue:`2723`)
|
||||
- New documented :ref:`datasette.fixtures.populate_fixture_database(conn) <datasette_fixtures_populate_fixture_database>` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites.
|
||||
- Keyboard accessibility and ARIA roles for actions menus, thanks `pintaste <https://github.com/pintaste>`__. (:pr:`2727`)
|
||||
|
||||
.. _v1_0_a29:
|
||||
|
||||
1.0a29 (2026-05-12)
|
||||
-------------------
|
||||
|
||||
- New ``TokenRestrictions.abbreviated(datasette)`` :ref:`utility method <TokenRestrictions>` for creating ``"_r"`` dictionaries. (:issue:`2695`)
|
||||
- Table headers and column options are now visible even if a table contains zero rows. (:issue:`2701`)
|
||||
- Fixed bug with display of column actions dialog on Mobile Safari. (:issue:`2708`)
|
||||
- Fixed bug where tests could crash with a segfault due to a race condition between ``Datasette.close()`` and ``Datasette.close()``. (:issue:`2709`)
|
||||
|
||||
.. _v1_0_a28:
|
||||
|
||||
1.0a28 (2026-04-16)
|
||||
-------------------
|
||||
|
||||
- Fixed a compatibility bug introduced in 1.0a27 where ``execute_write_fn()`` callbacks with a parameter name other than ``conn`` were seeing errors. (:issue:`2691`)
|
||||
- The :ref:`database.close() <database_close>` method now also shuts down the write connection for that database.
|
||||
- New :ref:`datasette.close() <datasette_close>` method for closing down all databases and resources associated with a Datasette instance. This is called automatically when the server shuts down. (:pr:`2693`)
|
||||
- Datasette now includes a pytest plugin which automatically calls ``datasette.close()`` on temporary instances created in function-scoped fixtures and during tests. See :ref:`testing_plugins_autoclose` for details. This helps avoid running out of file descriptors in plugin test suites that were written before the ``Database(is_temp_disk=True)`` feature introduced in Datasette 1.0a27. (:issue:`2692`)
|
||||
|
||||
.. _v1_0_a27:
|
||||
|
||||
|
|
@ -611,7 +764,7 @@ For more information and workarounds, read `the security advisory <https://githu
|
|||
Also in this alpha:
|
||||
|
||||
- The new ``datasette plugins --requirements`` option outputs a list of currently installed plugins in Python ``requirements.txt`` format, useful for duplicating that installation elsewhere. (:issue:`2133`)
|
||||
- :ref:`canned_queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
|
||||
- :ref:`queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
|
||||
- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`)
|
||||
- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
|
||||
|
||||
|
|
@ -1005,7 +1158,7 @@ Other small fixes
|
|||
- The ``base.html`` template now wraps everything other than the ``<footer>`` in a ``<div class="not-footer">`` element, to help with advanced CSS customization. (:issue:`1446`)
|
||||
- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook can now return an awaitable function. This means the hook can execute SQL queries. (:issue:`1425`)
|
||||
- :ref:`plugin_register_routes` plugin hook now accepts an optional ``datasette`` argument. (:issue:`1404`)
|
||||
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`canned_queries_options`. (:issue:`1422`)
|
||||
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`queries_options`. (:issue:`1422`)
|
||||
- New ``--cpu`` option for :ref:`datasette publish cloudrun <publish_cloud_run>`. (:issue:`1420`)
|
||||
- If `Rich <https://github.com/willmcgugan/rich>`__ is installed in the same virtual environment as Datasette, it will be used to provide enhanced display of error tracebacks on the console. (:issue:`1416`)
|
||||
- ``datasette.utils`` :ref:`internals_utils_parse_metadata` function, used by the new `datasette-remote-metadata plugin <https://datasette.io/plugins/datasette-remote-metadata>`__, is now a documented API. (:issue:`1405`)
|
||||
|
|
@ -1379,7 +1532,7 @@ See also `Datasette 0.50: The annotated release notes <https://simonwillison.net
|
|||
|
||||
See also `Datasette 0.49: The annotated release notes <https://simonwillison.net/2020/Sep/15/datasette-0-49/>`__.
|
||||
|
||||
- Writable canned queries now expose a JSON API, see :ref:`canned_queries_json_api`. (:issue:`880`)
|
||||
- Writable canned queries now expose a JSON API, see :ref:`queries_json_api`. (:issue:`880`)
|
||||
- New mechanism for defining page templates with custom path parameters - a template file called ``pages/about/{slug}.html`` will be used to render any requests to ``/about/something``. See :ref:`custom_pages_parameters`. (:issue:`944`)
|
||||
- ``register_output_renderer()`` render functions can now return a ``Response``. (:issue:`953`)
|
||||
- New ``--upgrade`` option for ``datasette install``. (:issue:`945`)
|
||||
|
|
@ -1471,7 +1624,7 @@ Magic parameters for canned queries, a log out feature, improved plugin document
|
|||
Magic parameters for canned queries
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Canned queries now support :ref:`canned_queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
|
||||
Canned queries now support :ref:`queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
|
||||
|
||||
insert into logs
|
||||
(user_id, timestamp)
|
||||
|
|
@ -1502,7 +1655,7 @@ New plugin hooks
|
|||
|
||||
- :ref:`plugin_hook_register_magic_parameters` can be used to define new types of magic canned query parameters.
|
||||
- :ref:`plugin_hook_startup` can run custom code when Datasette first starts up. `datasette-init <https://github.com/simonw/datasette-init>`__ is a new plugin that uses this hook to create database tables and views on startup if they have not yet been created. (:issue:`834`)
|
||||
- :ref:`plugin_hook_canned_queries` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
|
||||
- ``canned_queries()`` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
|
||||
- :ref:`plugin_hook_forbidden` is a hook for customizing how Datasette responds to 403 forbidden errors. (:issue:`812`)
|
||||
|
||||
Smaller changes
|
||||
|
|
@ -1577,7 +1730,7 @@ A new debug page at ``/-/permissions`` shows recent permission checks, to help a
|
|||
Writable canned queries
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Datasette's :ref:`canned_queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
|
||||
Datasette's :ref:`queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
|
||||
|
||||
Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 introduces the ability for canned queries to execute ``INSERT`` or ``UPDATE`` queries as well, using the new ``"write": true`` property (:issue:`800`):
|
||||
|
||||
|
|
@ -1596,7 +1749,7 @@ Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 intr
|
|||
}
|
||||
}
|
||||
|
||||
See :ref:`canned_queries_writable` for more details.
|
||||
See :ref:`queries_writable` for more details.
|
||||
|
||||
Flash messages
|
||||
~~~~~~~~~~~~~~
|
||||
|
|
@ -1651,7 +1804,7 @@ Smaller changes
|
|||
- New ``request.cookies`` property.
|
||||
- ``/-/plugins`` endpoint now shows a list of hooks implemented by each plugin, e.g. https://latest.datasette.io/-/plugins?all=1
|
||||
- ``request.post_vars()`` method no longer discards empty values.
|
||||
- New "params" canned query key for explicitly setting named parameters, see :ref:`canned_queries_named_parameters`. (:issue:`797`)
|
||||
- New "params" canned query key for explicitly setting named parameters, see :ref:`queries_named_parameters`. (:issue:`797`)
|
||||
- ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object.
|
||||
- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)
|
||||
- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)
|
||||
|
|
@ -1685,7 +1838,7 @@ The main focus of this release is a major upgrade to the :ref:`plugin_register_o
|
|||
* Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`)
|
||||
* The :ref:`internals_request`, which is passed to several plugin hooks, is now documented. (:issue:`706`)
|
||||
* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`table_configuration_size`. (:issue:`751`)
|
||||
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`canned_queries_options`. (:issue:`706`)
|
||||
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`queries_options`. (:issue:`706`)
|
||||
* Fixed a bug in ``datasette publish`` when running on operating systems where the ``/tmp`` directory lives in a different volume, using a backport of the Python 3.8 ``shutil.copytree()`` function. (:issue:`744`)
|
||||
* Every plugin hook is now covered by the unit tests, and a new unit test checks that each plugin hook has at least one corresponding test. (:issue:`771`, :issue:`773`)
|
||||
|
||||
|
|
@ -2202,7 +2355,7 @@ A number of small new features:
|
|||
- Documentation for :ref:`datasette publish and datasette package <publishing>`, closes `#337 <https://github.com/simonw/datasette/issues/337>`_
|
||||
- Fixed compatibility with Python 3.7
|
||||
- ``datasette publish heroku`` now supports app names via the ``-n`` option, which can also be used to overwrite an existing application [Russ Garrett]
|
||||
- Title and description metadata can now be set for :ref:`canned SQL queries <canned_queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
|
||||
- Title and description metadata can now be set for :ref:`canned SQL queries <queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
|
||||
- New ``force_https_on`` config option, fixes ``https://`` API URLs when deploying to Zeit Now - closes `#333 <https://github.com/simonw/datasette/issues/333>`_
|
||||
- ``?_json_infinity=1`` query string argument for handling Infinity/-Infinity values in JSON, closes `#332 <https://github.com/simonw/datasette/issues/332>`_
|
||||
- URLs displayed in the results of custom SQL queries are now URLified, closes `#298 <https://github.com/simonw/datasette/issues/298>`_
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ This is equivalent to a ``datasette.yaml`` file containing the following:
|
|||
}
|
||||
.. [[[end]]]
|
||||
|
||||
|
||||
.. _configuration_reference:
|
||||
|
||||
``datasette.yaml`` reference
|
||||
|
|
@ -433,12 +434,12 @@ Here is a simple example:
|
|||
|
||||
:ref:`authentication_permissions_config` has the full details.
|
||||
|
||||
.. _configuration_reference_canned_queries:
|
||||
.. _configuration_reference_queries:
|
||||
|
||||
Canned queries configuration
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Queries configuration
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
:ref:`Canned queries <canned_queries>` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level:
|
||||
:ref:`Queries <queries>` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level:
|
||||
|
||||
.. [[[cog
|
||||
from metadata_doc import config_example, config_example
|
||||
|
|
@ -483,7 +484,7 @@ Canned queries configuration
|
|||
}
|
||||
.. [[[end]]]
|
||||
|
||||
See the :ref:`canned queries documentation <canned_queries>` for more, including how to configure :ref:`writable canned queries <canned_queries_writable>`.
|
||||
See the :ref:`queries documentation <queries>` for more, including how to configure :ref:`writable queries <queries_writable>`.
|
||||
|
||||
.. _configuration_reference_css_js:
|
||||
|
||||
|
|
@ -1101,9 +1102,9 @@ These configure :ref:`full-text search <full_text_search>` for a table or view.
|
|||
``column_types``
|
||||
^^^^^^^^^^^^^^^^
|
||||
|
||||
You can assign semantic column types to columns, which affect how values are rendered, validated, and transformed. Built-in column types include ``url``, ``email``, and ``json``. Plugins can register additional column types using the :ref:`register_column_types <plugin_register_column_types>` plugin hook.
|
||||
You can assign semantic column types to columns, which affect how values are rendered, validated, transformed, and edited. Built-in column types include ``url``, ``email``, ``json``, and ``textarea``. Plugins can register additional column types using the :ref:`register_column_types <plugin_register_column_types>` plugin hook.
|
||||
|
||||
Column types can optionally declare which SQLite column types they apply to using ``sqlite_types``. Datasette will reject incompatible assignments. The built-in ``url``, ``email``, and ``json`` column types are all restricted to ``TEXT`` columns.
|
||||
Column types can optionally declare which SQLite column types they apply to using ``sqlite_types``. Datasette will reject incompatible assignments. The built-in ``url``, ``email``, ``json``, and ``textarea`` column types are all restricted to ``TEXT`` columns.
|
||||
|
||||
The simplest form maps column names to type name strings:
|
||||
|
||||
|
|
@ -1118,6 +1119,7 @@ The simplest form maps column names to type name strings:
|
|||
website: url
|
||||
contact: email
|
||||
extra_data: json
|
||||
notes: textarea
|
||||
""").strip()
|
||||
)
|
||||
.. ]]]
|
||||
|
|
@ -1134,6 +1136,7 @@ The simplest form maps column names to type name strings:
|
|||
website: url
|
||||
contact: email
|
||||
extra_data: json
|
||||
notes: textarea
|
||||
|
||||
.. tab:: datasette.json
|
||||
|
||||
|
|
@ -1147,7 +1150,8 @@ The simplest form maps column names to type name strings:
|
|||
"column_types": {
|
||||
"website": "url",
|
||||
"contact": "email",
|
||||
"extra_data": "json"
|
||||
"extra_data": "json",
|
||||
"notes": "textarea"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1211,4 +1215,3 @@ For column types that accept additional configuration, use an object with ``type
|
|||
}
|
||||
}
|
||||
.. [[[end]]]
|
||||
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ Don't forget to create the release from the correct branch - usually ``main``, b
|
|||
|
||||
While the release is running you can confirm that the correct commits made it into the release using the https://github.com/simonw/datasette/compare/0.64.6...0.64.7 URL.
|
||||
|
||||
Finally, post a news item about the release on `datasette.io <https://datasette.io/>`__ by editing the `news.yaml <https://github.com/simonw/datasette.io/blob/main/news.yaml>`__ file in that site's repository.
|
||||
Finally, post a news item about the release on `datasette.io <https://datasette.io/>`__ by editing the `news.yaml <https://github.com/simonw/datasette.io/blob/main/news.yaml>`__ file in that site's repository. Use `this preview tool <https://tools.simonwillison.net/datasette-io-preview>`__ to preview the edits to the YAML.
|
||||
|
||||
.. _contributing_alpha_beta:
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ The custom SQL template (``/dbname?sql=...``) gets this:
|
|||
|
||||
<body class="query db-dbname">
|
||||
|
||||
A canned query template (``/dbname/queryname``) gets this:
|
||||
A stored query template (``/dbname/queryname``) gets this:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
|
|
@ -193,8 +193,8 @@ The lookup rules Datasette uses are as follows::
|
|||
query-mydatabase.html
|
||||
query.html
|
||||
|
||||
Canned query page (/mydatabase/canned-query):
|
||||
query-mydatabase-canned-query.html
|
||||
Stored query page (/mydatabase/query-name):
|
||||
query-mydatabase-query-name.html
|
||||
query-mydatabase.html
|
||||
query.html
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ will look something like this::
|
|||
|
||||
<!-- Templates considered: *query-mydb-tz.html, query-mydb.html, query.html -->
|
||||
|
||||
This example is from the canned query page for a query called "tz" in the
|
||||
This example is from the stored query page for a query called "tz" in the
|
||||
database called "mydb". The asterisk shows which template was selected - so in
|
||||
this case, Datasette found a template file called ``query-mydb-tz.html`` and
|
||||
used that - but if that template had not been found, it would have tried for
|
||||
|
|
@ -274,13 +274,28 @@ Here is an example of a custom ``_table.html`` template:
|
|||
.. code-block:: jinja
|
||||
|
||||
{% for row in display_rows %}
|
||||
<div>
|
||||
<div data-row="{{ row.row_path }}">
|
||||
<h2>{{ row["title"] }}</h2>
|
||||
<p>{{ row["description"] }}<lp>
|
||||
<p>Category: {{ row.display("category_id") }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
If your custom table template should support Datasette's row editing UI, include
|
||||
``data-row="{{ row.row_path }}"`` on the outer element that represents each row.
|
||||
This does not need to be a ``<tr>``: it can be a ``<div>``, ``<li>`` or any other
|
||||
element that wraps the HTML for that row. Datasette uses this attribute to find
|
||||
the element to remove after a delete, or replace after an edit. Any edit or
|
||||
delete controls should be rendered inside that same element.
|
||||
|
||||
The ``_action_menu.html`` template renders the action menus used by database,
|
||||
table, query and row pages. Plugin-provided actions can be link dictionaries
|
||||
with ``href`` and ``label`` keys, or button dictionaries using ``{"type":
|
||||
"button", "label": "...", "attrs": {...}}`` for JavaScript-backed interactions.
|
||||
Both shapes can include an optional ``description`` key. Custom
|
||||
``_action_menu.html`` templates should preserve support for both link and button
|
||||
action items.
|
||||
|
||||
.. _custom_pages:
|
||||
|
||||
Custom pages
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ The object also has the following awaitable methods:
|
|||
``await request.post_vars()`` - dictionary
|
||||
Returns a dictionary of form variables that were submitted in the request body via ``POST`` using ``application/x-www-form-urlencoded`` encoding. For multipart forms or file uploads, use ``request.form()`` instead.
|
||||
|
||||
``await request.json()`` - Any
|
||||
Returns the parsed JSON body of a request submitted by ``POST``.
|
||||
|
||||
``await request.post_body()`` - bytes
|
||||
Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.
|
||||
|
||||
|
|
@ -512,6 +515,43 @@ Example usage:
|
|||
|
||||
The method returns ``True`` if the permission is granted, ``False`` if denied.
|
||||
|
||||
Results are cached for the duration of the current request, so checking the same ``(actor, action, resource)`` combination twice within one request only does the underlying permission resolution work once.
|
||||
|
||||
.. _datasette_allowed_many:
|
||||
|
||||
await .allowed_many(\*, actions, resource, actor=None)
|
||||
------------------------------------------------------
|
||||
|
||||
``actions`` - list of strings
|
||||
The names of the actions to permission check.
|
||||
|
||||
``resource`` - Resource object
|
||||
A Resource object representing the database, table, or other resource that each action is checked against. Omit for global actions.
|
||||
|
||||
``actor`` - dictionary, optional
|
||||
The authenticated actor. This is usually ``request.actor``. Defaults to ``None`` for unauthenticated requests.
|
||||
|
||||
Checks several actions against the same resource for the same actor, returning a dictionary mapping each action name to ``True`` or ``False``. The whole batch - including any actions pulled in through ``also_requires`` dependencies - is resolved with a single SQL query against the internal database, so this is much faster than calling :ref:`datasette.allowed() <datasette_allowed>` once per action.
|
||||
|
||||
Example usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette.resources import TableResource
|
||||
|
||||
results = await datasette.allowed_many(
|
||||
actions=["insert-row", "delete-row", "drop-table"],
|
||||
resource=TableResource(
|
||||
database="fixtures", table="facetable"
|
||||
),
|
||||
actor=request.actor,
|
||||
)
|
||||
# {"insert-row": True, "delete-row": True, "drop-table": False}
|
||||
|
||||
Each result is stored in the per-request permission check cache, so subsequent ``datasette.allowed()`` calls for the same checks within the same request are served from that cache. Datasette uses this before running the ``table_actions`` and ``database_actions`` plugin hooks: it resolves every registered table-level action against the current table and every database-level action against its database first, which means ``allowed()`` calls made by those plugin hooks are usually served from the cache instead of triggering additional queries.
|
||||
|
||||
Actions for which no plugin provides any permission rules are resolved to ``False`` directly, without being included in the SQL query at all.
|
||||
|
||||
.. _datasette_allowed_resources:
|
||||
|
||||
await .allowed_resources(action, actor=None, \*, parent=None, include_is_private=False, include_reasons=False, limit=100, next=None)
|
||||
|
|
@ -725,10 +765,34 @@ The builder methods are:
|
|||
|
||||
- ``allow_all(action)`` - allow an action across all databases and resources
|
||||
- ``allow_database(database, action)`` - allow an action on a specific database
|
||||
- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`canned query <canned_queries>`) within a database
|
||||
- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`stored query <stored_queries>`) within a database
|
||||
|
||||
Each method returns the ``TokenRestrictions`` instance so calls can be chained.
|
||||
|
||||
``TokenRestrictions`` also provides an ``abbreviated(datasette)`` method which returns the restrictions as a dictionary using the compact format described in :ref:`authentication_cli_create_token_restrict`, with action names replaced by their registered abbreviations. It returns the inner dictionary only - the ``"_r"`` wrapping key shown in that section is not included. Returns ``None`` if no restrictions are set. This is useful when writing a custom :ref:`plugin_hook_register_token_handler` that needs to embed restrictions in a token payload.
|
||||
|
||||
For example, the following restrictions:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
restrictions = (
|
||||
TokenRestrictions()
|
||||
.allow_all("view-instance")
|
||||
.allow_database("docs", "view-query")
|
||||
.allow_resource("docs", "attachments", "insert-row")
|
||||
)
|
||||
restrictions.abbreviated(datasette)
|
||||
|
||||
Returns this dictionary, using the abbreviations registered for each action:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
"a": ["vi"],
|
||||
"d": {"docs": ["vq"]},
|
||||
"r": {"docs": {"attachments": ["ir"]}},
|
||||
}
|
||||
|
||||
The following example creates a token that can access ``view-instance`` and ``view-table`` across everything, can additionally use ``view-query`` for anything in the ``docs`` database and is allowed to execute ``insert-row`` and ``update-row`` in the ``attachments`` table in that database:
|
||||
|
||||
.. code-block:: python
|
||||
|
|
@ -813,10 +877,10 @@ await .get_resource_metadata(self, database_name, resource_name)
|
|||
``database_name`` - string
|
||||
The name of the database to query.
|
||||
``resource_name`` - string
|
||||
The name of the resource (table, view, or canned query) inside ``database_name`` to query.
|
||||
The name of the resource (table, view, or stored query) inside ``database_name`` to query.
|
||||
|
||||
Returns metadata keys and values for the specified "resource" as a dictionary.
|
||||
A "resource" in this context can be a table, view, or canned query.
|
||||
A "resource" in this context can be a table, view, or stored query.
|
||||
Internally queries the ``metadata_resources`` table inside the :ref:`internal database <internals_internal>`.
|
||||
|
||||
.. _datasette_get_column_metadata:
|
||||
|
|
@ -827,7 +891,7 @@ await .get_column_metadata(self, database_name, resource_name, column_name)
|
|||
``database_name`` - string
|
||||
The name of the database to query.
|
||||
``resource_name`` - string
|
||||
The name of the resource (table, view, or canned query) inside ``database_name`` to query.
|
||||
The name of the resource (table, view, or stored query) inside ``database_name`` to query.
|
||||
``column_name`` - string
|
||||
The name of the column inside ``resource_name`` to query.
|
||||
|
||||
|
|
@ -873,7 +937,7 @@ await .set_resource_metadata(self, database_name, resource_name, key, value)
|
|||
``database_name`` - string
|
||||
The database the metadata entry belongs to.
|
||||
``resource_name`` - string
|
||||
The resource (table, view, or canned query) the metadata entry belongs to.
|
||||
The resource (table, view, or stored query) the metadata entry belongs to.
|
||||
``key`` - string
|
||||
The metadata entry key to insert (ex ``title``, ``description``, etc.)
|
||||
``value`` - string
|
||||
|
|
@ -891,7 +955,7 @@ await .set_column_metadata(self, database_name, resource_name, column_name, key,
|
|||
``database_name`` - string
|
||||
The database the metadata entry belongs to.
|
||||
``resource_name`` - string
|
||||
The resource (table, view, or canned query) the metadata entry belongs to.
|
||||
The resource (table, view, or stored query) the metadata entry belongs to.
|
||||
``column-name`` - string
|
||||
The column the metadata entry belongs to.
|
||||
``key`` - string
|
||||
|
|
@ -903,6 +967,200 @@ Adds a new metadata entry for the specified column.
|
|||
Any previous column-level metadata entry with the same ``key`` will be overwritten.
|
||||
Internally upserts the value into the the ``metadata_columns`` table inside the :ref:`internal database <internals_internal>`.
|
||||
|
||||
.. _datasette_stored_queries:
|
||||
|
||||
Stored queries
|
||||
--------------
|
||||
|
||||
:ref:`Stored queries <stored_queries>` are stored in the ``queries`` table in the :ref:`internal database <internals_internal>`. Plugins can use the following methods to add, update, list and remove stored queries.
|
||||
|
||||
.. _datasette_add_query:
|
||||
|
||||
await .add_query(database, name, sql, ...)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Adds a stored query.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def add_query(
|
||||
self,
|
||||
database,
|
||||
name,
|
||||
sql,
|
||||
*,
|
||||
title=None,
|
||||
description=None,
|
||||
description_html=None,
|
||||
hide_sql=False,
|
||||
fragment=None,
|
||||
parameters=None,
|
||||
is_write=False,
|
||||
is_private=False,
|
||||
is_trusted=False,
|
||||
source="plugin",
|
||||
owner_id=None,
|
||||
on_success_message=None,
|
||||
on_success_message_sql=None,
|
||||
on_success_redirect=None,
|
||||
on_error_message=None,
|
||||
on_error_redirect=None,
|
||||
replace=True,
|
||||
): ...
|
||||
|
||||
``database`` - string
|
||||
The name of the database this query should belong to.
|
||||
``name`` - string
|
||||
The name of the stored query, used in the URL for that query.
|
||||
``sql`` - string
|
||||
The SQL for the stored query.
|
||||
``title`` - string, optional
|
||||
A display title for the query.
|
||||
``description`` - string, optional
|
||||
A plain text description.
|
||||
``description_html`` - string, optional
|
||||
An HTML description.
|
||||
``hide_sql`` - boolean, optional
|
||||
Set to ``True`` to hide the SQL by default on the query page.
|
||||
``fragment`` - string, optional
|
||||
A URL fragment to append to query links, for example ``"chart"``.
|
||||
``parameters`` - list of strings, optional
|
||||
Explicit parameter names for the query form. If omitted, Datasette derives parameters from the SQL.
|
||||
``is_write`` - boolean, optional
|
||||
Set to ``True`` for writable queries. They will the run against the SQLite write connection for the database.
|
||||
``is_private`` - boolean, optional
|
||||
Set to ``True`` for private queries. Private queries can only be viewed, updated or deleted by their owner.
|
||||
``is_trusted`` - boolean, optional
|
||||
Set to ``True`` for :ref:`trusted stored queries <trusted_stored_queries>`.
|
||||
``source`` - string, optional
|
||||
Identifies where the query came from. Defaults to ``"plugin"``.
|
||||
``owner_id`` - string, optional
|
||||
Actor ID of the query owner, used by private query permissions.
|
||||
``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message``, ``on_error_redirect`` - strings, optional
|
||||
Options for :ref:`writable queries <queries_writable>`.
|
||||
``replace`` - boolean, optional
|
||||
Defaults to ``True``, which replaces any existing stored query with the same ``database`` and ``name``. Set this to ``False`` to raise a SQLite integrity error if the query already exists.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
await datasette.add_query(
|
||||
database="fixtures",
|
||||
name="recent_rows",
|
||||
sql="select * from facetable order by created desc limit 10",
|
||||
title="Recent rows",
|
||||
source="my-plugin",
|
||||
)
|
||||
|
||||
.. _datasette_update_query:
|
||||
|
||||
await .update_query(database, name, ...)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Updates fields for an existing stored query. Only keyword arguments that are provided will be changed.
|
||||
|
||||
The available keyword arguments are the same as those for :ref:`datasette_add_query`, except for ``replace``. Pass ``None`` to clear optional text fields and options such as ``on_success_redirect``. Passing ``hide_sql=False`` removes the ``hide_sql`` option.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
await datasette.update_query(
|
||||
database="fixtures",
|
||||
name="recent_rows",
|
||||
title="Latest rows",
|
||||
is_private=True,
|
||||
owner_id="alice",
|
||||
)
|
||||
|
||||
.. _datasette_get_query:
|
||||
|
||||
await .get_query(database, name)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Returns a ``StoredQuery`` dataclass instance, or ``None`` if the query does not exist.
|
||||
|
||||
``StoredQuery`` has the following attributes: ``database``, ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write``, ``is_private``, ``is_trusted``, ``source``, ``owner_id``, ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``.
|
||||
|
||||
``parameters`` is a list of explicit parameter names.
|
||||
|
||||
.. _datasette_list_queries:
|
||||
|
||||
await .list_queries(database=None, ...)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Lists stored queries visible to the specified actor.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
async def list_queries(
|
||||
self,
|
||||
database=None,
|
||||
*,
|
||||
actor=None,
|
||||
limit=50,
|
||||
cursor=None,
|
||||
q=None,
|
||||
is_write=None,
|
||||
is_private=None,
|
||||
is_trusted=None,
|
||||
source=None,
|
||||
owner_id=None,
|
||||
include_private=False,
|
||||
): ...
|
||||
|
||||
``database`` - string, optional
|
||||
Restrict results to a specific database. Omit this to list queries across all databases.
|
||||
``actor`` - dictionary, optional
|
||||
The authenticated actor. Results are filtered using that actor's ``view-query`` permission.
|
||||
``limit`` - integer, optional
|
||||
Number of queries to return. Values are clamped to the range 1-1000.
|
||||
``cursor`` - string, optional
|
||||
Pagination cursor from the previous page's ``next`` value.
|
||||
``q`` - string, optional
|
||||
Search string matched against query name, title, description and SQL.
|
||||
``is_write``, ``is_private``, ``is_trusted`` - boolean, optional
|
||||
Filter by those stored query flags.
|
||||
``source`` - string, optional
|
||||
Filter by query source.
|
||||
``owner_id`` - string, optional
|
||||
Filter by owner actor ID.
|
||||
``include_private`` - boolean, optional
|
||||
Set to ``True`` to populate a ``private`` boolean on each returned ``StoredQuery`` indicating if anonymous users would be unable to view that query.
|
||||
|
||||
The return value is a ``StoredQueryPage`` dataclass instance with these attributes:
|
||||
|
||||
``queries`` - list of StoredQuery instances
|
||||
Stored queries in the same format returned by :ref:`datasette_get_query`.
|
||||
``next`` - string or None
|
||||
Pagination cursor for the next page, if one exists.
|
||||
``has_more`` - boolean
|
||||
``True`` if another page of results is available.
|
||||
``limit`` - integer
|
||||
The limit used for this page.
|
||||
|
||||
.. _datasette_count_queries:
|
||||
|
||||
await .count_queries(database=None, ...)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Counts stored queries visible to the specified actor. This accepts the same filtering keyword arguments as :ref:`datasette_list_queries`, except for ``limit``, ``cursor`` and ``include_private``.
|
||||
|
||||
.. _datasette_remove_query:
|
||||
|
||||
await .remove_query(database, name, source=None)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Removes a stored query.
|
||||
|
||||
``database`` - string
|
||||
The database the query belongs to.
|
||||
``name`` - string
|
||||
The query name.
|
||||
``source`` - string, optional
|
||||
If provided, only a query with this source will be removed.
|
||||
|
||||
.. _datasette_column_types:
|
||||
|
||||
Column types
|
||||
|
|
@ -1079,6 +1337,19 @@ The ``name`` and ``route`` parameters are optional and work the same way as they
|
|||
|
||||
This removes a database that has been previously added. ``name=`` is the unique name of that database.
|
||||
|
||||
.. _datasette_close:
|
||||
|
||||
.close()
|
||||
--------
|
||||
|
||||
Release all resources held by this ``Datasette`` instance. This calls :ref:`database_close` on every attached database (including the internal database), shuts down the thread pool executor used to run SQL queries, and unlinks the temporary file used to back the internal database if one was created.
|
||||
|
||||
``close()`` is synchronous, idempotent and one-way: after a call to ``close()`` any attempt to use the Datasette instance to execute SQL will raise a ``datasette.database.DatasetteClosedError`` exception. A closed ``Datasette`` cannot be reopened — callers that need a fresh instance should construct a new one.
|
||||
|
||||
If a call to ``Database.close()`` on one of the attached databases raises an exception, ``Datasette.close()`` will continue trying to close the remaining databases and will re-raise the first exception after every database has been processed.
|
||||
|
||||
When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``.
|
||||
|
||||
.. _datasette_track_event:
|
||||
|
||||
await .track_event(event)
|
||||
|
|
@ -1697,8 +1968,8 @@ Example usage:
|
|||
|
||||
.. _database_execute_write:
|
||||
|
||||
await db.execute_write(sql, params=None, block=True)
|
||||
----------------------------------------------------
|
||||
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10)
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
|
||||
|
||||
|
|
@ -1706,7 +1977,30 @@ This method can be used to queue up a non-SELECT SQL query to be executed agains
|
|||
|
||||
You can pass additional SQL parameters as a tuple or dictionary.
|
||||
|
||||
The method will block until the operation is completed, and the return value will be the return from calling ``conn.execute(...)`` using the underlying ``sqlite3`` Python library.
|
||||
The optional ``request=`` argument is used internally by Datasette to pass request context to :ref:`write_wrapper plugin hooks <plugin_hook_write_wrapper>`.
|
||||
|
||||
The method will block until the operation is completed, and the return value will be an ``ExecuteWriteResult`` object. This imitates a subset of the ``sqlite3.Cursor`` object:
|
||||
|
||||
``.rowcount``
|
||||
The number of rows modified by the statement, or ``-1`` if that number is unavailable.
|
||||
|
||||
``.lastrowid``
|
||||
The row ID of the last modified row, as returned by ``sqlite3.Cursor.lastrowid``.
|
||||
|
||||
``.description``
|
||||
The same column metadata exposed by Python's `sqlite3.Cursor.description <https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.description>`__: one tuple per returned column, or ``None`` if the statement does not return rows.
|
||||
|
||||
``.truncated``
|
||||
``True`` if the statement returned more rows than ``returning_limit``.
|
||||
|
||||
``.fetchall()``
|
||||
Returns any rows buffered by Datasette from the statement, such as rows from SQLite's ``RETURNING`` clause. This may be limited by ``returning_limit`` unless ``return_all=True`` was used. This method empties the buffer, so calling it again will return an empty list.
|
||||
|
||||
SQLite statements using ``RETURNING`` must have their rows consumed before the transaction can commit. Datasette will fetch up to ``returning_limit + 1`` rows before committing, store up to ``returning_limit`` rows on the result object and set ``.truncated`` if there were more. The default ``returning_limit`` is ``10``.
|
||||
|
||||
When ``.truncated`` is ``True``, ``.rowcount`` will be ``-1``. SQLite only reports the final row count for a ``RETURNING`` statement after every returned row has been fetched, and Datasette has deliberately stopped fetching rows after ``returning_limit`` to avoid buffering a potentially large result in memory.
|
||||
|
||||
If you need to retrieve every row returned by a statement, pass ``return_all=True``. This will buffer all returned rows in memory before committing.
|
||||
|
||||
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
|
||||
|
||||
|
|
@ -1830,7 +2124,11 @@ The return value of the function will be returned by this method. Any exceptions
|
|||
db.close()
|
||||
----------
|
||||
|
||||
Closes all of the open connections to file-backed databases. This is mainly intended to be used by large test suites, to avoid hitting limits on the number of open files.
|
||||
Release all resources held by this ``Database`` instance. This shuts down the background write thread (if one was started by a previous call to :ref:`database_execute_write_fn` or similar), closes the write connection, and closes any cached read connections.
|
||||
|
||||
After ``db.close()`` has been called, any further call to :ref:`database_execute`, :ref:`database_execute_fn`, :ref:`database_execute_write`, :ref:`database_execute_write_fn`, :ref:`database_execute_write_many`, :ref:`database_execute_write_script` or :ref:`database_execute_isolated_fn` will raise a ``datasette.database.DatasetteClosedError`` exception.
|
||||
|
||||
``close()`` is idempotent — calling it a second time is a no-op. It is one-way: a closed ``Database`` cannot be reopened.
|
||||
|
||||
.. _internals_database_introspection:
|
||||
|
||||
|
|
@ -2107,6 +2405,26 @@ The internal database schema is as follows:
|
|||
config TEXT,
|
||||
PRIMARY KEY (database_name, resource_name, column_name)
|
||||
);
|
||||
CREATE TABLE 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 queries_owner_idx
|
||||
ON queries(owner_id);
|
||||
|
||||
.. [[[end]]]
|
||||
|
||||
|
|
@ -2178,8 +2496,8 @@ Note that the space character is a special case: it will be replaced with a ``+`
|
|||
|
||||
.. _internals_utils_call_with_supported_arguments:
|
||||
|
||||
call_with_supported_arguments(fn, **kwargs)
|
||||
-------------------------------------------
|
||||
call_with_supported_arguments(fn, \*\*kwargs)
|
||||
---------------------------------------------
|
||||
|
||||
Call ``fn``, passing it only those keyword arguments that match its function signature. This implements a dependency injection pattern - the caller provides all available arguments, and the function receives only the ones it declares as parameters.
|
||||
|
||||
|
|
@ -2206,8 +2524,8 @@ This is useful in plugins that want to define callback functions that only decla
|
|||
|
||||
.. _internals_utils_async_call_with_supported_arguments:
|
||||
|
||||
await async_call_with_supported_arguments(fn, **kwargs)
|
||||
-------------------------------------------------------
|
||||
await async_call_with_supported_arguments(fn, \*\*kwargs)
|
||||
---------------------------------------------------------
|
||||
|
||||
Async version of :ref:`call_with_supported_arguments <internals_utils_call_with_supported_arguments>`. Use this for ``async def`` callback functions.
|
||||
|
||||
|
|
|
|||
|
|
@ -144,46 +144,71 @@ Shows currently attached databases. `Databases example <https://latest.datasette
|
|||
}
|
||||
]
|
||||
|
||||
.. _TablesView:
|
||||
.. _JumpView:
|
||||
|
||||
/-/tables
|
||||
---------
|
||||
/-/jump
|
||||
-------
|
||||
|
||||
Returns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.
|
||||
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and stored queries, and plugins can contribute additional items.
|
||||
|
||||
The endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.
|
||||
Each item includes a ``type`` string used as a category label in the menu. Items can also include an optional ``description`` with longer text describing that individual result.
|
||||
|
||||
`Tables example <https://latest.datasette.io/-/tables>`_:
|
||||
The endpoint supports a ``?q=`` query parameter for filtering items by name.
|
||||
|
||||
`Jump example <https://latest.datasette.io/-/jump>`_:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
"name": "fixtures",
|
||||
"url": "/fixtures",
|
||||
"type": "database",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"name": "fixtures/searchable",
|
||||
"url": "/fixtures/searchable"
|
||||
"name": "fixtures: facetable",
|
||||
"url": "/fixtures/facetable",
|
||||
"type": "table",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"name": "fixtures: recent_releases",
|
||||
"url": "/fixtures/recent_releases",
|
||||
"type": "query",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
|
||||
Search example with ``?q=facet`` returns only tables matching ``.*facet.*``:
|
||||
Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
"name": "fixtures: facetable",
|
||||
"url": "/fixtures/facetable",
|
||||
"type": "table",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name.
|
||||
|
||||
.. _AutocompleteDebugView:
|
||||
|
||||
/-/debug/autocomplete
|
||||
---------------------
|
||||
|
||||
The debug tool at ``/-/debug/autocomplete`` can be used to try out the autocomplete component against a specific table. Pass ``?database=db&table=table`` to display an autocomplete field backed by that table's ``/-/autocomplete`` endpoint.
|
||||
|
||||
Without those query string arguments, the page lists up to five tables with detected label columns, scanning at most 100 tables.
|
||||
|
||||
.. _JsonDataView_threads:
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ The ``datasetteManager`` object
|
|||
``registerPlugin(name, implementation)``
|
||||
Call this to register a plugin, passing its name and implementation
|
||||
|
||||
``makeColumnField(context)``
|
||||
Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs.
|
||||
|
||||
``selectors`` - object
|
||||
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
|
||||
|
||||
|
|
@ -58,6 +61,52 @@ JavaScript plugins are blocks of code that can be registered with Datasette usin
|
|||
|
||||
The ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:
|
||||
|
||||
.. _javascript_plugins_makeJumpSections:
|
||||
|
||||
makeJumpSections(context)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the ``/`` jump menu, before the user starts typing a search.
|
||||
|
||||
It should return an array of objects, each with the following:
|
||||
|
||||
``id`` - string
|
||||
A unique string ID for the section, for example ``agent-chat``
|
||||
``render(node, context)`` - function
|
||||
A function that will be called with a DOM node to render the section into
|
||||
|
||||
Datasette passes a ``context`` object to both ``makeJumpSections(context)`` and ``render(node, context)``. It has the following keys:
|
||||
|
||||
``navigationSearch``
|
||||
The ``<navigation-search>`` custom element instance.
|
||||
``container`` - only for ``render()``
|
||||
The ``.results-container`` element used by the jump menu.
|
||||
``input`` - only for ``render()``
|
||||
The ``.search-input`` element used by the jump menu.
|
||||
|
||||
This example shows how a plugin might add a button for starting a new chat:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener('datasette_init', function(ev) {
|
||||
ev.detail.registerPlugin('agent-plugin', {
|
||||
version: 0.1,
|
||||
makeJumpSections: (context) => {
|
||||
return [
|
||||
{
|
||||
id: 'agent-chat',
|
||||
render: (node, context) => {
|
||||
node.innerHTML = '<button type="button">Start a new chat</button>';
|
||||
node.querySelector('button').addEventListener('click', () => {
|
||||
location.href = '/-/agent/new';
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
.. _javascript_plugins_makeAboveTablePanelConfigs:
|
||||
|
||||
makeAboveTablePanelConfigs()
|
||||
|
|
@ -146,6 +195,285 @@ This example plugin adds two menu items - one to copy the column name to the cli
|
|||
});
|
||||
});
|
||||
|
||||
.. _javascript_plugins_makeColumnField:
|
||||
|
||||
makeColumnField(context)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This method, if present, can provide a custom form field for a column in Datasette's row insert and edit dialogs.
|
||||
|
||||
It is designed for plugins that :ref:`register custom column types <plugin_register_column_types>` using the Python ``register_column_types()`` plugin hook. For example, a plugin that defines a ``file`` column type can use ``makeColumnField()`` to replace a plain text input with a file picker, and a plugin that defines a rich text column type can use it to enhance the field with an editor.
|
||||
|
||||
Datasette calls ``makeColumnField(context)`` on each registered JavaScript plugin when it renders an editable insert/edit field. Plugins should inspect the ``context`` object and only return a control object if they can handle that field. Otherwise, use a bare ``return;``.
|
||||
|
||||
The first plugin to return a truthy control object is used for that field. Plugins are called in registration order. If a plugin raises an exception, Datasette logs the error to the browser console and continues to the next plugin.
|
||||
|
||||
The row dialog tracks the value that will be sent to the insert/update API. The ``context`` object describes the column and form environment; custom controls should read and write field values using the ``field`` helper object passed to ``render(field)``.
|
||||
|
||||
Context object
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
``makeColumnField(context)`` is called with a context object describing the field. The current context object has these keys:
|
||||
|
||||
``mode`` - string
|
||||
``"insert"`` or ``"edit"``.
|
||||
|
||||
``database`` - string or null
|
||||
The database name.
|
||||
|
||||
``table`` - string or null
|
||||
The table name.
|
||||
|
||||
``tableUrl`` - string or null
|
||||
The path to the table page, including any configured :ref:`base URL prefix <setting_base_url>`.
|
||||
|
||||
``column`` - string
|
||||
The column name.
|
||||
|
||||
``columnType`` - object or null
|
||||
The configured Datasette column type for this column, if one exists. This is ``null`` if no column type has been configured.
|
||||
|
||||
If present, this object has exactly these keys:
|
||||
|
||||
``type`` - string
|
||||
The :ref:`registered column type name <plugin_register_column_types>`, matching the ``name`` attribute of the Python ``ColumnType`` subclass.
|
||||
|
||||
``config`` - object
|
||||
Configuration for this specific column type assignment. This is ``{}`` if no configuration has been set.
|
||||
|
||||
``sqliteType`` - string or null
|
||||
The SQLite affinity for this column, if known. This is one of ``"TEXT"``, ``"INTEGER"``, ``"REAL"``, ``"BLOB"``, ``"NUMERIC"`` or ``null`` if Datasette could not determine the affinity.
|
||||
|
||||
``notNull`` - boolean
|
||||
True if the column is defined as ``NOT NULL``.
|
||||
|
||||
``isPk`` - boolean
|
||||
True if this column is part of the table's primary key.
|
||||
|
||||
``defaultExpression`` - string or null
|
||||
The SQLite default expression for the column, if available. This is ``null`` if the column has no SQLite default. For example, a column defined with ``DEFAULT (datetime('now'))`` will have ``"datetime('now')"`` here. This is the expression from the table schema, not the actual value SQLite will insert.
|
||||
|
||||
``form`` - ``HTMLFormElement`` or null
|
||||
The row insert/edit form element.
|
||||
|
||||
``dialog`` - ``HTMLDialogElement`` or null
|
||||
The modal dialog element.
|
||||
|
||||
Returned control object
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
A plugin that wants to handle a field should return an object. Datasette currently recognizes these properties:
|
||||
|
||||
``useTextarea`` - boolean, optional
|
||||
If true, Datasette creates a ``<textarea>`` as the underlying ``field.input`` before calling ``render()``. If omitted, Datasette chooses either an ``<input>`` or ``<textarea>`` based on the column type and current value.
|
||||
|
||||
``render(field)`` - function
|
||||
Called once to render the custom field UI. ``field`` is a helper object described below.
|
||||
|
||||
The recommended pattern is to return a DOM node from ``render()``. Datasette appends that node to ``field.root``, a ``<div>`` inside the control area for that field in the row insert/edit dialog. A plugin can alternatively manipulate ``field.root`` directly and return nothing.
|
||||
|
||||
``focus(field)`` - function, optional
|
||||
Called when Datasette wants to focus this field, for example when focusing the first editable field in the dialog. Use this to focus the most useful interactive element inside the custom UI.
|
||||
|
||||
``destroy(field)`` - function, optional
|
||||
Called when Datasette tears down the insert/edit form. Use this to remove event listeners, close nested pickers, revoke object URLs, clear timers, or release other resources.
|
||||
|
||||
The field helper object
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy(field)`` provides stable IDs, DOM elements and value helpers for integrating with the row insert/edit dialog:
|
||||
|
||||
``context`` - object
|
||||
The original context object passed to ``makeColumnField()``.
|
||||
|
||||
``id`` - string
|
||||
The ID Datasette assigned to ``field.input``, the backing ``<input>`` or ``<textarea>`` element.
|
||||
|
||||
``labelId`` - string
|
||||
The ID of the visible field label.
|
||||
|
||||
``descriptionId`` - string
|
||||
The ID of the field metadata/help text. This metadata can include details such as ``Primary key``, ``Required``, ``Current value: NULL`` or ``Custom type: file``.
|
||||
|
||||
``root`` - ``HTMLElement``
|
||||
The empty ``<div>`` container created by Datasette for this custom field. It is inside the control area for the field in the row insert/edit dialog, next to the field label and above the field metadata. Datasette appends the DOM node returned by ``render(field)`` to this element. Plugins can alternatively manipulate this element directly and return nothing from ``render(field)``.
|
||||
|
||||
``input`` - ``HTMLInputElement`` or ``HTMLTextAreaElement``
|
||||
The core-owned backing form control. Plugins can keep this visible, wrap it or hide it, but should use the value helper methods below rather than mutating ``input.value`` directly.
|
||||
|
||||
``control``
|
||||
An alias for ``input``.
|
||||
|
||||
``meta`` - ``HTMLElement`` or null
|
||||
The field metadata/help text element.
|
||||
|
||||
``form`` - ``HTMLFormElement`` or null
|
||||
The containing row insert/edit form.
|
||||
|
||||
``dialog`` - ``HTMLDialogElement`` or null
|
||||
The containing modal dialog.
|
||||
|
||||
``getValue()`` - function
|
||||
Returns the current value for this field.
|
||||
|
||||
Datasette uses string values by default. Insert fields for ``"INTEGER"`` and ``"REAL"`` SQLite columns return numbers, or ``null`` if left blank. Plugins can use strings, numbers, booleans or ``null``. If a plugin is editing structured data stored in a SQLite ``TEXT`` column, such as JSON, it should serialize that data to a string before calling ``setValue()``.
|
||||
|
||||
``setValue(value)`` - function
|
||||
Sets the current value for this field. ``value`` should be a string, number, boolean or ``null``.
|
||||
|
||||
Calling ``setValue()`` also stops using the SQLite default for the field, if it was previously selected.
|
||||
|
||||
``getInitialValue()`` - function
|
||||
Returns the submitted-value representation the field had when the form was rendered. For edit forms this is the raw row value from the database. For insert forms this is the blank starting value.
|
||||
|
||||
``hasChanged()`` - function
|
||||
Returns true if the field has changed since Datasette last considered it unmodified. By default that means the field state when the insert/edit form was rendered.
|
||||
|
||||
``clearValue()`` - function
|
||||
Sets the value to ``null``.
|
||||
|
||||
``markClean()`` - function
|
||||
Tells Datasette to treat the field's current state as unmodified. After calling this method, ``hasChanged()`` returns false until the field value changes again or its SQLite-default state changes.
|
||||
|
||||
This is useful when a plugin rewrites the starting value into an equivalent representation while initializing its editor. For example, a rich text editor might normalize empty HTML or reserialize its initial document before the user has made any edits.
|
||||
|
||||
``isUsingSqliteDefault()`` - function
|
||||
Returns true if the insert dialog is currently set to omit this column and use the SQLite default.
|
||||
|
||||
``setValidity(message)`` - function
|
||||
Sets a custom validation message for this field, marks the backing input with ``aria-invalid="true"`` and shows the message in the field metadata area. Pass an empty string to clear the error.
|
||||
|
||||
``clearValidity()`` - function
|
||||
Clears any custom validation message previously set by ``setValidity()``.
|
||||
|
||||
Submitted value contract
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The ``field.setValue()`` method accepts the following value types:
|
||||
|
||||
* string
|
||||
* number
|
||||
* boolean
|
||||
* ``null``
|
||||
|
||||
These values are used as column values in requests to the :ref:`insert rows <TableInsertView>` and :ref:`update row <RowUpdateView>` JSON APIs.
|
||||
|
||||
Plugins should not pass objects or arrays to ``field.setValue()``. If a column stores structured data in SQLite, such as JSON in a ``TEXT`` column, the plugin should serialize that data first and submit the serialized string. Client-side parsing can still be useful for validation or editor state, but the submitted value should match the SQLite value Datasette should write.
|
||||
|
||||
Value helpers
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
Custom fields should use ``field.getValue()`` and ``field.setValue(value)`` for value handling:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
const currentValue = field.getValue();
|
||||
field.setValue("new value");
|
||||
field.setValue(null);
|
||||
|
||||
Plugins can keep the core input visible, wrap it in a custom element, or hide it and provide a richer interface. If the input is hidden, the custom UI must still expose an accessible name, state and keyboard interaction.
|
||||
|
||||
``field.setValue()`` updates both ``field.input`` and the value used in the insert/update request.
|
||||
|
||||
For example, a file picker that stores a selected file ID can hide the backing input and call ``field.setValue()`` when the selection changes:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
field.input.type = "hidden";
|
||||
field.setValue(fileId);
|
||||
|
||||
For insert forms with a SQLite default, ``field.isUsingSqliteDefault()`` indicates whether Datasette will omit that column from the insert payload. Calling ``field.setValue(value)`` automatically stops using the SQLite default.
|
||||
|
||||
Lazy loading large controls
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The JavaScript file that registers ``makeColumnField()`` should be small. If the actual control is large, load it from inside ``render()`` using dynamic ``import()``. That way the heavier code is only downloaded after a user opens an insert/edit dialog containing a matching column type.
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
const editorUrl = new URL("./editor.js", import.meta.url).href;
|
||||
|
||||
document.addEventListener("datasette_init", function (event) {
|
||||
event.detail.registerPlugin("my-editor", {
|
||||
version: "0.1",
|
||||
|
||||
makeColumnField(context) {
|
||||
if (!context.columnType || context.columnType.type !== "my-editor") {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
useTextarea: true,
|
||||
render(field) {
|
||||
import(editorUrl).then(function () {
|
||||
// Enhance field.input here.
|
||||
});
|
||||
return field.input;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Example: textarea-backed custom element
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This example handles a ``markdown-editor`` column type by asking Datasette for a textarea and wrapping that textarea in a custom ``<my-markdown-editor>`` Web Component element:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener("datasette_init", function (event) {
|
||||
event.detail.registerPlugin("markdown-editor", {
|
||||
version: "0.1",
|
||||
|
||||
makeColumnField(context) {
|
||||
if (!context.columnType || context.columnType.type !== "markdown-editor") {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
useTextarea: true,
|
||||
|
||||
render(field) {
|
||||
const editor = document.createElement("my-markdown-editor");
|
||||
editor.appendChild(field.input);
|
||||
|
||||
if (field.labelId) {
|
||||
field.input.setAttribute("aria-labelledby", field.labelId);
|
||||
}
|
||||
if (field.descriptionId) {
|
||||
field.input.setAttribute("aria-describedby", field.descriptionId);
|
||||
}
|
||||
|
||||
return editor;
|
||||
},
|
||||
|
||||
focus(field) {
|
||||
const editor = field.root.querySelector("my-markdown-editor");
|
||||
if (editor && editor.focus) {
|
||||
editor.focus();
|
||||
} else {
|
||||
field.input.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Accessibility
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
Custom fields are responsible for preserving the accessibility of the form:
|
||||
|
||||
- The visible field label should name the control. Use ``field.labelId`` with ``aria-labelledby`` when wrapping or replacing the visible input.
|
||||
- Field metadata should remain available to assistive technology. Use ``field.descriptionId`` with ``aria-describedby``.
|
||||
- Keyboard users must be able to operate every part of the custom field.
|
||||
- If the field opens an inline picker or other nested UI, ``Escape`` should close that nested UI first and return focus to a sensible element.
|
||||
- If a control performs asynchronous loading, expose loading and error states in the UI. Use appropriate ARIA live regions where the state change is important to understand the field.
|
||||
- If a plugin hides ``field.input``, the replacement UI must still make the current value and available actions clear.
|
||||
|
||||
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
|
||||
|
||||
.. _javascript_datasette_manager_selectors:
|
||||
|
||||
Selectors
|
||||
|
|
|
|||
1131
docs/json_api.rst
1131
docs/json_api.rst
File diff suppressed because it is too large
Load diff
148
docs/json_api_doc.py
Normal file
148
docs/json_api_doc.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import asyncio
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import textwrap
|
||||
|
||||
|
||||
def table_extras(cog):
|
||||
from datasette.extras import ExtraScope
|
||||
from datasette.views.table_extras import table_extra_registry
|
||||
|
||||
scopes = [
|
||||
(
|
||||
ExtraScope.TABLE,
|
||||
"Table JSON responses",
|
||||
"The available table extras are listed below.",
|
||||
),
|
||||
(
|
||||
ExtraScope.ROW,
|
||||
"Row JSON responses",
|
||||
"The following extras are available for row JSON responses.",
|
||||
),
|
||||
(
|
||||
ExtraScope.QUERY,
|
||||
"Query JSON responses",
|
||||
(
|
||||
"The following extras are available for arbitrary SQL query "
|
||||
"responses and stored, named query responses."
|
||||
),
|
||||
),
|
||||
]
|
||||
classes_by_scope = [
|
||||
(scope, heading, intro, table_extra_registry.public_classes_for_scope(scope))
|
||||
for scope, heading, intro in scopes
|
||||
]
|
||||
|
||||
live_examples = asyncio.run(
|
||||
_fetch_live_examples(
|
||||
[
|
||||
(scope, cls)
|
||||
for scope, _, _, classes in classes_by_scope
|
||||
for cls in classes
|
||||
]
|
||||
)
|
||||
)
|
||||
cog.out("\n")
|
||||
for scope, heading, intro, classes in classes_by_scope:
|
||||
cog.out("{}\n{}\n\n".format(heading, "~" * len(heading)))
|
||||
cog.out("{}\n\n".format(intro))
|
||||
for cls in classes:
|
||||
examples = _examples_for_scope(cls, scope)
|
||||
description = cls.description or ""
|
||||
notes = []
|
||||
if cls.expensive:
|
||||
notes.append("May execute additional queries.")
|
||||
if cls.docs_note:
|
||||
notes.append(cls.docs_note)
|
||||
if notes:
|
||||
description = "{} ({})".format(description, " ".join(notes)).strip()
|
||||
|
||||
cog.out("``{}``\n".format(cls.key()))
|
||||
cog.out(" {}\n\n".format(description))
|
||||
for example in examples:
|
||||
if example.path:
|
||||
value = live_examples[(example.path, example.key or cls.key())]
|
||||
cog.out(" ``GET {}``\n\n".format(example.path))
|
||||
else:
|
||||
value = example.value
|
||||
if example.note:
|
||||
cog.out(" {}\n\n".format(example.note))
|
||||
cog.out(" .. code-block:: json\n\n")
|
||||
cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
|
||||
cog.out("\n\n")
|
||||
|
||||
|
||||
def _examples_for_scope(cls, scope):
|
||||
examples = cls.example_for_scope(scope)
|
||||
if examples is None:
|
||||
return []
|
||||
if isinstance(examples, list):
|
||||
return examples
|
||||
return [examples]
|
||||
|
||||
|
||||
async def _fetch_live_examples(scoped_classes):
|
||||
from datasette.app import Datasette
|
||||
from datasette.fixtures import write_fixture_database
|
||||
|
||||
examples = {}
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = pathlib.Path(tmpdir) / "fixtures.db"
|
||||
write_fixture_database(db_path)
|
||||
datasette = Datasette(
|
||||
[str(db_path)],
|
||||
settings={"num_sql_threads": 1},
|
||||
metadata={
|
||||
"databases": {
|
||||
"fixtures": {
|
||||
"tables": {
|
||||
"facetable": {
|
||||
"description": "A demo table of places, used to demonstrate facets",
|
||||
"columns": {"state": "Two letter US state code"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
config={
|
||||
"databases": {
|
||||
"fixtures": {
|
||||
"tables": {
|
||||
"facetable": {
|
||||
"column_types": {"tags": "json"},
|
||||
}
|
||||
},
|
||||
"queries": {
|
||||
"neighborhood_search": {
|
||||
"sql": textwrap.dedent("""
|
||||
select _neighborhood, facet_cities.name, state
|
||||
from facetable
|
||||
join facet_cities
|
||||
on facetable._city_id = facet_cities.id
|
||||
where _neighborhood like '%' || :text || '%'
|
||||
order by _neighborhood;
|
||||
"""),
|
||||
"title": "Search neighborhoods",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
try:
|
||||
for scope, cls in scoped_classes:
|
||||
for example in _examples_for_scope(cls, scope):
|
||||
if not example.path:
|
||||
continue
|
||||
key = example.key or cls.key()
|
||||
response = await datasette.client.get(example.path)
|
||||
assert response.status_code == 200, example.path
|
||||
data = response.json()
|
||||
assert key in data, "{} missing from {}".format(key, example.path)
|
||||
examples[(example.path, key)] = data[key]
|
||||
finally:
|
||||
for db in datasette.databases.values():
|
||||
if not db.is_memory:
|
||||
db.close()
|
||||
return examples
|
||||
|
|
@ -28,7 +28,7 @@ The index page can also be accessed at ``/-/``, useful for if the default index
|
|||
Database
|
||||
========
|
||||
|
||||
Each database has a page listing the tables, views and canned queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data.
|
||||
Each database has a page listing the tables, views and stored queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data.
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
@ -62,16 +62,43 @@ The following tables are hidden by default:
|
|||
Queries
|
||||
=======
|
||||
|
||||
.. _pages_custom_sql_queries:
|
||||
|
||||
Custom SQL queries
|
||||
------------------
|
||||
|
||||
The ``/database-name/-/query`` page can be used to execute an arbitrary SQL query against that database, if the :ref:`actions_execute_sql` permission is enabled. This query is passed as the ``?sql=`` query string parameter.
|
||||
|
||||
This means you can link directly to a query by constructing the following URL:
|
||||
|
||||
``/database-name/-/query?sql=SELECT+*+FROM+table_name``
|
||||
|
||||
Each configured :ref:`canned query <canned_queries>` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results.
|
||||
Each configured :ref:`stored query <stored_queries>` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results.
|
||||
|
||||
In both cases adding a ``.json`` extension to the URL will return the results as JSON.
|
||||
|
||||
.. _pages_execute_write:
|
||||
|
||||
Write SQL queries
|
||||
-----------------
|
||||
|
||||
The ``/database-name/-/execute-write`` page can be used to execute SQL statements that write to a mutable database, if the :ref:`actions_execute_write_sql` permission is enabled.
|
||||
|
||||
This page extracts named parameters from the SQL, shows the tables that will be affected and lists the permissions required before the query can be executed. It also includes templates for common ``INSERT``, ``UPDATE`` and ``DELETE`` statements.
|
||||
|
||||
Datasette checks additional permissions based on the operations in the SQL. Row changes require the relevant table-level permissions such as :ref:`actions_insert_row`, :ref:`actions_update_row` and :ref:`actions_delete_row`; reads from source tables require :ref:`actions_view_table`; and schema changes require permissions such as :ref:`actions_create_table`, :ref:`actions_alter_table` or :ref:`actions_drop_table`.
|
||||
|
||||
Use the :ref:`ExecuteWriteView` JSON API to execute writable SQL programmatically.
|
||||
|
||||
.. _pages_stored_query_browser:
|
||||
|
||||
Stored query browsers
|
||||
---------------------
|
||||
|
||||
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database.
|
||||
|
||||
These pages support search, pagination and filters for read-only or writable queries and private or public queries. Adding a ``.json`` extension to either URL returns the same list as JSON.
|
||||
|
||||
.. _TableView:
|
||||
|
||||
Table
|
||||
|
|
@ -91,6 +118,16 @@ Some examples:
|
|||
* `../antiquities-act%2Factions_under_antiquities_act <https://fivethirtyeight.datasettes.com/fivethirtyeight/antiquities-act%2Factions_under_antiquities_act>`_ is an interface for exploring the "actions under the antiquities act" data table published by FiveThirtyEight.
|
||||
* `../global-power-plants?country_long=United+Kingdom&primary_fuel=Gas <https://datasette.io/global-power-plants/global-power-plants?_facet=primary_fuel&_facet=owner&_facet=country_long&country_long__exact=United+Kingdom&primary_fuel=Gas>`_ is a filtered table page showing every Gas power plant in the United Kingdom. It includes some default facets (configured using `its metadata.json <https://datasette.io/-/metadata>`_) and uses the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`_ plugin to show a map of the results.
|
||||
|
||||
.. _TableFragmentView:
|
||||
|
||||
Table fragment
|
||||
--------------
|
||||
|
||||
The ``/<database>/<table>/-/fragment`` endpoint returns the rendered table HTML
|
||||
for rows matching the provided filters. It is used by Datasette's row editing
|
||||
interface to refresh rows after changes while still respecting custom table
|
||||
templates and ``render_cell`` plugin hooks.
|
||||
|
||||
.. _RowView:
|
||||
|
||||
Row
|
||||
|
|
|
|||
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