Compare commits

..

No commits in common. "main" and "1.0a22" have entirely different histories.

177 changed files with 3983 additions and 26493 deletions

View file

@ -0,0 +1,35 @@
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"

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out datasette - name: Check out datasette
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -24,7 +24,8 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install . --group dev python -m pip install -e .[test]
python -m pip install -e .[docs]
python -m pip install sphinx-to-sqlite==0.1a1 python -m pip install sphinx-to-sqlite==0.1a1
- name: Run tests - name: Run tests
if: ${{ github.ref == 'refs/heads/main' }} if: ${{ github.ref == 'refs/heads/main' }}
@ -57,7 +58,7 @@ jobs:
db.route = "alternative-route" db.route = "alternative-route"
' > plugins/alternative_route.py ' > plugins/alternative_route.py
cp fixtures.db fixtures2.db cp fixtures.db fixtures2.db
- name: And the counters writable stored query demo - name: And the counters writable canned query demo
run: | run: |
cat > plugins/counters.py <<EOF cat > plugins/counters.py <<EOF
from datasette import hookimpl from datasette import hookimpl
@ -69,24 +70,23 @@ jobs:
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)") await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)") await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)") await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
for name in ("counter_a", "counter_b", "counter_c"):
await datasette.add_query(
"counters",
"increment_{}".format(name),
"update counters set value = value + 1 where name = '{}'".format(name),
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
is_write=True,
is_trusted=True,
)
await datasette.add_query(
"counters",
"decrement_{}".format(name),
"update counters set value = value - 1 where name = '{}'".format(name),
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
is_write=True,
is_trusted=True,
)
return inner return inner
@hookimpl
def canned_queries(database):
if database == "counters":
queries = {}
for name in ("counter_a", "counter_b", "counter_c"):
queries["increment_{}".format(name)] = {
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
"write": True,
}
queries["decrement_{}".format(name)] = {
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
"write": True,
}
return queries
EOF EOF
# - name: Make some modifications to metadata.json # - name: Make some modifications to metadata.json
# run: | # run: |
@ -117,7 +117,7 @@ jobs:
--plugins-dir=plugins \ --plugins-dir=plugins \
--branch=$GITHUB_SHA \ --branch=$GITHUB_SHA \
--version-note=$GITHUB_SHA \ --version-note=$GITHUB_SHA \
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \ --extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
--install 'datasette-ephemeral-tables>=0.2.2' \ --install 'datasette-ephemeral-tables>=0.2.2' \
--service "datasette-latest$SUFFIX" \ --service "datasette-latest$SUFFIX" \
--secret $LATEST_DATASETTE_SECRET --secret $LATEST_DATASETTE_SECRET

View file

@ -1,6 +1,6 @@
name: Read the Docs Pull Request Preview name: Read the Docs Pull Request Preview
on: on:
pull_request: pull_request_target:
types: types:
- opened - opened

View file

@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v6 uses: actions/checkout@v4
- uses: actions/cache@v5 - uses: actions/cache@v4
name: Configure npm caching name: Configure npm caching
with: with:
path: ~/.npm path: ~/.npm

View file

@ -14,7 +14,7 @@ jobs:
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -23,7 +23,7 @@ jobs:
cache-dependency-path: pyproject.toml cache-dependency-path: pyproject.toml
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install . --group dev pip install -e '.[test]'
- name: Run tests - name: Run tests
run: | run: |
pytest pytest
@ -35,7 +35,7 @@ jobs:
permissions: permissions:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -56,7 +56,7 @@ jobs:
needs: [deploy] needs: [deploy]
if: "!github.event.release.prerelease" if: "!github.event.release.prerelease"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -65,7 +65,7 @@ jobs:
cache-dependency-path: pyproject.toml cache-dependency-path: pyproject.toml
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install . --group dev python -m pip install -e .[docs]
python -m pip install sphinx-to-sqlite==0.1a1 python -m pip install sphinx-to-sqlite==0.1a1
- name: Build docs.db - name: Build docs.db
run: |- run: |-
@ -92,7 +92,7 @@ jobs:
needs: [deploy] needs: [deploy]
if: "!github.event.release.prerelease" if: "!github.event.release.prerelease"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Build and push to Docker Hub - name: Build and push to Docker Hub
env: env:
DOCKER_USER: ${{ secrets.DOCKER_USER }} DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -13,7 +13,7 @@ jobs:
deploy_docker: deploy_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v2
- name: Build and push to Docker Hub - name: Build and push to Docker Hub
env: env:
DOCKER_USER: ${{ secrets.DOCKER_USER }} DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -9,7 +9,7 @@ jobs:
spellcheck: spellcheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -18,7 +18,7 @@ jobs:
cache-dependency-path: '**/pyproject.toml' cache-dependency-path: '**/pyproject.toml'
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install . --group dev pip install -e '.[docs]'
- name: Check spelling - name: Check spelling
run: | run: |
codespell README.md --ignore-words docs/codespell-ignore-words.txt codespell README.md --ignore-words docs/codespell-ignore-words.txt

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v5
with: with:
fetch-depth: 0 # We need all commits to find docs/ changes fetch-depth: 0 # We need all commits to find docs/ changes
- name: Set up Git user - name: Set up Git user

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out datasette - name: Check out datasette
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -25,7 +25,7 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install . --group dev python -m pip install -e .[test]
python -m pip install pytest-cov python -m pip install pytest-cov
- name: Run tests - name: Run tests
run: |- run: |-

View file

@ -12,7 +12,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -20,7 +20,7 @@ jobs:
cache: 'pip' cache: 'pip'
cache-dependency-path: '**/pyproject.toml' cache-dependency-path: '**/pyproject.toml'
- name: Cache Playwright browsers - name: Cache Playwright browsers
uses: actions/cache@v5 uses: actions/cache@v4
with: with:
path: ~/.cache/ms-playwright/ path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers key: ${{ runner.os }}-browsers

View file

@ -25,7 +25,7 @@ jobs:
#"3.23.1" # 2018-04-10, before UPSERT #"3.23.1" # 2018-04-10, before UPSERT
] ]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -45,7 +45,7 @@ jobs:
(cd tests && gcc ext.c -fPIC -shared -o ext.so) (cd tests && gcc ext.c -fPIC -shared -o ext.so)
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install . --group dev pip install -e '.[test]'
pip freeze pip freeze
- name: Run tests - name: Run tests
run: | run: |

View file

@ -12,7 +12,7 @@ jobs:
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -25,7 +25,7 @@ jobs:
(cd tests && gcc ext.c -fPIC -shared -o ext.so) (cd tests && gcc ext.c -fPIC -shared -o ext.so)
- name: Install dependencies - name: Install dependencies
run: | run: |
pip install . --group dev pip install -e '.[test]'
pip freeze pip freeze
- name: Run tests - name: Run tests
run: | run: |
@ -33,12 +33,11 @@ jobs:
pytest -m "serial" pytest -m "serial"
# And the test that exceeds a localhost HTTPS server # And the test that exceeds a localhost HTTPS server
tests/test_datasette_https_server.sh tests/test_datasette_https_server.sh
- name: Black - name: Install docs dependencies
run: | run: |
black --version pip install -e '.[docs]'
black --check . - name: Black
- name: Ruff run: black --check .
run: ruff check datasette tests
- name: Check if cog needs to be run - name: Check if cog needs to be run
run: | run: |
cog --check docs/*.rst cog --check docs/*.rst

View file

@ -10,6 +10,6 @@ jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v2
- name: Setup tmate session - name: Setup tmate session
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3

View file

@ -11,7 +11,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v2
- name: Setup tmate session - name: Setup tmate session
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3
env: env:

5
.gitignore vendored
View file

@ -8,9 +8,6 @@ scratchpad
uv.lock uv.lock
data.db data.db
# test databases
*.db
# We don't use Pipfile, so ignore them # We don't use Pipfile, so ignore them
Pipfile Pipfile
Pipfile.lock Pipfile.lock
@ -130,5 +127,3 @@ node_modules
tests/*.dylib tests/*.dylib
tests/*.so tests/*.so
tests/*.dll tests/*.dll
.idea

View file

@ -1,17 +1,16 @@
version: 2 version: 2
sphinx:
configuration: docs/conf.py
build: build:
os: ubuntu-24.04 os: ubuntu-20.04
tools: tools:
python: "3.13" python: "3.11"
jobs:
install:
- pip install --upgrade pip
- pip install . --group dev
formats: sphinx:
- pdf configuration: docs/conf.py
- epub
python:
install:
- method: pip
path: .
extra_requirements:
- docs

View file

@ -5,7 +5,7 @@ export DATASETTE_SECRET := "not_a_secret"
# Setup project # Setup project
@init: @init:
uv sync uv sync --extra test --extra docs
# Run pytest with supplied options # Run pytest with supplied options
@test *options: init @test *options: init
@ -17,23 +17,19 @@ export DATASETTE_SECRET := "not_a_secret"
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
# Run linters: black, ruff, cog # Run linters: black, flake8, mypy, cog
@lint: codespell @lint: codespell
uv run black datasette tests --check uv run black . --check
uv run ruff check datasette tests uv run flake8
uv run cog --check README.md docs/*.rst uv run --extra test cog --check README.md docs/*.rst
# Apply ruff fixes
@fix:
uv run ruff check --fix datasette tests
# Rebuild docs with cog # Rebuild docs with cog
@cog: @cog:
uv run cog -r README.md docs/*.rst uv run --extra test cog -r README.md docs/*.rst
# Serve live docs on localhost:8000 # Serve live docs on localhost:8000
@docs: cog blacken-docs @docs: cog blacken-docs
uv run make -C docs livehtml uv sync --extra docs && cd docs && uv run make livehtml
# Build docs as static HTML # Build docs as static HTML
@docs-build: cog blacken-docs @docs-build: cog blacken-docs
@ -41,7 +37,7 @@ export DATASETTE_SECRET := "not_a_secret"
# Apply Black # Apply Black
@black: @black:
uv run black datasette tests uv run black .
# Apply blacken-docs # Apply blacken-docs
@blacken-docs: @blacken-docs:

View file

@ -1,7 +1,6 @@
from datasette.permissions import Permission # noqa from datasette.permissions import Permission # noqa
from datasette.version import __version_info__, __version__ # noqa from datasette.version import __version_info__, __version__ # noqa
from datasette.events import Event # noqa from datasette.events import Event # noqa
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
from datasette.utils import actor_matches_allow # noqa from datasette.utils import actor_matches_allow # noqa
from datasette.views import Context # noqa from datasette.views import Context # noqa

View file

@ -1,108 +0,0 @@
"""
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
from datasette.app import Datasette
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
"datasette_active_instances", default=None
)
_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_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

View file

@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
from asgi_csrf import Errors
import asyncio import asyncio
import contextvars import contextvars
from typing import TYPE_CHECKING, Any, Dict, Iterable, List from typing import TYPE_CHECKING, Any, Dict, Iterable, List
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.permissions import Resource from datasette.permissions import AllowedResource, Resource
from datasette.tokens import TokenRestrictions import asgi_csrf
import collections import collections
import dataclasses import dataclasses
import datetime import datetime
@ -41,26 +42,8 @@ from jinja2.environment import Template
from jinja2.exceptions import TemplateNotFound from jinja2.exceptions import TemplateNotFound
from .events import Event from .events import Event
from .column_types import SQLiteType
from . import stored_queries, write_sql
from .views import Context from .views import Context
from .views.database import ( from .views.database import database_download, DatabaseView, TableCreateView, QueryView
database_download,
DatabaseView,
TableCreateView,
QueryView,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.stored_queries import (
QueryCreateAnalyzeView,
QueryDeleteView,
QueryDefinitionView,
GlobalQueryListView,
QueryListView,
QueryParametersView,
QueryStoreView,
QueryUpdateView,
)
from .views.index import IndexView from .views.index import IndexView
from .views.special import ( from .views.special import (
JsonDataView, JsonDataView,
@ -75,7 +58,7 @@ from .views.special import (
AllowedResourcesView, AllowedResourcesView,
PermissionRulesView, PermissionRulesView,
PermissionCheckView, PermissionCheckView,
JumpView, TablesView,
InstanceSchemaView, InstanceSchemaView,
DatabaseSchemaView, DatabaseSchemaView,
TableSchemaView, TableSchemaView,
@ -83,7 +66,6 @@ from .views.special import (
from .views.table import ( from .views.table import (
TableInsertView, TableInsertView,
TableUpsertView, TableUpsertView,
TableSetColumnTypeView,
TableDropView, TableDropView,
table_view, table_view,
) )
@ -135,7 +117,6 @@ from .utils.asgi import (
asgi_send_file, asgi_send_file,
asgi_send_redirect, asgi_send_redirect,
) )
from .csrf import CrossOriginProtectionMiddleware
from .utils.internal_db import init_internal_db, populate_schema_tables from .utils.internal_db import init_internal_db, populate_schema_tables
from .utils.sqlite import ( from .utils.sqlite import (
sqlite3, sqlite3,
@ -343,7 +324,6 @@ class Datasette:
default_deny=False, default_deny=False,
): ):
self._startup_invoked = False self._startup_invoked = False
self._closed = False
assert config_dir is None or isinstance( assert config_dir is None or isinstance(
config_dir, Path config_dir, Path
), "config_dir= should be a pathlib.Path" ), "config_dir= should be a pathlib.Path"
@ -373,7 +353,6 @@ class Datasette:
self.immutables = set(immutables or []) self.immutables = set(immutables or [])
self.databases = collections.OrderedDict() self.databases = collections.OrderedDict()
self.actions = {} # .invoke_startup() will populate this self.actions = {} # .invoke_startup() will populate this
self._column_types = {} # .invoke_startup() will populate this
try: try:
self._refresh_schemas_lock = asyncio.Lock() self._refresh_schemas_lock = asyncio.Lock()
except RuntimeError as rex: except RuntimeError as rex:
@ -398,7 +377,7 @@ class Datasette:
self.internal_db_created = False self.internal_db_created = False
if internal is None: if internal is None:
self._internal_database = Database(self, is_temp_disk=True) self._internal_database = Database(self, memory_name=secrets.token_hex())
else: else:
self._internal_database = Database(self, path=internal, mode="rwc") self._internal_database = Database(self, path=internal, mode="rwc")
self._internal_database.name = INTERNAL_DB_NAME self._internal_database.name = INTERNAL_DB_NAME
@ -588,9 +567,6 @@ class Datasette:
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log # TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
# a warning to user that they should delete their metadata.json file # a warning to user that they should delete their metadata.json file
async def _save_queries_from_config(self):
await stored_queries.save_queries_from_config(self)
def get_jinja_environment(self, request: Request = None) -> Environment: def get_jinja_environment(self, request: Request = None) -> Environment:
environment = self._jinja_env environment = self._jinja_env
if request: if request:
@ -613,10 +589,6 @@ class Datasette:
return None return None
async def refresh_schemas(self): async def refresh_schemas(self):
# Throttle schema refreshes to at most once per second
if time.monotonic() - getattr(self, "_last_schema_refresh", 0) < 1.0:
return
self._last_schema_refresh = time.monotonic()
if self._refresh_schemas_lock.locked(): if self._refresh_schemas_lock.locked():
return return
async with self._refresh_schemas_lock: async with self._refresh_schemas_lock:
@ -634,36 +606,6 @@ class Datasette:
"select database_name, schema_version from catalog_databases" "select database_name, schema_version from catalog_databases"
) )
} }
catalog_table_names = (
"catalog_columns",
"catalog_foreign_keys",
"catalog_indexes",
"catalog_views",
"catalog_tables",
"catalog_databases",
)
# 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(): for database_name, db in self.databases.items():
schema_version = (await db.execute("PRAGMA schema_version")).first()[0] schema_version = (await db.execute("PRAGMA schema_version")).first()[0]
# Compare schema versions to see if we should skip it # Compare schema versions to see if we should skip it
@ -678,7 +620,9 @@ class Datasette:
""" """
INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version) INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)
VALUES {} VALUES {}
""".format(placeholders), """.format(
placeholders
),
values, values,
) )
await populate_schema_tables(internal_db, db) await populate_schema_tables(internal_db, db)
@ -736,24 +680,10 @@ class Datasette:
action_abbrs[action.abbr] = action action_abbrs[action.abbr] = action
self.actions[action.name] = action self.actions[action.name] = action
# Register column types (classes, not instances)
self._column_types = {}
for hook in pm.hook.register_column_types(datasette=self):
if hook:
for ct_cls in hook:
if ct_cls.name in self._column_types:
raise StartupError(f"Duplicate column type name: {ct_cls.name}")
self._column_types[ct_cls.name] = ct_cls
for hook in pm.hook.prepare_jinja2_environment( for hook in pm.hook.prepare_jinja2_environment(
env=self._jinja_env, datasette=self env=self._jinja_env, datasette=self
): ):
await await_me_maybe(hook) 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): for hook in pm.hook.startup(datasette=self):
await await_me_maybe(hook) await await_me_maybe(hook)
self._startup_invoked = True self._startup_invoked = True
@ -772,70 +702,44 @@ class Datasette:
""" """
return _in_datasette_client.get() return _in_datasette_client.get()
def _token_handlers(self): def create_token(
"""Collect all registered token handlers from plugins."""
from datasette.tokens import TokenHandler
handlers = []
for result in pm.hook.register_token_handler(datasette=self):
if isinstance(result, TokenHandler):
handlers.append(result)
elif isinstance(result, list):
handlers.extend(h for h in result if isinstance(h, TokenHandler))
return handlers
async def create_token(
self, self,
actor_id: str, actor_id: str,
*, *,
expires_after: int | None = None, expires_after: int | None = None,
restrictions: "TokenRestrictions | None" = None, restrict_all: Iterable[str] | None = None,
handler: str | None = None, restrict_database: Dict[str, Iterable[str]] | None = None,
) -> str: restrict_resource: Dict[str, Dict[str, Iterable[str]]] | None = None,
""" ):
Create an API token for the given actor. token = {"a": actor_id, "t": int(time.time())}
if expires_after:
token["d"] = expires_after
Uses the first registered token handler by default, or a specific def abbreviate_action(action):
handler if ``handler`` is provided (matched by handler name). # rename to abbr if possible
action_obj = self.actions.get(action)
if not action_obj:
return action
return action_obj.abbr or action
Pass a :class:`TokenRestrictions` to limit which actions the token if expires_after:
can perform. token["d"] = expires_after
""" if restrict_all or restrict_database or restrict_resource:
handlers = self._token_handlers() token["_r"] = {}
if not handlers: if restrict_all:
raise RuntimeError("No token handlers are registered") token["_r"]["a"] = [abbreviate_action(a) for a in restrict_all]
if restrict_database:
if handler is not None: token["_r"]["d"] = {}
matched = [h for h in handlers if h.name == handler] for database, actions in restrict_database.items():
if not matched: token["_r"]["d"][database] = [abbreviate_action(a) for a in actions]
available = [h.name for h in handlers] if restrict_resource:
raise ValueError( token["_r"]["r"] = {}
f"Token handler {handler!r} not found. " for database, resources in restrict_resource.items():
f"Available handlers: {available}" for resource, actions in resources.items():
) token["_r"]["r"].setdefault(database, {})[resource] = [
chosen = matched[0] abbreviate_action(a) for a in actions
else: ]
chosen = handlers[0] return "dstok_{}".format(self.sign(token, namespace="token"))
return await chosen.create_token(
self,
actor_id,
expires_after=expires_after,
restrictions=restrictions,
)
async def verify_token(self, token: str) -> dict | None:
"""
Verify an API token by trying all registered token handlers.
Returns an actor dict from the first handler that recognizes the
token, or None if no handler accepts it.
"""
for token_handler in self._token_handlers():
result = await token_handler.verify_token(self, token)
if result is not None:
return result
return None
def get_database(self, name=None, route=None): def get_database(self, name=None, route=None):
if route is not None: if route is not None:
@ -877,33 +781,6 @@ class Datasette:
new_databases.pop(name) new_databases.pop(name)
self.databases = new_databases 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): def setting(self, key):
return self._settings.get(key, None) return self._settings.get(key, None)
@ -923,12 +800,14 @@ class Datasette:
return orig return orig
async def get_instance_metadata(self): async def get_instance_metadata(self):
rows = await self.get_internal_database().execute(""" rows = await self.get_internal_database().execute(
"""
SELECT SELECT
key, key,
value value
FROM metadata_instance FROM metadata_instance
""") """
)
return dict(rows) return dict(rows)
async def get_database_metadata(self, database_name: str): async def get_database_metadata(self, database_name: str):
@ -1028,349 +907,6 @@ class Datasette:
[database_name, resource_name, column_name, key, value], [database_name, resource_name, column_name, key, value],
) )
@staticmethod
def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None:
return stored_queries.query_row_to_stored_query(row)
@staticmethod
def _query_options_json(options):
return stored_queries.query_options_json(options)
async def add_query(
self,
database: str,
name: str,
sql: str,
*,
title: str | None = None,
description: str | None = None,
description_html: str | None = None,
hide_sql: bool = False,
fragment: str | None = None,
parameters: Iterable[str] | None = None,
is_write: bool = False,
is_private: bool = False,
is_trusted: bool = False,
source: str = "plugin",
owner_id: str | None = None,
on_success_message: str | None = None,
on_success_message_sql: str | None = None,
on_success_redirect: str | None = None,
on_error_message: str | None = None,
on_error_redirect: str | None = None,
replace: bool = True,
) -> None:
return await stored_queries.add_query(
self,
database,
name,
sql,
title=title,
description=description,
description_html=description_html,
hide_sql=hide_sql,
fragment=fragment,
parameters=parameters,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
on_success_message=on_success_message,
on_success_message_sql=on_success_message_sql,
on_success_redirect=on_success_redirect,
on_error_message=on_error_message,
on_error_redirect=on_error_redirect,
replace=replace,
)
async def update_query(
self,
database: str,
name: str,
*,
sql=stored_queries.UNCHANGED,
title=stored_queries.UNCHANGED,
description=stored_queries.UNCHANGED,
description_html=stored_queries.UNCHANGED,
hide_sql=stored_queries.UNCHANGED,
fragment=stored_queries.UNCHANGED,
parameters=stored_queries.UNCHANGED,
is_write=stored_queries.UNCHANGED,
is_private=stored_queries.UNCHANGED,
is_trusted=stored_queries.UNCHANGED,
source=stored_queries.UNCHANGED,
owner_id=stored_queries.UNCHANGED,
on_success_message=stored_queries.UNCHANGED,
on_success_message_sql=stored_queries.UNCHANGED,
on_success_redirect=stored_queries.UNCHANGED,
on_error_message=stored_queries.UNCHANGED,
on_error_redirect=stored_queries.UNCHANGED,
) -> None:
return await stored_queries.update_query(
self,
database,
name,
sql=sql,
title=title,
description=description,
description_html=description_html,
hide_sql=hide_sql,
fragment=fragment,
parameters=parameters,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
on_success_message=on_success_message,
on_success_message_sql=on_success_message_sql,
on_success_redirect=on_success_redirect,
on_error_message=on_error_message,
on_error_redirect=on_error_redirect,
)
async def remove_query(
self, database: str, name: str, source: str | None = None
) -> None:
return await stored_queries.remove_query(self, database, name, source=source)
async def get_query(
self, database: str, name: str
) -> stored_queries.StoredQuery | None:
return await stored_queries.get_query(self, database, name)
async def count_queries(
self,
database: str | None = None,
*,
actor: dict[str, Any] | None = None,
q: str | None = None,
is_write: bool | None = None,
is_private: bool | None = None,
is_trusted: bool | None = None,
source: str | None = None,
owner_id: str | None = None,
) -> int:
return await stored_queries.count_queries(
self,
database,
actor=actor,
q=q,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
)
async def list_queries(
self,
database: str | None = None,
*,
actor: dict[str, Any] | None = None,
limit: int = 50,
cursor: str | None = None,
q: str | None = None,
is_write: bool | None = None,
is_private: bool | None = None,
is_trusted: bool | None = None,
source: str | None = None,
owner_id: str | None = None,
include_private: bool = False,
) -> stored_queries.StoredQueryPage:
return await stored_queries.list_queries(
self,
database,
actor=actor,
limit=limit,
cursor=cursor,
q=q,
is_write=is_write,
is_private=is_private,
is_trusted=is_trusted,
source=source,
owner_id=owner_id,
include_private=include_private,
)
async def ensure_query_write_permissions(
self, database, sql, *, actor=None, params=None, analysis=None
):
# Raise Forbidden or QueryWriteRejected if SQL should not run
return await write_sql.ensure_query_write_permissions(
self, database, sql, actor=actor, params=params, analysis=analysis
)
# Column types API
async def _get_resource_column_details(self, database: str, resource: str):
db = self.databases.get(database)
if db is None:
return {}
try:
return {
column.name: column
for column in await db.table_column_details(resource)
}
except sqlite3.OperationalError:
return {}
@staticmethod
def _column_type_is_applicable(ct_cls, column_detail) -> bool:
sqlite_types = getattr(ct_cls, "sqlite_types", None)
if sqlite_types is None:
return True
if column_detail is None:
return False
actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type)
return actual_sqlite_type in sqlite_types
async def _validate_column_type_assignment(
self, database: str, resource: str, column: str, ct_cls
) -> None:
sqlite_types = getattr(ct_cls, "sqlite_types", None)
if sqlite_types is None:
return
column_detail = (
await self._get_resource_column_details(database, resource)
).get(column)
if column_detail is None:
return
actual_sqlite_type = SQLiteType.from_declared_type(column_detail.type)
if actual_sqlite_type in sqlite_types:
return
allowed = ", ".join(sqlite_type.value for sqlite_type in sqlite_types)
actual = (
actual_sqlite_type.value
if actual_sqlite_type is not None
else "unrecognized {!r}".format(column_detail.type)
)
raise ValueError(
"Column type {!r} is only applicable to SQLite types {} but {}.{}.{} "
"has SQLite type {}".format(
ct_cls.name,
allowed,
database,
resource,
column,
actual,
)
)
async def _apply_column_types_config(self):
"""Load column_types from datasette.json config into the internal DB."""
import logging
for db_name, db_conf in (self.config or {}).get("databases", {}).items():
for table_name, table_conf in db_conf.get("tables", {}).items():
for col_name, ct in table_conf.get("column_types", {}).items():
if isinstance(ct, str):
col_type, config = ct, None
else:
col_type = ct["type"]
config = ct.get("config")
if col_type not in self._column_types:
logging.warning(
"column_types config references unknown type %r "
"for %s.%s.%s",
col_type,
db_name,
table_name,
col_name,
)
try:
await self.set_column_type(
db_name, table_name, col_name, col_type, config
)
except ValueError as ex:
logging.warning(str(ex))
async def get_column_type(self, database: str, resource: str, column: str):
"""
Return a ColumnType instance (with config baked in) for a specific
column, or None if no column type is assigned.
"""
row = await self.get_internal_database().execute(
"SELECT column_type, config FROM column_types "
"WHERE database_name = ? AND resource_name = ? AND column_name = ?",
[database, resource, column],
)
rows = row.rows
if not rows:
return None
ct_name, config = rows[0]
ct_cls = self._column_types.get(ct_name)
if ct_cls is None:
return None
column_detail = (
await self._get_resource_column_details(database, resource)
).get(column)
if not self._column_type_is_applicable(ct_cls, column_detail):
return None
return ct_cls(config=json.loads(config) if config else None)
async def get_column_types(self, database: str, resource: str) -> dict:
"""
Return {column_name: ColumnType instance (with config)}
for all columns with assigned types on the given resource.
"""
rows = await self.get_internal_database().execute(
"SELECT column_name, column_type, config FROM column_types "
"WHERE database_name = ? AND resource_name = ?",
[database, resource],
)
column_details = await self._get_resource_column_details(database, resource)
result = {}
for row in rows.rows:
col_name, ct_name, config = row
ct_cls = self._column_types.get(ct_name)
if ct_cls is not None and self._column_type_is_applicable(
ct_cls, column_details.get(col_name)
):
result[col_name] = ct_cls(config=json.loads(config) if config else None)
return result
async def set_column_type(
self,
database: str,
resource: str,
column: str,
column_type: str,
config: dict = None,
) -> None:
"""Assign a column type. Overwrites any existing assignment."""
ct_cls = self._column_types.get(column_type)
if ct_cls is not None:
await self._validate_column_type_assignment(
database, resource, column, ct_cls
)
await self.get_internal_database().execute_write(
"""INSERT OR REPLACE INTO column_types
(database_name, resource_name, column_name, column_type, config)
VALUES (?, ?, ?, ?, ?)""",
[
database,
resource,
column,
column_type,
json.dumps(config) if config else None,
],
)
async def remove_column_type(
self, database: str, resource: str, column: str
) -> None:
"""Remove a column type assignment."""
await self.get_internal_database().execute_write(
"DELETE FROM column_types "
"WHERE database_name = ? AND resource_name = ? AND column_name = ?",
[database, resource, column],
)
def get_internal_database(self): def get_internal_database(self):
return self._internal_database return self._internal_database
@ -1414,24 +950,36 @@ class Datasette:
return db_plugin_config 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): def app_css_hash(self):
return self.static_hash("app.css") 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
def _prepare_connection(self, conn, database): def _prepare_connection(self, conn, database):
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -1587,7 +1135,7 @@ class Datasette:
# Validate that resource is a Resource object or None # Validate that resource is a Resource object or None
if resource is not None and not isinstance(resource, Resource): if resource is not None and not isinstance(resource, Resource):
raise TypeError("resource must be a Resource subclass instance or None.") raise TypeError(f"resource must be a Resource subclass instance or None.")
# Check if actor can see it # Check if actor can see it
if not await self.allowed(action=action, resource=resource, actor=actor): if not await self.allowed(action=action, resource=resource, actor=actor):
@ -2051,7 +1599,6 @@ class Datasette:
break break
except importlib.metadata.PackageNotFoundError: except importlib.metadata.PackageNotFoundError:
pass pass
conn.close()
return info return info
def _plugins(self, request=None, all=False): def _plugins(self, request=None, all=False):
@ -2235,11 +1782,7 @@ class Datasette:
"extra_js_urls", template, context, request, view_name "extra_js_urls", template, context, request, view_name
), ),
"base_url": self.setting("base_url"), "base_url": self.setting("base_url"),
"csrftoken": ( "csrftoken": request.scope["csrftoken"] if request else lambda: "",
request.scope["csrftoken"]
if request and "csrftoken" in request.scope
else lambda: ""
),
"datasette_version": __version__, "datasette_version": __version__,
}, },
**extra_template_vars, **extra_template_vars,
@ -2405,12 +1948,8 @@ class Datasette:
r"/-/api$", r"/-/api$",
) )
add_route( add_route(
JumpView.as_view(self), TablesView.as_view(self),
r"/-/jump(\.(?P<format>json))?$", r"/-/tables(\.(?P<format>json))?$",
)
add_route(
GlobalQueryListView.as_view(self),
r"/-/queries(\.(?P<format>json))?$",
) )
add_route( add_route(
InstanceSchemaView.as_view(self), InstanceSchemaView.as_view(self),
@ -2457,50 +1996,14 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
) )
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$") add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
add_route(
QueryListView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
)
add_route(
QueryCreateAnalyzeView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/analyze$",
)
add_route(
QueryStoreView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries/store$",
)
add_route(
ExecuteWriteAnalyzeView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write/analyze$",
)
add_route(
ExecuteWriteView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/execute-write$",
)
add_route( add_route(
DatabaseSchemaView.as_view(self), DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$", r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
) )
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
)
add_route( add_route(
wrap_view(QueryView, self), wrap_view(QueryView, self),
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
) )
add_route(
QueryDefinitionView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
)
add_route(
QueryUpdateView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
)
add_route(
QueryDeleteView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
)
add_route( add_route(
wrap_view(table_view, self), wrap_view(table_view, self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
@ -2517,10 +2020,6 @@ class Datasette:
TableUpsertView.as_view(self), TableUpsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
) )
add_route(
TableSetColumnTypeView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
)
add_route( add_route(
TableDropView.as_view(self), TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
@ -2582,13 +2081,29 @@ class Datasette:
if not database.is_mutable: if not database.is_mutable:
await database.table_counts(limit=60 * 60 * 1000) await database.table_counts(limit=60 * 60 * 1000)
async def _close_on_shutdown(): async def custom_csrf_error(scope, send, message_id):
self.close() await asgi_send(
send,
content=await self.render_template(
"csrf_error.html",
{"message_id": message_id, "message_name": Errors(message_id).name},
),
status=403,
content_type="text/html; charset=utf-8",
)
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self) asgi = asgi_csrf.asgi_csrf(
DatasetteRouter(self, routes),
signing_secret=self._secret,
cookie_name="ds_csrftoken",
skip_if_scope=lambda scope: any(
pm.hook.skip_csrf(datasette=self, scope=scope)
),
send_csrf_failed=custom_csrf_error,
)
if self.setting("trace_debug"): if self.setting("trace_debug"):
asgi = AsgiTracer(asgi) asgi = AsgiTracer(asgi)
asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown]) asgi = AsgiLifespan(asgi)
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup]) asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup])
for wrapper in pm.hook.asgi_wrapper(datasette=self): for wrapper in pm.hook.asgi_wrapper(datasette=self):
asgi = wrapper(asgi) asgi = wrapper(asgi)
@ -2635,13 +2150,10 @@ class DatasetteRouter:
# Handle authentication # Handle authentication
default_actor = scope.get("actor") or None default_actor = scope.get("actor") or None
actor = None actor = None
results = pm.hook.actor_from_request(datasette=self.ds, request=request) for actor in pm.hook.actor_from_request(datasette=self.ds, request=request):
for result in results: actor = await await_me_maybe(actor)
result = await await_me_maybe(result) if actor:
if result and actor is None: break
actor = result
# Don't break — we must await all coroutines to avoid
# "coroutine was never awaited" warnings
scope_modifications["actor"] = actor or default_actor scope_modifications["actor"] = actor or default_actor
scope = dict(scope, **scope_modifications) scope = dict(scope, **scope_modifications)
@ -2870,22 +2382,19 @@ def wrap_view_function(view_fn, datasette):
def permanent_redirect(path, forward_query_string=False, forward_rest=False): def permanent_redirect(path, forward_query_string=False, forward_rest=False):
def view(request, send): return wrap_view(
redirect_path = ( lambda request, send: Response.redirect(
path path
+ (request.url_vars["rest"] if forward_rest else "") + (request.url_vars["rest"] if forward_rest else "")
+ ( + (
("?" + request.query_string) ("?" + request.query_string)
if forward_query_string and request.query_string if forward_query_string and request.query_string
else "" else ""
) ),
) status=301,
route_path = request.scope.get("route_path") ),
if route_path and request.path.endswith(route_path): datasette=None,
redirect_path = request.path[: -len(route_path)] + redirect_path )
return Response.redirect(redirect_path, status=301)
return wrap_view(view, datasette=None)
_curly_re = re.compile(r"({.*?})") _curly_re = re.compile(r"({.*?})")
@ -2933,21 +2442,9 @@ class DatasetteClient:
path = f"http://localhost{path}" path = f"http://localhost{path}"
return path return path
def _apply_actor(self, kwargs):
"""If ``actor=`` was supplied, convert it into a signed ds_actor cookie."""
actor = kwargs.pop("actor", None)
if actor is None:
return
cookies = dict(kwargs.get("cookies") or {})
if "ds_actor" in cookies:
raise TypeError("Cannot pass both actor= and a ds_actor cookie")
cookies["ds_actor"] = self.actor_cookie(actor)
kwargs["cookies"] = cookies
async def _request(self, method, path, skip_permission_checks=False, **kwargs): async def _request(self, method, path, skip_permission_checks=False, **kwargs):
from datasette.permissions import SkipPermissions from datasette.permissions import SkipPermissions
self._apply_actor(kwargs)
with _DatasetteClientContext(): with _DatasetteClientContext():
if skip_permission_checks: if skip_permission_checks:
with SkipPermissions(): with SkipPermissions():
@ -3013,7 +2510,6 @@ class DatasetteClient:
from datasette.permissions import SkipPermissions from datasette.permissions import SkipPermissions
avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None) avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None)
self._apply_actor(kwargs)
with _DatasetteClientContext(): with _DatasetteClientContext():
if skip_permission_checks: if skip_permission_checks:
with SkipPermissions(): with SkipPermissions():

View file

@ -21,7 +21,6 @@ from .app import (
SQLITE_LIMIT_ATTACHED, SQLITE_LIMIT_ATTACHED,
pm, pm,
) )
from .inspect import inspect_tables
from .utils import ( from .utils import (
LoadExtension, LoadExtension,
StartupError, StartupError,
@ -110,11 +109,15 @@ def sqlite_extensions(fn):
return fn(*args, **kwargs) return fn(*args, **kwargs)
except AttributeError as e: except AttributeError as e:
if "enable_load_extension" in str(e): if "enable_load_extension" in str(e):
raise click.ClickException(textwrap.dedent(""" raise click.ClickException(
textwrap.dedent(
"""
Your Python installation does not have the ability to load SQLite extensions. Your Python installation does not have the ability to load SQLite extensions.
More information: https://datasette.io/help/extensions More information: https://datasette.io/help/extensions
""").strip()) """
).strip()
)
raise raise
return wrapped return wrapped
@ -155,14 +158,14 @@ async def inspect_(files, sqlite_extensions):
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
data = {} data = {}
for name, database in app.databases.items(): for name, database in app.databases.items():
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {})) counts = await database.table_counts(limit=3600 * 1000)
data[name] = { data[name] = {
"hash": database.hash, "hash": database.hash,
"size": database.size, "size": database.size,
"file": database.path, "file": database.path,
"tables": { "tables": {
table_name: {"count": table["count"]} table_name: {"count": table_count}
for table_name, table in tables.items() for table_name, table_count in counts.items()
}, },
} }
return data return data
@ -548,7 +551,7 @@ def serve(
if reload: if reload:
import hupper import hupper
reloader = hupper.start_reloader("datasette.cli.cli") reloader = hupper.start_reloader("datasette.cli.serve")
if immutable: if immutable:
reloader.watch_files(immutable) reloader.watch_files(immutable)
if config: if config:
@ -616,9 +619,7 @@ def serve(
for file in file_paths: for file in file_paths:
if not pathlib.Path(file).exists(): if not pathlib.Path(file).exists():
if create: if create:
conn = sqlite3.connect(file) sqlite3.connect(file).execute("vacuum")
conn.execute("vacuum")
conn.close()
else: else:
raise click.ClickException( raise click.ClickException(
"Invalid value for '[FILES]...': Path '{}' does not exist.".format( "Invalid value for '[FILES]...': Path '{}' does not exist.".format(
@ -664,15 +665,11 @@ def serve(
# Private utility mechanism for writing unit tests # Private utility mechanism for writing unit tests
return ds return ds
# Run async soundness checks before startup hooks, since invoke_startup
# now populates internal tables which requires querying each database
run_sync(lambda: check_databases(ds))
# Run the "startup" plugin hooks # Run the "startup" plugin hooks
try: run_sync(ds.invoke_startup)
run_sync(ds.invoke_startup)
except StartupError as e: # Run async soundness checks - but only if we're not under pytest
raise click.ClickException(e.args[0]) run_sync(lambda: check_databases(ds))
if headers and not get: if headers and not get:
raise click.ClickException("--headers can only be used with --get") raise click.ClickException("--headers can only be used with --get")
@ -818,10 +815,7 @@ def create_token(
ds = Datasette(secret=secret, plugins_dir=plugins_dir) ds = Datasette(secret=secret, plugins_dir=plugins_dir)
# Run ds.invoke_startup() in an event loop # Run ds.invoke_startup() in an event loop
try: run_sync(ds.invoke_startup)
run_sync(ds.invoke_startup)
except StartupError as e:
raise click.ClickException(e.args[0])
# Warn about any unknown actions # Warn about any unknown actions
actions = [] actions = []
@ -836,23 +830,21 @@ def create_token(
err=True, err=True,
) )
from datasette.tokens import TokenRestrictions restrict_database = {}
restrictions = TokenRestrictions()
for action in alls:
restrictions.allow_all(action)
for database, action in databases: for database, action in databases:
restrictions.allow_database(database, action) restrict_database.setdefault(database, []).append(action)
restrict_resource = {}
for database, resource, action in resources: for database, resource, action in resources:
restrictions.allow_resource(database, resource, action) restrict_resource.setdefault(database, {}).setdefault(resource, []).append(
action
token = run_sync(
lambda: ds.create_token(
id,
expires_after=expires_after,
restrictions=restrictions,
handler="signed",
) )
token = ds.create_token(
id,
expires_after=expires_after,
restrict_all=alls,
restrict_database=restrict_database,
restrict_resource=restrict_resource,
) )
click.echo(token) click.echo(token)
if debug: if debug:

View file

@ -1,83 +0,0 @@
from enum import Enum
class SQLiteType(Enum):
TEXT = "TEXT"
INTEGER = "INTEGER"
REAL = "REAL"
BLOB = "BLOB"
NULL = "NULL"
@classmethod
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
if declared_type is None:
return cls.NULL
normalized = declared_type.strip().upper()
if not normalized:
return cls.NULL
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")):
return cls.TEXT
if "BLOB" in normalized:
return cls.BLOB
if any(
token in normalized
for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub
):
return cls.REAL
return None
class ColumnType:
"""
Base class for column types.
Subclasses must define ``name`` and ``description`` as class attributes:
- ``name``: Unique identifier string. Lowercase, no spaces.
Examples: "markdown", "file", "email", "url", "point", "image".
- ``description``: Human-readable label for admin UI dropdowns.
Examples: "Markdown text", "File reference", "Email address".
- ``sqlite_types``: Optional tuple of SQLiteType values restricting
which SQLite column types this ColumnType can be assigned to.
Instantiate with an optional ``config`` dict to bind per-column
configuration::
ct = MyColumnType(config={"key": "value"})
ct.config # {"key": "value"}
"""
name: str
description: str
sqlite_types: tuple[SQLiteType, ...] | None = None
def __init__(self, config=None):
self.config = config
async def render_cell(self, value, column, table, database, datasette, request):
"""
Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain.
"""
return None
async def validate(self, value, datasette):
"""
Validate a value before it is written. Return None if valid,
or a string error message if invalid.
"""
return None
async def transform_value(self, value, datasette):
"""
Transform a value before it appears in JSON API output.
Return the transformed value. Default: return unchanged.
"""
return value

View file

@ -1,178 +0,0 @@
"""
Header-based CSRF (Cross-Origin) protection.
Datasette uses the Sec-Fetch-Site + Origin header approach described in
Filippo Valsorda's article (https://words.filippo.io/csrf/) and implemented
in Go 1.25's http.CrossOriginProtection. This replaces the previous
token-based asgi-csrf mechanism.
"""
from __future__ import annotations
import secrets
import urllib.parse
from .utils.asgi import asgi_send
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
def _normalize_headers(raw_headers):
"""Lowercase header names; for duplicates, last value wins."""
result = {}
for name, value in raw_headers:
if isinstance(name, str):
name = name.encode("latin-1")
if isinstance(value, str):
value = value.encode("latin-1")
result[name.lower()] = value
return result
def _origin_tuple(value):
"""
Parse an origin-like string into ``(scheme, host, port)`` with default
ports filled in. Raises ``ValueError`` for malformed input.
"""
parsed = urllib.parse.urlsplit(value)
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
if not scheme or not host:
raise ValueError("missing scheme or host in {!r}".format(value))
port = parsed.port # may raise ValueError on bad ports
if port is None:
port = DEFAULT_PORTS.get(scheme)
if port is None:
raise ValueError("unknown default port for scheme {!r}".format(scheme))
return scheme, host, port
def _install_legacy_csrftoken(scope):
"""
Populate ``scope["csrftoken"]`` with a callable returning a per-request
random token. Provided for plugin compatibility only - core no longer
uses this value for CSRF enforcement.
"""
def csrftoken():
if "_datasette_legacy_csrftoken" not in scope:
scope["_datasette_legacy_csrftoken"] = secrets.token_urlsafe(32)
return scope["_datasette_legacy_csrftoken"]
scope["csrftoken"] = csrftoken
class CrossOriginProtectionMiddleware:
"""
Modern CSRF protection using the Sec-Fetch-Site and Origin headers.
Based on Filippo Valsorda's algorithm, as implemented in Go 1.25's
http.CrossOriginProtection. See https://words.filippo.io/csrf/
Unsafe-method requests are allowed through only if they look same-origin.
Non-browser clients (curl, etc.) send neither Sec-Fetch-Site nor Origin
and are passed through unchanged - CSRF is a browser-only attack.
"""
SAFE_METHODS = SAFE_METHODS
def __init__(self, app, datasette):
self.app = app
self.datasette = datasette
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
_install_legacy_csrftoken(scope)
if scope.get("method", "GET") in self.SAFE_METHODS:
await self.app(scope, receive, send)
return
headers = _normalize_headers(scope.get("headers") or [])
authorization = headers.get(b"authorization", b"").decode("latin-1")
cookie_header = headers.get(b"cookie")
# Bearer-token requests are not ambient browser credentials, so they
# are not CSRF-vulnerable. Narrowly exempt them from the header check
# before evaluating Sec-Fetch-Site / Origin. Only "Bearer" is exempt;
# schemes like Basic or Digest can be browser-managed and ambient.
# If the request also carries a Cookie header, ambient cookie auth
# could be in play, so do NOT treat it as exempt.
if authorization and not cookie_header:
parts = authorization.split(None, 1)
if parts and parts[0].lower() == "bearer":
await self.app(scope, receive, send)
return
origin_bytes = headers.get(b"origin")
sec_fetch_site_bytes = headers.get(b"sec-fetch-site")
host_bytes = headers.get(b"host", b"")
origin = origin_bytes.decode("latin-1") if origin_bytes else None
sec_fetch_site = (
sec_fetch_site_bytes.decode("latin-1") if sec_fetch_site_bytes else None
)
host = host_bytes.decode("latin-1")
# Primary defense: Sec-Fetch-Site (set by browsers, unforgeable from JS)
if sec_fetch_site is not None:
if sec_fetch_site in ("same-origin", "none"):
await self.app(scope, receive, send)
return
await self._forbid(
send,
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
sec_fetch_site
),
)
return
# No Sec-Fetch-Site and no Origin -> non-browser client (curl, API, etc.)
if origin is None:
await self.app(scope, receive, send)
return
# Fallback for older browsers: Origin must match the request's own
# scheme + host + port. Compare full origin tuples, not host alone.
request_scheme = self._request_scheme(scope)
try:
origin_tuple = _origin_tuple(origin)
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
except ValueError:
await self._forbid(
send,
"Malformed Origin {!r} or Host {!r}".format(origin, host),
)
return
if origin_tuple == expected_tuple:
await self.app(scope, receive, send)
return
await self._forbid(
send,
"Origin {!r} does not match Host {!r}".format(origin, host),
)
def _request_scheme(self, scope):
if self.datasette is not None:
try:
if self.datasette.setting("force_https_urls"):
return "https"
except Exception:
pass
return scope.get("scheme") or "http"
async def _forbid(self, send, reason):
await asgi_send(
send,
content=await self.datasette.render_template(
"csrf_error.html", {"reason": reason}
),
status=403,
content_type="text/html; charset=utf-8",
)

View file

@ -1,19 +1,15 @@
import asyncio import asyncio
import atexit
from collections import namedtuple from collections import namedtuple
import inspect
import os
from pathlib import Path from pathlib import Path
import janus
import queue import queue
import sqlite_utils import sqlite_utils
import sys import sys
import tempfile
import threading import threading
import uuid import uuid
from .tracer import trace from .tracer import trace
from .utils import ( from .utils import (
call_with_supported_arguments,
detect_fts, detect_fts,
detect_primary_keys, detect_primary_keys,
detect_spatialite, detect_spatialite,
@ -25,24 +21,14 @@ from .utils import (
table_columns, table_columns,
table_column_details, table_column_details,
) )
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables from .utils.sqlite import sqlite_version
from .utils.sqlite import sqlite_hidden_table_names
from .inspect import inspect_hash from .inspect import inspect_hash
connections = threading.local() connections = threading.local()
EXECUTE_WRITE_RETURNING_LIMIT = 10
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file")) AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
class DatasetteClosedError(RuntimeError):
"""Raised when using a Datasette or Database instance after close()."""
_SHUTDOWN = object()
class Database: class Database:
# For table counts stop at this many rows: # For table counts stop at this many rows:
count_limit = 10000 count_limit = 10000
@ -56,7 +42,6 @@ class Database:
is_memory=False, is_memory=False,
memory_name=None, memory_name=None,
mode=None, mode=None,
is_temp_disk=False,
): ):
self.name = None self.name = None
self._thread_local_id = f"x{self._thread_local_id_counter}" self._thread_local_id = f"x{self._thread_local_id_counter}"
@ -67,44 +52,19 @@ class Database:
self.is_mutable = is_mutable self.is_mutable = is_mutable
self.is_memory = is_memory self.is_memory = is_memory
self.memory_name = memory_name self.memory_name = memory_name
self.is_temp_disk = is_temp_disk
if memory_name is not None: if memory_name is not None:
self.is_memory = True self.is_memory = True
if is_temp_disk:
fd, temp_path = tempfile.mkstemp(suffix=".db", prefix="datasette_temp_")
os.close(fd)
self.path = temp_path
self.is_mutable = True
self.mode = "rwc"
self._wal_enabled = False
atexit.register(self._cleanup_temp_file)
else:
self._wal_enabled = False
self.cached_hash = None self.cached_hash = None
self.cached_size = None self.cached_size = None
self._cached_table_counts = None self._cached_table_counts = None
self._write_thread = None self._write_thread = None
self._write_queue = 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: # These are used when in non-threaded mode:
self._read_connection = None self._read_connection = None
self._write_connection = None self._write_connection = None
# This is used to track all file connections so they can be closed # This is used to track all file connections so they can be closed
self._all_file_connections = [] self._all_file_connections = []
if not is_temp_disk: self.mode = mode
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 @property
def cached_table_counts(self): def cached_table_counts(self):
@ -125,8 +85,6 @@ class Database:
return md5_not_usedforsecurity(self.name)[:6] return md5_not_usedforsecurity(self.name)[:6]
def suggest_name(self): def suggest_name(self):
if self.is_temp_disk:
return "_temp_disk"
if self.path: if self.path:
return Path(self.path).stem return Path(self.path).stem
elif self.memory_name: elif self.memory_name:
@ -165,117 +123,32 @@ class Database:
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
) )
self._all_file_connections.append(conn) self._all_file_connections.append(conn)
if self.is_temp_disk and not self._wal_enabled:
conn.execute("PRAGMA journal_mode=WAL")
self._wal_enabled = True
return conn return conn
def close(self): def close(self):
"""Release all resources held by this database. # Close all connections - useful to avoid running out of file handles in tests
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: for connection in self._all_file_connections:
try: connection.close()
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()
def _cleanup_temp_file(self):
if self.is_temp_disk and self.path:
for suffix in ("", "-wal", "-shm"):
try:
os.unlink(self.path + suffix)
except OSError:
pass
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")
async def execute_write(self, sql, params=None, block=True):
def _inner(conn): def _inner(conn):
cursor = conn.execute(sql, params or []) return conn.execute(sql, params or [])
return ExecuteWriteResult.from_cursor(
cursor, return_all=return_all, returning_limit=returning_limit
)
with trace("sql", database=self.name, sql=sql.strip(), params=params): with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn(_inner, block=block, request=request) results = await self.execute_write_fn(_inner, block=block)
return results return results
async def execute_write_script(self, sql, block=True, request=None): async def execute_write_script(self, sql, block=True):
self._check_not_closed()
def _inner(conn): def _inner(conn):
return conn.executescript(sql) return conn.executescript(sql)
with trace("sql", database=self.name, sql=sql.strip(), executescript=True): with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
results = await self.execute_write_fn( results = await self.execute_write_fn(
_inner, block=block, transaction=False, request=request _inner, block=block, transaction=False
) )
return results return results
async def execute_write_many(self, sql, params_seq, block=True, request=None): async def execute_write_many(self, sql, params_seq, block=True):
self._check_not_closed()
def _inner(conn): def _inner(conn):
count = 0 count = 0
@ -290,14 +163,11 @@ class Database:
with trace( with trace(
"sql", database=self.name, sql=sql.strip(), executemany=True "sql", database=self.name, sql=sql.strip(), executemany=True
) as kwargs: ) as kwargs:
results, count = await self.execute_write_fn( results, count = await self.execute_write_fn(_inner, block=block)
_inner, block=block, request=request
)
kwargs["count"] = count kwargs["count"] = count
return results return results
async def execute_isolated_fn(self, fn): async def execute_isolated_fn(self, fn):
self._check_not_closed()
# Open a new connection just for the duration of this function # Open a new connection just for the duration of this function
# blocking the write queue to avoid any writes occurring during it # blocking the write queue to avoid any writes occurring during it
if self.ds.executor is None: if self.ds.executor is None:
@ -317,21 +187,7 @@ class Database:
# Threaded mode - send to write thread # Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True) return await self._send_to_write_thread(fn, isolated_connection=True)
async def analyze_sql(self, sql, params=None) -> SQLAnalysis: async def execute_write_fn(self, fn, block=True, transaction=True):
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):
pending_events.append(event)
fn = self._wrap_fn_with_hooks(fn, request, transaction, track_event)
if self.ds.executor is None: if self.ds.executor is None:
# non-threaded mode # non-threaded mode
if self._write_connection is None: if self._write_connection is None:
@ -339,67 +195,13 @@ class Database:
self.ds._prepare_connection(self._write_connection, self.name) self.ds._prepare_connection(self._write_connection, self.name)
if transaction: if transaction:
with self._write_connection: with self._write_connection:
result = fn(self._write_connection) return fn(self._write_connection)
else: else:
result = fn(self._write_connection) return fn(self._write_connection)
else: else:
result = await self._send_to_write_thread( return await self._send_to_write_thread(
fn, block=block, transaction=transaction fn, block=block, transaction=transaction
) )
if block:
for event in pending_events:
await self.ds.track_event(event)
else:
# For non-blocking writes, spawn a background task to
# dispatch events after the write thread completes
task_id, reply_future = result
async def _dispatch_events_after_write():
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
return result
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.
# 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
if "track_event" in inspect.signature(original_fn).parameters:
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,
database=self.name,
request=request,
transaction=transaction,
)
wrappers = [w for w in wrappers if w is not None]
if not wrappers:
return fn
# Build the wrapped fn by nesting context manager generators.
# The first wrapper returned by pluggy is outermost.
for wrapper_factory in reversed(wrappers):
fn = _apply_write_wrapper(fn, wrapper_factory, track_event)
return fn
async def _send_to_write_thread( async def _send_to_write_thread(
self, fn, block=True, isolated_connection=False, transaction=True self, fn, block=True, isolated_connection=False, transaction=True
@ -415,15 +217,18 @@ class Database:
) )
self._write_thread.start() self._write_thread.start()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
loop = asyncio.get_running_loop() reply_queue = janus.Queue()
reply_future = loop.create_future()
self._write_queue.put( self._write_queue.put(
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction) WriteTask(fn, task_id, reply_queue, isolated_connection, transaction)
) )
if block: if block:
return await reply_future result = await reply_queue.async_q.get()
if isinstance(result, Exception):
raise result
else:
return result
else: else:
return task_id, reply_future return task_id
def _execute_writes(self): def _execute_writes(self):
# Infinite looping thread that protects the single write connection # Infinite looping thread that protects the single write connection
@ -437,47 +242,38 @@ class Database:
conn_exception = e conn_exception = e
while True: while True:
task = self._write_queue.get() 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: if conn_exception is not None:
exception = conn_exception result = conn_exception
elif task.isolated_connection:
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()
exception = e
finally:
isolated_connection.close()
try:
self._all_file_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
pass
else: else:
try: if task.isolated_connection:
if task.transaction: isolated_connection = self.connect(write=True)
with conn: 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:
self._all_file_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
pass
else:
try:
if task.transaction:
with conn:
result = task.fn(conn)
else:
result = task.fn(conn) result = task.fn(conn)
else: except Exception as e:
result = task.fn(conn) sys.stderr.write("{}\n".format(e))
except Exception as e: sys.stderr.flush()
sys.stderr.write("{}\n".format(e)) result = e
sys.stderr.flush() task.reply_queue.sync_q.put(result)
exception = e
_deliver_write_result(task, result, exception)
async def execute_fn(self, fn): async def execute_fn(self, fn):
self._check_not_closed()
if self.ds.executor is None: if self.ds.executor is None:
# non-threaded mode # non-threaded mode
if self._read_connection is None: if self._read_connection is None:
@ -494,12 +290,9 @@ class Database:
setattr(connections, self._thread_local_id, conn) setattr(connections, self._thread_local_id, conn)
return fn(conn) return fn(conn)
with self._pending_execute_futures_lock: return await asyncio.get_event_loop().run_in_executor(
self._check_not_closed() self.ds.executor, in_thread
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( async def execute(
self, self,
@ -511,7 +304,6 @@ class Database:
log_sql_errors=True, log_sql_errors=True,
): ):
"""Executes sql against db_name in a thread""" """Executes sql against db_name in a thread"""
self._check_not_closed()
page_size = page_size or self.ds.page_size page_size = page_size or self.ds.page_size
def sql_operation_in_thread(conn): def sql_operation_in_thread(conn):
@ -559,7 +351,7 @@ class Database:
def hash(self): def hash(self):
if self.cached_hash is not None: if self.cached_hash is not None:
return self.cached_hash return self.cached_hash
elif self.is_mutable or self.is_memory or self.is_temp_disk: elif self.is_mutable or self.is_memory:
return None return None
elif self.ds.inspect_data and self.ds.inspect_data.get(self.name): elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):
self.cached_hash = self.ds.inspect_data[self.name]["hash"] self.cached_hash = self.ds.inspect_data[self.name]["hash"]
@ -639,7 +431,7 @@ class Database:
async def table_names(self): async def table_names(self):
results = await self.execute( results = await self.execute(
"select name from sqlite_master where type='table' order by name" "select name from sqlite_master where type='table'"
) )
return [r[0] for r in results.rows] return [r[0] for r in results.rows]
@ -717,7 +509,98 @@ class Database:
t for t in db_config["tables"] if db_config["tables"][t].get("hidden") t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
] ]
hidden_tables += await self.execute_fn(sqlite_hidden_table_names) if sqlite_version()[1] >= 37:
hidden_tables += [
x[0]
for x in await self.execute(
"""
with shadow_tables as (
select name
from pragma_table_list
where [type] = 'shadow'
order by name
),
core_tables as (
select name
from sqlite_master
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
OR substr(name, 1, 1) == '_'
),
combined as (
select name from shadow_tables
union all
select name from core_tables
)
select name from combined order by 1
"""
)
]
else:
hidden_tables += [
x[0]
for x in await self.execute(
"""
WITH base AS (
SELECT name
FROM sqlite_master
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
OR substr(name, 1, 1) == '_'
),
fts_suffixes AS (
SELECT column1 AS suffix
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
),
fts5_names AS (
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
),
fts5_shadow_tables AS (
SELECT
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
FROM fts5_names
JOIN fts_suffixes
),
fts3_suffixes AS (
SELECT column1 AS suffix
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
),
fts3_names AS (
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
),
fts3_shadow_tables AS (
SELECT
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
FROM fts3_names
JOIN fts3_suffixes
),
final AS (
SELECT name FROM base
UNION ALL
SELECT name FROM fts5_shadow_tables
UNION ALL
SELECT name FROM fts3_shadow_tables
)
SELECT name FROM final ORDER BY 1
"""
)
]
# Also hide any FTS tables that have a content= argument
hidden_tables += [
x[0]
for x in await self.execute(
"""
SELECT name
FROM sqlite_master
WHERE sql LIKE '%VIRTUAL TABLE%'
AND sql LIKE '%USING FTS%'
AND sql LIKE '%content=%'
"""
)
]
has_spatialite = await self.execute_fn(detect_spatialite) has_spatialite = await self.execute_fn(detect_spatialite)
if has_spatialite: if has_spatialite:
@ -736,11 +619,16 @@ class Database:
"KNN", "KNN",
"KNN2", "KNN2",
] + [ ] + [
r[0] for r in (await self.execute(""" r[0]
for r in (
await self.execute(
"""
select name from sqlite_master select name from sqlite_master
where name like "idx_%" where name like "idx_%"
and type = "table" and type = "table"
""")).rows """
)
).rows
] ]
return hidden_tables return hidden_tables
@ -782,8 +670,6 @@ class Database:
tags.append("mutable") tags.append("mutable")
if self.is_memory: if self.is_memory:
tags.append("memory") tags.append("memory")
if self.is_temp_disk:
tags.append("temp_disk")
if self.hash: if self.hash:
tags.append(f"hash={self.hash}") tags.append(f"hash={self.hash}")
if self.size is not None: if self.size is not None:
@ -794,90 +680,17 @@ class Database:
return f"<Database: {self.name}{tags_str}>" return f"<Database: {self.name}{tags_str}>"
def _apply_write_wrapper(fn, wrapper_factory, track_event):
"""Apply a single write_wrapper context manager around fn.
``wrapper_factory`` is a callable that takes ``(conn)`` and optionally
``track_event``, and returns a generator that yields exactly once.
Code before the yield runs before ``fn(conn)``, code after the yield
runs after. The result of ``fn(conn)`` is sent into the generator
via ``.send()``, and any exception raised by ``fn(conn)`` is thrown
via ``.throw()``.
"""
def wrapped(conn):
gen = call_with_supported_arguments(
wrapper_factory, conn=conn, track_event=track_event
)
# Advance to the yield point (run "before" code)
try:
next(gen)
except StopIteration:
# Generator didn't yield — just run fn unchanged
return fn(conn)
# Execute the actual write
try:
result = fn(conn)
except Exception:
# Throw exception into generator so it can handle it
try:
gen.throw(*sys.exc_info())
except StopIteration:
pass
# Re-raise the original exception
raise
else:
# Send the result back through the yield
try:
gen.send(result)
except StopIteration:
pass
return result
return wrapped
class WriteTask: class WriteTask:
__slots__ = ( __slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction")
"fn",
"task_id",
"loop",
"reply_future",
"isolated_connection",
"transaction",
)
def __init__( def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):
self, fn, task_id, loop, reply_future, isolated_connection, transaction
):
self.fn = fn self.fn = fn
self.task_id = task_id self.task_id = task_id
self.loop = loop self.reply_queue = reply_queue
self.reply_future = reply_future
self.isolated_connection = isolated_connection self.isolated_connection = isolated_connection
self.transaction = transaction 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): class QueryInterrupted(Exception):
def __init__(self, e, sql, params): def __init__(self, e, sql, params):
self.e = e self.e = e
@ -892,44 +705,6 @@ class MultipleValues(Exception):
pass pass
class ExecuteWriteResult:
def __init__(self, rowcount, lastrowid, description, rows, truncated):
self.rowcount = rowcount
self.lastrowid = lastrowid
self.description = description
self.truncated = truncated
self._rows = rows
@classmethod
def from_cursor(
cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT
):
rows = []
truncated = False
description = cursor.description
lastrowid = cursor.lastrowid
try:
if description is not None:
if return_all:
rows = cursor.fetchall()
else:
rows = cursor.fetchmany(returning_limit + 1)
if len(rows) > returning_limit:
rows = rows[:returning_limit]
truncated = True
rowcount = cursor.rowcount
finally:
cursor.close()
if description is not None and not return_all and truncated:
rowcount = -1
return cls(rowcount, lastrowid, description, rows, truncated)
def fetchall(self):
rows = self._rows
self._rows = []
return rows
class Results: class Results:
def __init__(self, rows, truncated, description): def __init__(self, rows, truncated, description):
self.rows = rows self.rows = rows

View file

@ -48,26 +48,12 @@ def register_actions():
resource_class=DatabaseResource, resource_class=DatabaseResource,
also_requires="view-database", also_requires="view-database",
), ),
Action(
name="execute-write-sql",
abbr="ews",
description="Execute writable SQL queries",
resource_class=DatabaseResource,
also_requires="view-database",
),
Action( Action(
name="create-table", name="create-table",
abbr="ct", abbr="ct",
description="Create tables", description="Create tables",
resource_class=DatabaseResource, resource_class=DatabaseResource,
), ),
Action(
name="store-query",
abbr="sq",
description="Create stored queries",
resource_class=DatabaseResource,
also_requires="execute-sql",
),
# Table-level actions (child-level) # Table-level actions (child-level)
Action( Action(
name="view-table", name="view-table",
@ -99,12 +85,6 @@ def register_actions():
description="Alter tables", description="Alter tables",
resource_class=TableResource, resource_class=TableResource,
), ),
Action(
name="set-column-type",
abbr="sct",
description="Set column type",
resource_class=TableResource,
),
Action( Action(
name="drop-table", name="drop-table",
abbr="dt", abbr="dt",
@ -118,16 +98,4 @@ def register_actions():
description="View named query results", description="View named query results",
resource_class=QueryResource, resource_class=QueryResource,
), ),
Action(
name="update-query",
abbr="uq",
description="Update stored queries",
resource_class=QueryResource,
),
Action(
name="delete-query",
abbr="dq",
description="Delete stored queries",
resource_class=QueryResource,
),
) )

View file

@ -1,81 +0,0 @@
import json
import re
import markupsafe
from datasette import hookimpl
from datasette.column_types import ColumnType, SQLiteType
class UrlColumnType(ColumnType):
name = "url"
description = "URL"
sqlite_types = (SQLiteType.TEXT,)
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
return "URL must be a string"
if not re.match(r"^https?://\S+$", value.strip()):
return "Invalid URL"
return None
class EmailColumnType(ColumnType):
name = "email"
description = "Email address"
sqlite_types = (SQLiteType.TEXT,)
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'<a href="mailto:{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
return "Email must be a string"
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()):
return "Invalid email address"
return None
class JsonColumnType(ColumnType):
name = "json"
description = "JSON data"
sqlite_types = (SQLiteType.TEXT,)
async def render_cell(self, value, column, table, database, datasette, request):
if value is None:
return None
try:
parsed = json.loads(value) if isinstance(value, str) else value
formatted = json.dumps(parsed, indent=2)
escaped = markupsafe.escape(formatted)
return markupsafe.Markup(f"<pre>{escaped}</pre>")
except (json.JSONDecodeError, TypeError):
return None
async def validate(self, value, datasette):
if value is None or value == "":
return None
if isinstance(value, str):
try:
json.loads(value)
except json.JSONDecodeError:
return "Invalid JSON"
return None
@hookimpl
def register_column_types(datasette):
return [UrlColumnType, EmailColumnType, JsonColumnType]

View file

@ -1,24 +0,0 @@
from datasette import hookimpl
from datasette.resources import DatabaseResource
@hookimpl
def database_actions(datasette, actor, database, request):
async def inner():
if not datasette.get_database(database).is_mutable:
return []
if not await datasette.allowed(
action="execute-write-sql",
resource=DatabaseResource(database),
actor=actor,
):
return []
return [
{
"href": datasette.urls.database(database) + "/-/execute-write",
"label": "Execute write SQL",
"description": "Run writable SQL with table permission checks.",
}
]
return inner

View file

@ -1,75 +0,0 @@
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.",
),
(
"/-/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

View file

@ -1,82 +0,0 @@
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

View file

@ -0,0 +1,41 @@
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

View file

@ -0,0 +1,494 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
from datasette.utils import actor_matches_allow
import itsdangerous
import time
@hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql(datasette, actor, action):
"""Handle actor restriction-based permission rules (_r key)."""
if not actor:
return None
restrictions = actor.get("_r") if isinstance(actor, dict) else None
if restrictions is None:
return []
# Check if this action appears in restrictions (with abbreviations)
action_obj = datasette.actions.get(action)
action_checks = {action}
if action_obj and action_obj.abbr:
action_checks.add(action_obj.abbr)
# Check if globally allowed in restrictions
global_actions = restrictions.get("a", [])
is_globally_allowed = action_checks.intersection(global_actions)
if is_globally_allowed:
# Globally allowed - no restriction filtering needed
return []
# Not globally allowed - build restriction_sql that lists allowlisted resources
restriction_selects = []
restriction_params = {}
param_counter = 0
# Add database-level allowlisted resources
db_restrictions = restrictions.get("d", {})
for db_name, db_actions in db_restrictions.items():
if action_checks.intersection(db_actions):
prefix = f"restr_{param_counter}"
param_counter += 1
restriction_selects.append(
f"SELECT :{prefix}_parent AS parent, NULL AS child"
)
restriction_params[f"{prefix}_parent"] = db_name
# Add table-level allowlisted resources
resource_restrictions = restrictions.get("r", {})
for db_name, tables in resource_restrictions.items():
for table_name, table_actions in tables.items():
if action_checks.intersection(table_actions):
prefix = f"restr_{param_counter}"
param_counter += 1
restriction_selects.append(
f"SELECT :{prefix}_parent AS parent, :{prefix}_child AS child"
)
restriction_params[f"{prefix}_parent"] = db_name
restriction_params[f"{prefix}_child"] = table_name
if not restriction_selects:
# Action not in allowlist - return empty restriction (INTERSECT will return no results)
return [
PermissionSQL(
params={"deny": f"actor restrictions: {action} not in allowlist"},
restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0", # Empty set
)
]
# Build restriction SQL that returns allowed (parent, child) pairs
restriction_sql = "\nUNION ALL\n".join(restriction_selects)
# Return restriction-only PermissionSQL (sql=None means no permission rules)
# The restriction_sql does the actual filtering via INTERSECT
return [
PermissionSQL(
params=restriction_params,
restriction_sql=restriction_sql,
)
]
@hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql(datasette, actor, action):
"""Grant root user full permissions when enabled."""
if datasette.root_enabled and actor and actor.get("id") == "root":
# Add a single global-level allow rule (NULL, NULL) for root
# This allows root to access everything by default, but database-level
# and table-level deny rules in config can still block specific resources
return PermissionSQL.allow(reason="root user")
return None
@hookimpl(specname="permission_resources_sql")
async def config_permissions_sql(datasette, actor, action):
"""Apply config-based permission rules from datasette.yaml."""
config = datasette.config or {}
def evaluate(allow_block):
if allow_block is None:
return None
return actor_matches_allow(actor, allow_block)
has_restrictions = actor and "_r" in actor if actor else False
restrictions = actor.get("_r", {}) if actor else {}
action_obj = datasette.actions.get(action)
action_checks = {action}
if action_obj and action_obj.abbr:
action_checks.add(action_obj.abbr)
restricted_databases: set[str] = set()
restricted_tables: set[tuple[str, str]] = set()
if has_restrictions:
restricted_databases = {
db_name
for db_name, db_actions in (restrictions.get("d") or {}).items()
if action_checks.intersection(db_actions)
}
restricted_tables = {
(db_name, table_name)
for db_name, tables in (restrictions.get("r") or {}).items()
for table_name, table_actions in tables.items()
if action_checks.intersection(table_actions)
}
# Tables implicitly reference their parent databases
restricted_databases.update(db for db, _ in restricted_tables)
def is_in_restriction_allowlist(parent, child, action_name):
"""Check if a resource is in the actor's restriction allowlist for this action"""
if not has_restrictions:
return True # No restrictions, all resources allowed
# Check global allowlist
if action_checks.intersection(restrictions.get("a", [])):
return True
# Check database-level allowlist
if parent and action_checks.intersection(
restrictions.get("d", {}).get(parent, [])
):
return True
# Check table-level allowlist
if parent:
table_restrictions = (restrictions.get("r", {}) or {}).get(parent, {})
if child:
table_actions = table_restrictions.get(child, [])
if action_checks.intersection(table_actions):
return True
else:
# Parent query should proceed if any child in this database is allowlisted
for table_actions in table_restrictions.values():
if action_checks.intersection(table_actions):
return True
# Parent/child both None: include if any restrictions exist for this action
if parent is None and child is None:
if action_checks.intersection(restrictions.get("a", [])):
return True
if restricted_databases:
return True
if restricted_tables:
return True
return False
rows = []
def add_row(parent, child, result, scope):
if result is None:
return
rows.append(
(
parent,
child,
bool(result),
f"config {'allow' if result else 'deny'} {scope}",
)
)
def add_row_allow_block(parent, child, allow_block, scope):
"""For 'allow' blocks, always add a row if the block exists - deny if no match"""
if allow_block is None:
return
# If actor has restrictions and this resource is NOT in allowlist, skip this config rule
# Restrictions act as a gating filter - config cannot grant access to restricted-out resources
if not is_in_restriction_allowlist(parent, child, action):
return
result = evaluate(allow_block)
bool_result = bool(result)
# If result is None (no match) or False, treat as deny
rows.append(
(
parent,
child,
bool_result, # None becomes False, False stays False, True stays True
f"config {'allow' if result else 'deny'} {scope}",
)
)
if has_restrictions and not bool_result and child is None:
reason = f"config deny {scope} (restriction gate)"
if parent is None:
# Root-level deny: add more specific denies for restricted resources
if action_obj and action_obj.takes_parent:
for db_name in restricted_databases:
rows.append((db_name, None, 0, reason))
if action_obj and action_obj.takes_child:
for db_name, table_name in restricted_tables:
rows.append((db_name, table_name, 0, reason))
else:
# Database-level deny: add child-level denies for restricted tables
if action_obj and action_obj.takes_child:
for db_name, table_name in restricted_tables:
if db_name == parent:
rows.append((db_name, table_name, 0, reason))
root_perm = (config.get("permissions") or {}).get(action)
add_row(None, None, evaluate(root_perm), f"permissions for {action}")
for db_name, db_config in (config.get("databases") or {}).items():
db_perm = (db_config.get("permissions") or {}).get(action)
add_row(
db_name, None, evaluate(db_perm), f"permissions for {action} on {db_name}"
)
for table_name, table_config in (db_config.get("tables") or {}).items():
table_perm = (table_config.get("permissions") or {}).get(action)
add_row(
db_name,
table_name,
evaluate(table_perm),
f"permissions for {action} on {db_name}/{table_name}",
)
if action == "view-table":
table_allow = (table_config or {}).get("allow")
add_row_allow_block(
db_name,
table_name,
table_allow,
f"allow for {action} on {db_name}/{table_name}",
)
for query_name, query_config in (db_config.get("queries") or {}).items():
# query_config can be a string (just SQL) or a dict (with SQL and options)
if isinstance(query_config, dict):
query_perm = (query_config.get("permissions") or {}).get(action)
add_row(
db_name,
query_name,
evaluate(query_perm),
f"permissions for {action} on {db_name}/{query_name}",
)
if action == "view-query":
query_allow = query_config.get("allow")
add_row_allow_block(
db_name,
query_name,
query_allow,
f"allow for {action} on {db_name}/{query_name}",
)
if action == "view-database":
db_allow = db_config.get("allow")
add_row_allow_block(
db_name, None, db_allow, f"allow for {action} on {db_name}"
)
if action == "execute-sql":
db_allow_sql = db_config.get("allow_sql")
add_row_allow_block(db_name, None, db_allow_sql, f"allow_sql for {db_name}")
if action == "view-table":
# Database-level allow block affects all tables in that database
db_allow = db_config.get("allow")
add_row_allow_block(
db_name, None, db_allow, f"allow for {action} on {db_name}"
)
if action == "view-query":
# Database-level allow block affects all queries in that database
db_allow = db_config.get("allow")
add_row_allow_block(
db_name, None, db_allow, f"allow for {action} on {db_name}"
)
# Root-level allow block applies to all view-* actions
if action == "view-instance":
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-instance")
if action == "view-database":
# Root-level allow block also applies to view-database
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-database")
if action == "view-table":
# Root-level allow block also applies to view-table
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-table")
if action == "view-query":
# Root-level allow block also applies to view-query
allow_block = config.get("allow")
add_row_allow_block(None, None, allow_block, "allow for view-query")
if action == "execute-sql":
allow_sql = config.get("allow_sql")
add_row_allow_block(None, None, allow_sql, "allow_sql")
if not rows:
return []
parts = []
params = {}
for idx, (parent, child, allow, reason) in enumerate(rows):
key = f"cfg_{idx}"
parts.append(
f"SELECT :{key}_parent AS parent, :{key}_child AS child, :{key}_allow AS allow, :{key}_reason AS reason"
)
params[f"{key}_parent"] = parent
params[f"{key}_child"] = child
params[f"{key}_allow"] = 1 if allow else 0
params[f"{key}_reason"] = reason
sql = "\nUNION ALL\n".join(parts)
return [PermissionSQL(sql=sql, params=params)]
@hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check(datasette, actor, action):
"""Enforce default_allow_sql setting for execute-sql action."""
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None
@hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql(datasette, actor, action):
"""Apply default allow rules for standard view/execute actions.
With the INTERSECT-based restriction approach, these defaults are always generated
and then filtered by restriction_sql if the actor has restrictions.
"""
# Skip default allow rules if default_deny is enabled
if datasette.default_deny:
return None
default_allow_actions = {
"view-instance",
"view-database",
"view-database-download",
"view-table",
"view-query",
"execute-sql",
}
if action in default_allow_actions:
reason = f"default allow for {action}".replace("'", "''")
return PermissionSQL.allow(reason=reason)
return None
def restrictions_allow_action(
datasette: "Datasette",
restrictions: dict,
action: str,
resource: str | tuple[str, str],
):
"""
Check if actor restrictions allow the requested action against the requested resource.
Restrictions work on an exact-match basis: if an actor has view-table permission,
they can view tables, but NOT automatically view-instance or view-database.
Each permission is checked independently without implication logic.
"""
# Does this action have an abbreviation?
to_check = {action}
action_obj = datasette.actions.get(action)
if action_obj and action_obj.abbr:
to_check.add(action_obj.abbr)
# Check if restrictions explicitly allow this action
# Restrictions can be at three levels:
# - "a": global (any resource)
# - "d": per-database
# - "r": per-table/resource
# Check global level (any resource)
all_allowed = restrictions.get("a")
if all_allowed is not None:
assert isinstance(all_allowed, list)
if to_check.intersection(all_allowed):
return True
# Check database level
if resource:
if isinstance(resource, str):
database_name = resource
else:
database_name = resource[0]
database_allowed = restrictions.get("d", {}).get(database_name)
if database_allowed is not None:
assert isinstance(database_allowed, list)
if to_check.intersection(database_allowed):
return True
# Check table/resource level
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
database, table = resource
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
if table_allowed is not None:
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
# This action is not explicitly allowed, so reject it
return False
@hookimpl
def actor_from_request(datasette, request):
prefix = "dstok_"
if not datasette.setting("allow_signed_tokens"):
return None
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
authorization = request.headers.get("authorization")
if not authorization:
return None
if not authorization.startswith("Bearer "):
return None
token = authorization[len("Bearer ") :]
if not token.startswith(prefix):
return None
token = token[len(prefix) :]
try:
decoded = datasette.unsign(token, namespace="token")
except itsdangerous.BadSignature:
return None
if "t" not in decoded:
# Missing timestamp
return None
created = decoded["t"]
if not isinstance(created, int):
# Invalid timestamp
return None
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
# Invalid duration
return None
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
and max_signed_tokens_ttl
and duration > max_signed_tokens_ttl
):
duration = max_signed_tokens_ttl
if duration:
if time.time() - created > duration:
# Expired
return None
actor = {"id": decoded["a"], "token": "dstok"}
if "_r" in decoded:
actor["_r"] = decoded["_r"]
if duration:
actor["token_expires"] = created + duration
return actor
@hookimpl
def skip_csrf(scope):
# Skip CSRF check for requests with content-type: application/json
if scope["type"] == "http":
headers = scope.get("headers") or {}
if dict(headers).get(b"content-type") == b"application/json":
return True
@hookimpl
def canned_queries(datasette, database, actor):
"""Return canned queries from datasette configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

@ -1,34 +0,0 @@
"""
Default permission implementations for Datasette.
This module provides the built-in permission checking logic through implementations
of the permission_resources_sql hook. The hooks are organized by their purpose:
1. Actor Restrictions - Enforces _r allowlists embedded in actor tokens
2. Root User - Grants full access when --root flag is used
3. Config Rules - Applies permissions from datasette.yaml
4. Default Settings - Enforces default_allow_sql and default view permissions
IMPORTANT: These hooks return PermissionSQL objects that are combined using SQL
UNION/INTERSECT operations. The order of evaluation is:
- restriction_sql fields are INTERSECTed (all must match)
- Regular sql fields are UNIONed and evaluated with cascading priority
"""
from __future__ import annotations
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql,
restrictions_allow_action as restrictions_allow_action,
ActorRestrictions as ActorRestrictions,
)
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,
)

View file

@ -1,442 +0,0 @@
"""
Config-based permission handling for Datasette.
Applies permission rules from datasette.yaml configuration.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
from datasette.utils import actor_matches_allow
from .helpers import PermissionRowCollector, get_action_name_variants
class ConfigPermissionProcessor:
"""
Processes permission rules from datasette.yaml configuration.
Configuration structure:
permissions: # Root-level permissions block
view-instance:
id: admin
databases:
mydb:
permissions: # Database-level permissions
view-database:
id: admin
allow: # Database-level allow block (for view-*)
id: viewer
allow_sql: # execute-sql allow block
id: analyst
tables:
users:
permissions: # Table-level permissions
view-table:
id: admin
allow: # Table-level allow block
id: viewer
queries:
my_query:
permissions: # Query-level permissions
view-query:
id: admin
allow: # Query-level allow block
id: viewer
"""
def __init__(
self,
datasette: "Datasette",
actor: Optional[dict],
action: str,
):
self.datasette = datasette
self.actor = actor
self.action = action
self.config = datasette.config or {}
self.collector = PermissionRowCollector(prefix="cfg")
# Pre-compute action variants
self.action_checks = get_action_name_variants(datasette, action)
self.action_obj = datasette.actions.get(action)
# Parse restrictions if present
self.has_restrictions = actor and "_r" in actor if actor else False
self.restrictions = actor.get("_r", {}) if actor else {}
# Pre-compute restriction info for efficiency
self.restricted_databases: Set[str] = set()
self.restricted_tables: Set[Tuple[str, str]] = set()
if self.has_restrictions:
self.restricted_databases = {
db_name
for db_name, db_actions in (self.restrictions.get("d") or {}).items()
if self.action_checks.intersection(db_actions)
}
self.restricted_tables = {
(db_name, table_name)
for db_name, tables in (self.restrictions.get("r") or {}).items()
for table_name, table_actions in tables.items()
if self.action_checks.intersection(table_actions)
}
# Tables implicitly reference their parent databases
self.restricted_databases.update(db for db, _ in self.restricted_tables)
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
"""Evaluate an allow block against the current actor."""
if allow_block is None:
return None
return actor_matches_allow(self.actor, allow_block)
def is_in_restriction_allowlist(
self,
parent: Optional[str],
child: Optional[str],
) -> bool:
"""Check if resource is allowed by actor restrictions."""
if not self.has_restrictions:
return True # No restrictions, all resources allowed
# Check global allowlist
if self.action_checks.intersection(self.restrictions.get("a", [])):
return True
# Check database-level allowlist
if parent and self.action_checks.intersection(
self.restrictions.get("d", {}).get(parent, [])
):
return True
# Check table-level allowlist
if parent:
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
if child:
table_actions = table_restrictions.get(child, [])
if self.action_checks.intersection(table_actions):
return True
else:
# Parent query should proceed if any child in this database is allowlisted
for table_actions in table_restrictions.values():
if self.action_checks.intersection(table_actions):
return True
# Parent/child both None: include if any restrictions exist for this action
if parent is None and child is None:
if self.action_checks.intersection(self.restrictions.get("a", [])):
return True
if self.restricted_databases:
return True
if self.restricted_tables:
return True
return False
def add_permissions_rule(
self,
parent: Optional[str],
child: Optional[str],
permissions_block: Optional[dict],
scope_desc: str,
) -> None:
"""Add a rule from a permissions:{action} block."""
if permissions_block is None:
return
action_allow_block = permissions_block.get(self.action)
result = self.evaluate_allow_block(action_allow_block)
self.collector.add(
parent=parent,
child=child,
allow=result,
reason=f"config {'allow' if result else 'deny'} {scope_desc}",
if_not_none=True,
)
def add_allow_block_rule(
self,
parent: Optional[str],
child: Optional[str],
allow_block: Any,
scope_desc: str,
) -> None:
"""
Add rules from an allow:{} block.
For allow blocks, if the block exists but doesn't match the actor,
this is treated as a deny. We also handle the restriction-gate logic.
"""
if allow_block is None:
return
# Skip if resource is not in restriction allowlist
if not self.is_in_restriction_allowlist(parent, child):
return
result = self.evaluate_allow_block(allow_block)
bool_result = bool(result)
self.collector.add(
parent,
child,
bool_result,
f"config {'allow' if result else 'deny'} {scope_desc}",
)
# Handle restriction-gate: add explicit denies for restricted resources
self._add_restriction_gate_denies(parent, child, bool_result, scope_desc)
def _add_restriction_gate_denies(
self,
parent: Optional[str],
child: Optional[str],
is_allowed: bool,
scope_desc: str,
) -> None:
"""
When a config rule denies at a higher level, add explicit denies
for restricted resources to prevent child-level allows from
incorrectly granting access.
"""
if is_allowed or child is not None or not self.has_restrictions:
return
if not self.action_obj:
return
reason = f"config deny {scope_desc} (restriction gate)"
if parent is None:
# Root-level deny: add denies for all restricted resources
if self.action_obj.takes_parent:
for db_name in self.restricted_databases:
self.collector.add(db_name, None, False, reason)
if self.action_obj.takes_child:
for db_name, table_name in self.restricted_tables:
self.collector.add(db_name, table_name, False, reason)
else:
# Database-level deny: add denies for tables in that database
if self.action_obj.takes_child:
for db_name, table_name in self.restricted_tables:
if db_name == parent:
self.collector.add(db_name, table_name, False, reason)
def process(self) -> Optional[PermissionSQL]:
"""Process all config rules and return combined PermissionSQL."""
self._process_root_permissions()
self._process_databases()
self._process_root_allow_blocks()
return self.collector.to_permission_sql()
def _process_root_permissions(self) -> None:
"""Process root-level permissions block."""
root_perms = self.config.get("permissions") or {}
self.add_permissions_rule(
None,
None,
root_perms,
f"permissions for {self.action}",
)
def _process_databases(self) -> None:
"""Process database-level and nested configurations."""
databases = self.config.get("databases") or {}
for db_name, db_config in databases.items():
self._process_database(db_name, db_config or {})
def _process_database(self, db_name: str, db_config: dict) -> None:
"""Process a single database's configuration."""
# Database-level permissions block
db_perms = db_config.get("permissions") or {}
self.add_permissions_rule(
db_name,
None,
db_perms,
f"permissions for {self.action} on {db_name}",
)
# Process tables
for table_name, table_config in (db_config.get("tables") or {}).items():
self._process_table(db_name, table_name, table_config or {})
# Process queries
for query_name, query_config in (db_config.get("queries") or {}).items():
self._process_query(db_name, query_name, query_config)
# Database-level allow blocks
self._process_database_allow_blocks(db_name, db_config)
def _process_table(
self,
db_name: str,
table_name: str,
table_config: dict,
) -> None:
"""Process a single table's configuration."""
# Table-level permissions block
table_perms = table_config.get("permissions") or {}
self.add_permissions_rule(
db_name,
table_name,
table_perms,
f"permissions for {self.action} on {db_name}/{table_name}",
)
# Table-level allow block (for view-table)
if self.action == "view-table":
self.add_allow_block_rule(
db_name,
table_name,
table_config.get("allow"),
f"allow for {self.action} on {db_name}/{table_name}",
)
def _process_query(
self,
db_name: str,
query_name: str,
query_config: Any,
) -> None:
"""Process a single query's configuration."""
# Query config can be a string (just SQL) or dict
if not isinstance(query_config, dict):
return
# Query-level permissions block
query_perms = query_config.get("permissions") or {}
self.add_permissions_rule(
db_name,
query_name,
query_perms,
f"permissions for {self.action} on {db_name}/{query_name}",
)
# Query-level allow block (for view-query)
if self.action == "view-query":
self.add_allow_block_rule(
db_name,
query_name,
query_config.get("allow"),
f"allow for {self.action} on {db_name}/{query_name}",
)
def _process_database_allow_blocks(
self,
db_name: str,
db_config: dict,
) -> None:
"""Process database-level allow/allow_sql blocks."""
# view-database allow block
if self.action == "view-database":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow"),
f"allow for {self.action} on {db_name}",
)
# execute-sql allow_sql block
if self.action == "execute-sql":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow_sql"),
f"allow_sql for {db_name}",
)
# view-table uses database-level allow for inheritance
if self.action == "view-table":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow"),
f"allow for {self.action} on {db_name}",
)
# view-query uses database-level allow for inheritance
if self.action == "view-query":
self.add_allow_block_rule(
db_name,
None,
db_config.get("allow"),
f"allow for {self.action} on {db_name}",
)
def _process_root_allow_blocks(self) -> None:
"""Process root-level allow/allow_sql blocks."""
root_allow = self.config.get("allow")
if self.action == "view-instance":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-instance",
)
if self.action == "view-database":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-database",
)
if self.action == "view-table":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-table",
)
if self.action == "view-query":
self.add_allow_block_rule(
None,
None,
root_allow,
"allow for view-query",
)
if self.action == "execute-sql":
self.add_allow_block_rule(
None,
None,
self.config.get("allow_sql"),
"allow_sql",
)
@hookimpl(specname="permission_resources_sql")
async def config_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[List[PermissionSQL]]:
"""
Apply permission rules from datasette.yaml configuration.
This processes:
- permissions: blocks at root, database, table, and query levels
- allow: blocks for view-* actions
- allow_sql: blocks for execute-sql action
"""
processor = ConfigPermissionProcessor(datasette, actor, action)
result = processor.process()
if result is None:
return []
return [result]

View file

@ -1,114 +0,0 @@
"""
Default permission settings for Datasette.
Provides default allow rules for standard view/execute actions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
# Actions that are allowed by default (unless --default-deny is used)
DEFAULT_ALLOW_ACTIONS = frozenset(
{
"view-instance",
"view-database",
"view-database-download",
"view-table",
"view-query",
"execute-sql",
}
)
@hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[PermissionSQL]:
"""
Enforce the default_allow_sql setting.
When default_allow_sql is false (the default), execute-sql is denied
unless explicitly allowed by config or other rules.
"""
if action == "execute-sql":
if not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None
@hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[PermissionSQL]:
"""
Provide default allow rules for standard view/execute actions.
These defaults are skipped when datasette is started with --default-deny.
The restriction_sql mechanism (from actor_restrictions_sql) will still
filter these results if the actor has restrictions.
"""
if datasette.default_deny:
return None
if action in DEFAULT_ALLOW_ACTIONS:
reason = f"default allow for {action}".replace("'", "''")
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,
)

View file

@ -1,85 +0,0 @@
"""
Shared helper utilities for default permission implementations.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Set
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette.permissions import PermissionSQL
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
"""
Get all name variants for an action (full name and abbreviation).
Example:
get_action_name_variants(ds, "view-table") -> {"view-table", "vt"}
"""
variants = {action}
action_obj = datasette.actions.get(action)
if action_obj and action_obj.abbr:
variants.add(action_obj.abbr)
return variants
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
"""Check if an action (or its abbreviation) is in a list."""
return bool(get_action_name_variants(datasette, action).intersection(action_list))
@dataclass
class PermissionRow:
"""A single permission rule row."""
parent: Optional[str]
child: Optional[str]
allow: bool
reason: str
class PermissionRowCollector:
"""Collects permission rows and converts them to PermissionSQL."""
def __init__(self, prefix: str = "row"):
self.rows: List[PermissionRow] = []
self.prefix = prefix
def add(
self,
parent: Optional[str],
child: Optional[str],
allow: Optional[bool],
reason: str,
if_not_none: bool = False,
) -> None:
"""Add a permission row. If if_not_none=True, only add if allow is not None."""
if if_not_none and allow is None:
return
self.rows.append(PermissionRow(parent, child, allow, reason))
def to_permission_sql(self) -> Optional[PermissionSQL]:
"""Convert collected rows to a PermissionSQL object."""
if not self.rows:
return None
parts = []
params = {}
for idx, row in enumerate(self.rows):
key = f"{self.prefix}_{idx}"
parts.append(
f"SELECT :{key}_parent AS parent, :{key}_child AS child, "
f":{key}_allow AS allow, :{key}_reason AS reason"
)
params[f"{key}_parent"] = row.parent
params[f"{key}_child"] = row.child
params[f"{key}_allow"] = 1 if row.allow else 0
params[f"{key}_reason"] = row.reason
sql = "\nUNION ALL\n".join(parts)
return PermissionSQL(sql=sql, params=params)

View file

@ -1,195 +0,0 @@
"""
Actor restriction handling for Datasette permissions.
This module handles the _r (restrictions) key in actor dictionaries, which
contains allowlists of resources the actor can access.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
from .helpers import action_in_list, get_action_name_variants
@dataclass
class ActorRestrictions:
"""Parsed actor restrictions from the _r key."""
global_actions: List[str] # _r.a - globally allowed actions
database_actions: dict # _r.d - {db_name: [actions]}
table_actions: dict # _r.r - {db_name: {table: [actions]}}
@classmethod
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
"""Parse restrictions from actor dict. Returns None if no restrictions."""
if not actor:
return None
assert isinstance(actor, dict), "actor must be a dictionary"
restrictions = actor.get("_r")
if restrictions is None:
return None
return cls(
global_actions=restrictions.get("a", []),
database_actions=restrictions.get("d", {}),
table_actions=restrictions.get("r", {}),
)
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
"""Check if action is in the global allowlist."""
return action_in_list(datasette, action, self.global_actions)
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
"""Get database names where this action is allowed."""
allowed = set()
for db_name, db_actions in self.database_actions.items():
if action_in_list(datasette, action, db_actions):
allowed.add(db_name)
return allowed
def get_allowed_tables(
self, datasette: "Datasette", action: str
) -> Set[Tuple[str, str]]:
"""Get (database, table) pairs where this action is allowed."""
allowed = set()
for db_name, tables in self.table_actions.items():
for table_name, table_actions in tables.items():
if action_in_list(datasette, action, table_actions):
allowed.add((db_name, table_name))
return allowed
@hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[List[PermissionSQL]]:
"""
Handle actor restriction-based permission rules.
When an actor has an "_r" key, it contains an allowlist of resources they
can access. This function returns restriction_sql that filters the final
results to only include resources in that allowlist.
The _r structure:
{
"a": ["vi", "pd"], # Global actions allowed
"d": {"mydb": ["vt", "es"]}, # Database-level actions
"r": {"mydb": {"users": ["vt"]}} # Table-level actions
}
"""
if not actor:
return None
restrictions = ActorRestrictions.from_actor(actor)
if restrictions is None:
# No restrictions - all resources allowed
return []
# If globally allowed, no filtering needed
if restrictions.is_action_globally_allowed(datasette, action):
return []
# Build restriction SQL
allowed_dbs = restrictions.get_allowed_databases(datasette, action)
allowed_tables = restrictions.get_allowed_tables(datasette, action)
# If nothing is allowed for this action, return empty-set restriction
if not allowed_dbs and not allowed_tables:
return [
PermissionSQL(
params={"deny": f"actor restrictions: {action} not in allowlist"},
restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0",
)
]
# Build UNION of allowed resources
selects = []
params = {}
counter = 0
# Database-level entries (parent, NULL) - allows all children
for db_name in allowed_dbs:
key = f"restr_{counter}"
counter += 1
selects.append(f"SELECT :{key}_parent AS parent, NULL AS child")
params[f"{key}_parent"] = db_name
# Table-level entries (parent, child)
for db_name, table_name in allowed_tables:
key = f"restr_{counter}"
counter += 1
selects.append(f"SELECT :{key}_parent AS parent, :{key}_child AS child")
params[f"{key}_parent"] = db_name
params[f"{key}_child"] = table_name
restriction_sql = "\nUNION ALL\n".join(selects)
return [PermissionSQL(params=params, restriction_sql=restriction_sql)]
def restrictions_allow_action(
datasette: "Datasette",
restrictions: dict,
action: str,
resource: Optional[str | Tuple[str, str]],
) -> bool:
"""
Check if restrictions allow the requested action on the requested resource.
This is a synchronous utility function for use by other code that needs
to quickly check restriction allowlists.
Args:
datasette: The Datasette instance
restrictions: The _r dict from an actor
action: The action name to check
resource: None for global, str for database, (db, table) tuple for table
Returns:
True if allowed, False if denied
"""
# Does this action have an abbreviation?
to_check = get_action_name_variants(datasette, action)
# Check global level (any resource)
all_allowed = restrictions.get("a")
if all_allowed is not None:
assert isinstance(all_allowed, list)
if to_check.intersection(all_allowed):
return True
# Check database level
if resource:
if isinstance(resource, str):
database_name = resource
else:
database_name = resource[0]
database_allowed = restrictions.get("d", {}).get(database_name)
if database_allowed is not None:
assert isinstance(database_allowed, list)
if to_check.intersection(database_allowed):
return True
# Check table/resource level
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
database, table = resource
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
if table_allowed is not None:
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
# This action is not explicitly allowed, so reject it
return False

View file

@ -1,29 +0,0 @@
"""
Root user permission handling for Datasette.
Grants full permissions to the root user when --root flag is used.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.permissions import PermissionSQL
@hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
) -> Optional[PermissionSQL]:
"""
Grant root user full permissions when --root flag is used.
"""
if not datasette.root_enabled:
return None
if actor is not None and actor.get("id") == "root":
return PermissionSQL.allow(reason="root user")

View file

@ -1,40 +0,0 @@
"""
Token authentication for Datasette.
Registers the default SignedTokenHandler and delegates token verification
to datasette.verify_token() so all registered handlers are tried.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
from datasette.tokens import SignedTokenHandler
@hookimpl
def register_token_handler(datasette: "Datasette"):
"""Register the default signed token handler."""
return SignedTokenHandler()
@hookimpl(specname="actor_from_request")
async def actor_from_signed_api_token(
datasette: "Datasette", request
) -> Optional[dict]:
"""
Authenticate requests using API tokens by delegating to all registered
token handlers via datasette.verify_token().
"""
authorization = request.headers.get("authorization")
if not authorization:
return None
if not authorization.startswith("Bearer "):
return None
token = authorization[len("Bearer ") :]
return await datasette.verify_token(token)

View file

@ -199,27 +199,6 @@ class UpdateRowEvent(Event):
pks: list pks: list
@dataclass
class RenameTableEvent(Event):
"""
Event name: ``rename-table``
A table has been renamed.
:ivar database: The name of the database containing the renamed table.
:type database: str
:ivar old_table: The previous name of the table.
:type old_table: str
:ivar new_table: The new name of the table.
:type new_table: str
"""
name = "rename-table"
database: str
old_table: str
new_table: str
@dataclass @dataclass
class DeleteRowEvent(Event): class DeleteRowEvent(Event):
""" """
@ -240,42 +219,6 @@ class DeleteRowEvent(Event):
pks: list pks: list
@hookimpl
def write_wrapper(datasette, database, request, transaction):
def wrapper(conn, track_event):
# Snapshot rootpage -> name before the write
before = {
row[1]: row[0]
for row in conn.execute(
"select name, rootpage from sqlite_master"
" where type='table' and rootpage != 0"
).fetchall()
}
yield
# Snapshot rootpage -> name after the write
after = {
row[1]: row[0]
for row in conn.execute(
"select name, rootpage from sqlite_master"
" where type='table' and rootpage != 0"
).fetchall()
}
# Detect renames: same rootpage, different name
for rootpage, old_name in before.items():
new_name = after.get(rootpage)
if new_name and new_name != old_name:
track_event(
RenameTableEvent(
actor=request.actor if request else None,
database=database,
old_table=old_name,
new_table=new_name,
)
)
return wrapper
@hookimpl @hookimpl
def register_events(): def register_events():
return [ return [
@ -284,7 +227,6 @@ def register_events():
CreateTableEvent, CreateTableEvent,
CreateTokenEvent, CreateTokenEvent,
AlterTableEvent, AlterTableEvent,
RenameTableEvent,
DropTableEvent, DropTableEvent,
InsertRowsEvent, InsertRowsEvent,
UpsertRowsEvent, UpsertRowsEvent,

View file

@ -83,7 +83,7 @@ class Facet:
self.ds = ds self.ds = ds
self.request = request self.request = request
self.database = database self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries: # For foreign key expansion. Can be None for e.g. canned SQL queries:
self.table = table self.table = table
self.sql = sql or f"select * from [{table}]" self.sql = sql or f"select * from [{table}]"
self.params = params or [] self.params = params or []
@ -233,7 +233,9 @@ class ColumnFacet(Facet):
) )
where {col} is not null where {col} is not null
group by {col} order by count desc, value limit {limit} group by {col} order by count desc, value limit {limit}
""".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1) """.format(
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
)
try: try:
facet_rows_results = await self.ds.execute( facet_rows_results = await self.ds.execute(
self.database, self.database,
@ -480,7 +482,9 @@ class DateFacet(Facet):
select date({column}) from ( select date({column}) from (
select * from ({sql}) limit 100 select * from ({sql}) limit 100
) where {column} glob "????-??-*" ) where {column} glob "????-??-*"
""".format(column=escape_sqlite(column), sql=self.sql) """.format(
column=escape_sqlite(column), sql=self.sql
)
try: try:
results = await self.ds.execute( results = await self.ds.execute(
self.database, self.database,
@ -526,7 +530,9 @@ class DateFacet(Facet):
) )
where date({col}) is not null where date({col}) is not null
group by date({col}) order by count desc, value limit {limit} group by date({col}) order by count desc, value limit {limit}
""".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1) """.format(
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
)
try: try:
facet_rows_results = await self.ds.execute( facet_rows_results = await self.ds.execute(
self.database, self.database,

View file

@ -1,415 +0,0 @@
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()

View file

@ -55,17 +55,7 @@ def publish_subcommand(publish):
@hookspec @hookspec
def render_cell( def render_cell(row, value, column, table, database, datasette, request):
row,
value,
column,
table,
pks,
database,
datasette,
request,
column_type,
):
"""Customize rendering of HTML table cell values""" """Customize rendering of HTML table cell values"""
@ -84,11 +74,6 @@ def register_actions(datasette):
"""Register actions: returns a list of datasette.permission.Action objects""" """Register actions: returns a list of datasette.permission.Action objects"""
@hookspec
def register_column_types(datasette):
"""Return a list of ColumnType subclasses"""
@hookspec @hookspec
def register_routes(datasette): def register_routes(datasette):
"""Register URL routes: return a list of (regex, view_function) pairs""" """Register URL routes: return a list of (regex, view_function) pairs"""
@ -137,6 +122,11 @@ def permission_resources_sql(datasette, actor, action):
""" """
@hookspec
def canned_queries(datasette, database, actor):
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
@hookspec @hookspec
def register_magic_parameters(datasette): def register_magic_parameters(datasette):
"""Return a list of (name, function) magic parameter functions""" """Return a list of (name, function) magic parameter functions"""
@ -152,11 +142,6 @@ def menu_links(datasette, actor, request):
"""Links for the navigation menu""" """Links for the navigation menu"""
@hookspec
def jump_items_sql(datasette, actor, request):
"""SQL fragments for extra items in the jump menu"""
@hookspec @hookspec
def row_actions(datasette, actor, request, database, table, row): def row_actions(datasette, actor, request, database, table, row):
"""Links for the row actions menu""" """Links for the row actions menu"""
@ -174,7 +159,7 @@ def view_actions(datasette, actor, database, view, request):
@hookspec @hookspec
def query_actions(datasette, actor, database, query_name, request, sql, params): def query_actions(datasette, actor, database, query_name, request, sql, params):
"""Links for the query and stored query actions menu""" """Links for the query and canned query actions menu"""
@hookspec @hookspec
@ -187,6 +172,11 @@ def homepage_actions(datasette, actor, request):
"""Links for the homepage actions menu""" """Links for the homepage actions menu"""
@hookspec
def skip_csrf(datasette, scope):
"""Mechanism for skipping CSRF checks for certain requests"""
@hookspec @hookspec
def handle_exception(datasette, request, exception): def handle_exception(datasette, request, exception):
"""Handle an uncaught exception. Can return a Response or None.""" """Handle an uncaught exception. Can return a Response or None."""
@ -228,38 +218,5 @@ def top_query(datasette, request, database, sql):
@hookspec @hookspec
def top_stored_query(datasette, request, database, query_name): def top_canned_query(datasette, request, database, query_name):
"""HTML to include at the top of the stored query page""" """HTML to include at the top of the canned query page"""
@hookspec
def register_token_handler(datasette):
"""Return a TokenHandler instance for token creation and verification"""
@hookspec
def write_wrapper(datasette, database, request, transaction):
"""Called when a write function is about to execute.
Return a generator function that accepts a ``conn`` argument and
optionally a ``track_event`` argument. The generator should
``yield`` exactly once: code before the ``yield`` runs before
the write, code after the ``yield`` runs after the write
completes. The result of the write is sent back through the
``yield``, so you can capture it with ``result = yield``.
If your generator accepts ``track_event``, you can call
``track_event(event)`` to queue an event that will be dispatched
via ``datasette.track_event()`` after the write commits
successfully. Events are discarded if the write raises an
exception.
If the write raises an exception, it is thrown into the generator
so you can handle it with a try/except around the ``yield``.
``request`` may be ``None`` for writes not originating from an
HTTP request. ``transaction`` is ``True`` if the write will
be wrapped in a transaction.
Return ``None`` to skip wrapping.
"""

View file

@ -10,6 +10,7 @@ from .utils import (
sqlite3, sqlite3,
) )
HASH_BLOCK_SIZE = 1024 * 1024 HASH_BLOCK_SIZE = 1024 * 1024
@ -69,11 +70,16 @@ def inspect_tables(conn, database_metadata):
tables[table]["foreign_keys"] = info tables[table]["foreign_keys"] = info
# Mark tables 'hidden' if they relate to FTS virtual tables # Mark tables 'hidden' if they relate to FTS virtual tables
hidden_tables = [r["name"] for r in conn.execute(""" hidden_tables = [
r["name"]
for r in conn.execute(
"""
select name from sqlite_master select name from sqlite_master
where rootpage = 0 where rootpage = 0
and sql like '%VIRTUAL TABLE%USING FTS%' and sql like '%VIRTUAL TABLE%USING FTS%'
""")] """
)
]
if detect_spatialite(conn): if detect_spatialite(conn):
# Also hide Spatialite internal tables # Also hide Spatialite internal tables
@ -88,11 +94,14 @@ def inspect_tables(conn, database_metadata):
"views_geometry_columns", "views_geometry_columns",
"virts_geometry_columns", "virts_geometry_columns",
] + [ ] + [
r["name"] for r in conn.execute(""" r["name"]
for r in conn.execute(
"""
select name from sqlite_master select name from sqlite_master
where name like "idx_%" where name like "idx_%"
and type = "table" and type = "table"
""") """
)
] ]
for t in tables.keys(): for t in tables.keys():

View file

@ -1,68 +0,0 @@
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()
}

View file

@ -3,6 +3,7 @@ from dataclasses import dataclass
from typing import Any, NamedTuple from typing import Any, NamedTuple
import contextvars import contextvars
# Context variable to track when permission checks should be skipped # Context variable to track when permission checks should be skipped
_skip_permission_checks = contextvars.ContextVar( _skip_permission_checks = contextvars.ContextVar(
"skip_permission_checks", default=False "skip_permission_checks", default=False
@ -58,16 +59,6 @@ class Resource(ABC):
self.child = child self.child = child
self._private = None # Sentinel to track if private was set self._private = None # Sentinel to track if private was set
def __str__(self) -> str:
return "/".join(
str(part) for part in (self.parent, self.child) if part is not None
)
def __repr__(self) -> str:
return "{}(parent={!r}, child={!r})".format(
self.__class__.__name__, self.parent, self.child
)
@property @property
def private(self) -> bool: def private(self) -> bool:
""" """
@ -115,7 +106,7 @@ class Resource(ABC):
@classmethod @classmethod
@abstractmethod @abstractmethod
async def resources_sql(cls, datasette, actor=None) -> str: def resources_sql(cls) -> str:
""" """
Return SQL query that returns all resources of this type. Return SQL query that returns all resources of this type.

View file

@ -23,14 +23,10 @@ DEFAULT_PLUGINS = (
"datasette.sql_functions", "datasette.sql_functions",
"datasette.actor_auth_cookie", "datasette.actor_auth_cookie",
"datasette.default_permissions", "datasette.default_permissions",
"datasette.default_permissions.tokens",
"datasette.default_actions", "datasette.default_actions",
"datasette.default_column_types",
"datasette.default_magic_parameters", "datasette.default_magic_parameters",
"datasette.blob_renderer", "datasette.blob_renderer",
"datasette.default_debug_menu", "datasette.default_menu_links",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.handle_exception", "datasette.handle_exception",
"datasette.forbidden", "datasette.forbidden",
"datasette.events", "datasette.events",

View file

@ -13,7 +13,7 @@ class DatabaseResource(Resource):
super().__init__(parent=database, child=None) super().__init__(parent=database, child=None)
@classmethod @classmethod
async def resources_sql(cls, datasette, actor=None) -> str: async def resources_sql(cls, datasette) -> str:
return """ return """
SELECT database_name AS parent, NULL AS child SELECT database_name AS parent, NULL AS child
FROM catalog_databases FROM catalog_databases
@ -30,7 +30,7 @@ class TableResource(Resource):
super().__init__(parent=database, child=table) super().__init__(parent=database, child=table)
@classmethod @classmethod
async def resources_sql(cls, datasette, actor=None) -> str: async def resources_sql(cls, datasette) -> str:
return """ return """
SELECT database_name AS parent, table_name AS child SELECT database_name AS parent, table_name AS child
FROM catalog_tables FROM catalog_tables
@ -41,7 +41,7 @@ class TableResource(Resource):
class QueryResource(Resource): class QueryResource(Resource):
"""A stored query in a database.""" """A canned query in a database."""
name = "query" name = "query"
parent_class = DatabaseResource parent_class = DatabaseResource
@ -50,9 +50,41 @@ class QueryResource(Resource):
super().__init__(parent=database, child=query) super().__init__(parent=database, child=query)
@classmethod @classmethod
async def resources_sql(cls, datasette, actor=None) -> str: async def resources_sql(cls, datasette) -> str:
return """ from datasette.plugins import pm
SELECT q.database_name AS parent, q.name AS child from datasette.utils import await_me_maybe
FROM queries q
JOIN catalog_databases cd ON cd.database_name = q.database_name # Get all databases from catalog
""" db = datasette.get_internal_database()
result = await db.execute("SELECT database_name FROM catalog_databases")
databases = [row[0] for row in result.rows]
# Gather all canned queries from all databases
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=None, # Get ALL queries for resource enumeration
):
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)

View file

@ -63,14 +63,6 @@ em {
} }
/* end reset */ /* end reset */
/* Modal CSS variables (shared by web components via Shadow DOM) */
:root {
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
--modal-backdrop-blur: blur(4px);
--modal-border-radius: 0.75rem;
--modal-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--modal-animation-duration: 0.2s;
}
body { body {
margin: 0; margin: 0;
@ -362,32 +354,6 @@ form.nav-menu-logout {
.nav-menu-inner a { .nav-menu-inner a {
display: block; 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 */ /* Table/database actions menu */
.page-action-menu { .page-action-menu {
@ -681,14 +647,10 @@ button.core[type=button] {
border-radius: 3px; border-radius: 3px;
-webkit-appearance: none; -webkit-appearance: none;
padding: 9px 4px; padding: 9px 4px;
font-size: 16px; font-size: 1em;
font-family: Helvetica, sans-serif; font-family: Helvetica, sans-serif;
} }
#_search {
font-size: 16px;
}
@ -768,474 +730,6 @@ p.zero-results {
.select-wrapper.small-screen-only { .select-wrapper.small-screen-only {
display: none; display: none;
} }
@keyframes datasette-modal-slide-in {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes datasette-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
dialog.mobile-column-actions-dialog {
--ink: #0f0f0f;
--paper: #f5f3ef;
--muted: #6b6b6b;
--rule: #e2dfd8;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(420px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(640px, calc(100vh - 32px));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: 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.mobile-column-actions-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.mobile-column-actions-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;
}
.mobile-column-actions-dialog .modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.mobile-column-actions-dialog .modal-title {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.mobile-column-actions-dialog .modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
}
.mobile-column-actions-dialog .list-wrap {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
position: relative;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.mobile-column-actions-dialog .list-wrap::before,
.mobile-column-actions-dialog .list-wrap::after {
content: "";
position: sticky;
display: block;
left: 0;
right: 0;
height: 20px;
pointer-events: none;
z-index: 5;
}
.mobile-column-actions-dialog .list-wrap::before {
top: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
}
.mobile-column-actions-dialog .list-wrap::after {
bottom: 0;
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
margin-top: -20px;
}
.mobile-column-top-actions {
padding: 10px 24px 0;
}
.mobile-column-top-action {
display: inline-block;
text-decoration: none;
}
.mobile-column-section {
border-bottom: 1px solid var(--rule);
}
.mobile-column-actions-dialog .col-header {
width: 100%;
padding: 12px 24px;
font: inherit;
font-weight: 600;
border: 0;
background: none;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
}
.mobile-column-header-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.mobile-column-name {
color: var(--ink);
}
.mobile-column-meta {
color: var(--muted);
font-size: 0.78em;
font-family: ui-monospace, monospace;
font-weight: normal;
}
.mobile-column-chevron {
color: var(--muted);
transition: transform 0.2s ease-out;
}
.mobile-column-actions-dialog .col-header[aria-expanded="true"] .mobile-column-chevron {
transform: rotate(180deg);
}
.mobile-column-actions-dialog .col-actions[hidden] {
display: none;
}
.mobile-column-actions-dialog .col-actions ul,
.mobile-column-actions-dialog .col-actions li {
margin: 0;
padding: 0;
list-style-type: none;
}
.mobile-column-actions-dialog .col-actions a,
.mobile-column-actions-dialog .col-actions button {
display: block;
width: 100%;
padding: 10px 24px 10px 40px;
color: var(--ink);
text-align: left;
font: inherit;
text-decoration: none;
background: none;
border: 0;
border-top: 1px solid #f5f5f5;
cursor: pointer;
}
.mobile-column-actions-dialog .col-actions a:hover,
.mobile-column-actions-dialog .col-actions button:hover {
background: var(--paper);
}
.mobile-column-actions-dialog .col-actions a:active,
.mobile-column-actions-dialog .col-actions button:active {
background: #eee;
}
.mobile-column-description,
.mobile-column-no-actions {
margin: 0;
padding: 0 24px 12px 24px;
color: var(--muted);
font-size: 0.85em;
}
.mobile-column-actions-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.mobile-column-actions-dialog .footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.mobile-column-actions-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;
}
.mobile-column-actions-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.mobile-column-actions-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
dialog.set-column-type-dialog {
--ink: #0f0f0f;
--paper: #f5f3ef;
--muted: #6b6b6b;
--rule: #e2dfd8;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(520px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(720px, 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.set-column-type-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.set-column-type-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;
}
.set-column-type-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.set-column-type-dialog .modal-title {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.set-column-type-dialog .modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
}
.set-column-type-status,
.set-column-type-empty,
.set-column-type-error {
margin: 0;
padding: 12px 24px 0;
}
.set-column-type-status,
.set-column-type-empty {
color: var(--muted);
font-size: 0.9rem;
}
.set-column-type-error {
color: #b91c1c;
font-size: 0.9rem;
}
.set-column-type-options {
padding: 16px 24px 24px;
overflow-y: auto;
display: grid;
gap: 12px;
}
.set-column-type-option {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: start;
padding: 14px 16px;
border: 1px solid var(--rule);
border-radius: 8px;
background: #fcfbf9;
cursor: pointer;
}
.set-column-type-option:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.12);
}
.set-column-type-option input {
margin-top: 3px;
}
.set-column-type-option-content {
display: grid;
gap: 4px;
}
.set-column-type-option-name {
font-family: ui-monospace, monospace;
font-size: 0.95rem;
color: var(--ink);
}
.set-column-type-option-description {
color: var(--muted);
font-size: 0.9rem;
}
.set-column-type-dialog .modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.set-column-type-dialog .footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.set-column-type-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;
}
.set-column-type-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.set-column-type-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.set-column-type-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.set-column-type-dialog .btn-primary:hover {
background: #1949b8;
}
.set-column-type-dialog .btn:disabled {
opacity: 0.65;
cursor: wait;
}
@media (max-width: 640px) {
dialog.mobile-column-actions-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.mobile-column-actions-dialog .modal-header {
padding: 16px 18px 14px;
}
.mobile-column-top-actions {
padding-left: 18px;
padding-right: 18px;
}
.mobile-column-actions-dialog .col-header {
padding-left: 18px;
padding-right: 18px;
}
.mobile-column-actions-dialog .col-actions a,
.mobile-column-actions-dialog .col-actions button {
padding-left: 34px;
padding-right: 18px;
}
.mobile-column-description,
.mobile-column-no-actions {
padding-left: 18px;
padding-right: 18px;
}
dialog.set-column-type-dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.set-column-type-dialog .modal-header,
.set-column-type-status,
.set-column-type-empty,
.set-column-type-error,
.set-column-type-options {
padding-left: 18px;
padding-right: 18px;
}
}
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {
.small-screen-only { .small-screen-only {
@ -1297,43 +791,6 @@ dialog.set-column-type-dialog::backdrop {
.filters input.filter-value { .filters input.filter-value {
width: 140px; width: 140px;
} }
button.choose-columns-mobile,
button.column-actions-mobile {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
margin-bottom: 1em;
font-size: 0.9rem;
line-height: 1.2;
font-family: inherit;
background: white;
border: 1px solid #ccc;
border-radius: 5px;
cursor: pointer;
vertical-align: top;
box-sizing: border-box;
min-height: 2.5rem;
}
button.column-actions-mobile {
gap: 0.55rem;
}
button.column-actions-mobile svg {
display: block;
width: 16px;
height: 16px;
flex-shrink: 0;
}
button.column-actions-mobile span {
line-height: 1.2;
}
button.choose-columns-mobile {
margin-right: 0.5rem;
}
} }
svg.dropdown-menu-icon { svg.dropdown-menu-icon {
@ -1409,15 +866,11 @@ svg.dropdown-menu-icon {
border-bottom: 5px solid #666; border-bottom: 5px solid #666;
} }
.stored-query-edit-sql { .canned-query-edit-sql {
padding-left: 0.5em; padding-left: 0.5em;
position: relative; position: relative;
top: 1px; top: 1px;
} }
.save-query {
display: inline-block;
margin-left: 0.45em;
}
.blob-download { .blob-download {
display: block; display: block;

View file

@ -1,699 +0,0 @@
class ColumnChooser extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// State
this._items = [];
this._checked = new Set();
this._savedItems = null;
this._savedChecked = null;
this._onApply = null;
// Drag state
this._ghost = null;
this._dragSrcIdx = null;
this._dropTargetIdx = null;
this._dropPosition = null;
this._ghostOffX = 0;
this._ghostOffY = 0;
this._autoScrollRAF = null;
this._lastPointerY = 0;
this._lastPointerX = 0;
this._SCROLL_ZONE = 72;
this._SCROLL_SPEED = 0.4;
// Bound handlers
this._onMove = this._onMove.bind(this);
this._onUp = this._onUp.bind(this);
this.shadowRoot.innerHTML = `
<style>
:host {
--ink: #0f0f0f;
--paper: #f5f3ef;
--muted: #6b6b6b;
--rule: #e2dfd8;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
dialog {
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: 100%;
max-width: 420px;
max-height: min(640px, calc(100vh - 32px));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
dialog[open] {
display: flex;
flex-direction: column;
height: min(640px, calc(100vh - 32px));
}
dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.modal-title {
font-size: 1rem;
font-weight: 600;
}
.modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
}
.list-toolbar {
padding: 6px 24px;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 12px;
flex-shrink: 0;
}
.list-toolbar button {
background: var(--accent-light);
border: 1px solid var(--rule);
border-radius: 4px;
font-family: inherit;
font-size: 0.75rem;
color: var(--accent);
cursor: pointer;
padding: 3px 10px;
transition: background 0.12s, color 0.12s;
}
.list-toolbar button:hover { background: var(--accent); color: white; }
.list-wrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.list-wrap::before,
.list-wrap::after {
content: '';
position: sticky;
display: block;
left: 0; right: 0;
height: 20px;
pointer-events: none;
z-index: 5;
transition: opacity 0.2s;
}
.list-wrap::before {
top: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
}
.list-wrap::after {
bottom: 0;
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
margin-top: -20px;
}
.scroll-zone {
position: absolute;
left: 0; right: 0;
height: 72px;
pointer-events: none;
z-index: 10;
}
.scroll-zone-top { top: 0; }
.scroll-zone-bot { bottom: 0; }
.drag-list {
list-style: none;
padding: 4px 0;
}
.drag-item {
display: flex;
align-items: center;
background: white;
border-bottom: 1px solid var(--rule);
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
position: relative;
transition: background 0.08s;
}
.drag-item:last-child { border-bottom: none; }
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
flex-shrink: 0;
cursor: grab;
color: #c8c4bc;
touch-action: none;
transition: color 0.15s;
}
.drag-handle:hover { color: var(--accent); }
.drag-handle svg { pointer-events: none; display: block; }
.drag-item-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
cursor: pointer;
}
.drag-item-check {
display: flex;
align-items: center;
width: 32px;
height: 48px;
flex-shrink: 0;
}
.drag-item-check input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.drag-item-label {
flex: 1;
font-size: 0.9rem;
line-height: 48px;
padding-right: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
.drag-item.is-dragging {
opacity: 0;
}
.drop-indicator {
position: absolute;
left: 48px;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 99px;
pointer-events: none;
z-index: 20;
display: none;
}
.drop-indicator.top { top: -1px; display: block; }
.drop-indicator.bottom { bottom: -1px; display: block; }
.drag-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
background: white;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
border: 1.5px solid var(--accent-light);
opacity: 0.97;
will-change: transform;
font-family: system-ui, -apple-system, sans-serif;
}
.scroll-pulse {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
z-index: 10;
transition: opacity 0.15s;
}
.scroll-pulse.top { top: 8px; }
.scroll-pulse.bot { bottom: 8px; }
.scroll-pulse.active {
opacity: 0.18;
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: #1448c0; }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.btn-ghost:hover { background: var(--rule); color: var(--ink); }
.list-wrap::-webkit-scrollbar { width: 5px; }
.list-wrap::-webkit-scrollbar-track { background: transparent; }
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
input, textarea { -webkit-user-select: auto; user-select: auto; }
</style>
<dialog aria-labelledby="modalTitle">
<div class="modal-header">
<span class="modal-title" id="modalTitle">Choose columns</span>
<span class="modal-meta" id="selectedCount"></span>
</div>
<div class="list-toolbar">
<button id="selectAllBtn">Select all</button>
<button id="deselectAllBtn">Deselect all</button>
</div>
<div class="list-wrap" id="listWrap">
<div class="scroll-pulse top" id="pulseTop"></div>
<div class="scroll-pulse bot" id="pulseBot"></div>
<ul class="drag-list" id="dragList"></ul>
</div>
<div class="modal-footer">
<span class="footer-info" id="footerInfo"></span>
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn btn-primary" id="applyBtn">Apply</button>
</div>
</dialog>
`;
// DOM refs
this._dialog = this.shadowRoot.querySelector("dialog");
this._listWrap = this.shadowRoot.getElementById("listWrap");
this._dragList = this.shadowRoot.getElementById("dragList");
this._pulseTop = this.shadowRoot.getElementById("pulseTop");
this._pulseBot = this.shadowRoot.getElementById("pulseBot");
this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn");
this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn");
this._cancelBtn = this.shadowRoot.getElementById("cancelBtn");
this._applyBtn = this.shadowRoot.getElementById("applyBtn");
this._countEl = this.shadowRoot.getElementById("selectedCount");
this._footerEl = this.shadowRoot.getElementById("footerInfo");
// Event listeners
this._selectAllBtn.addEventListener("click", () => this._selectAll());
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
this._cancelBtn.addEventListener("click", () => this._close());
this._applyBtn.addEventListener("click", () => this._apply());
this._dialog.addEventListener("click", (e) => {
if (e.target === this._dialog) this._close();
});
this._dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this._close();
});
}
/**
* Open the column chooser dialog.
* @param {Object} opts
* @param {string[]} opts.columns - All available column names, in display order.
* @param {string[]} opts.selected - Column names that should be pre-checked.
* @param {function(string[]): void} opts.onApply - Called with the selected columns in order when Apply is clicked.
*/
open({ columns, selected = [], onApply }) {
this._items = [...columns];
this._checked = new Set(selected);
this._onApply = onApply || null;
// Save state for cancel/restore
this._savedItems = [...this._items];
this._savedChecked = new Set(this._checked);
this._render();
this._dialog.showModal();
}
// ── Internal methods ──
_close() {
this._items = this._savedItems ? [...this._savedItems] : this._items;
this._checked = this._savedChecked
? new Set(this._savedChecked)
: this._checked;
this._dialog.close();
}
_selectAll() {
this._items.forEach((col) => this._checked.add(col));
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.checked = true;
});
this._updateCounts();
}
_deselectAll() {
this._checked.clear();
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.checked = false;
});
this._updateCounts();
}
_apply() {
const selected = this._items.filter((col) => this._checked.has(col));
this._dialog.close();
if (this._onApply) {
this._onApply(selected);
}
}
_render() {
this._dragList.innerHTML = "";
this._items.forEach((col, i) => {
const li = document.createElement("li");
li.className = "drag-item";
li.dataset.idx = i;
li.innerHTML = `
<span class="drag-handle" aria-label="Drag to reorder">
<svg width="12" height="18" viewBox="0 0 12 18" fill="currentColor">
<circle cx="3.5" cy="3.5" r="1.8"/>
<circle cx="8.5" cy="3.5" r="1.8"/>
<circle cx="3.5" cy="9" r="1.8"/>
<circle cx="8.5" cy="9" r="1.8"/>
<circle cx="3.5" cy="14.5" r="1.8"/>
<circle cx="8.5" cy="14.5" r="1.8"/>
</svg>
</span>
<label class="drag-item-content">
<span class="drag-item-check">
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
</span>
<span class="drag-item-label">${col}</span>
</label>
<div class="drop-indicator"></div>
`;
li.querySelector("input").addEventListener("change", (e) => {
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
this._updateCounts();
});
li.querySelector(".drag-handle").addEventListener("pointerdown", (e) =>
this._startDrag(e, i),
);
this._dragList.appendChild(li);
});
this._updateCounts();
}
_updateCounts() {
const n = this._checked.size;
this._countEl.textContent = `${n} of ${this._items.length} selected`;
this._footerEl.textContent = `${this._items.length} columns`;
}
// ── Drag engine ──
_startDrag(e, idx) {
e.preventDefault();
this._dragSrcIdx = idx;
const srcEl = this._dragList.children[idx];
const rect = srcEl.getBoundingClientRect();
this._ghostOffX = e.clientX - rect.left;
this._ghostOffY = e.clientY - rect.top;
// Build ghost inside shadow DOM
this._ghost = document.createElement("div");
this._ghost.className = "drag-ghost";
this._ghost.style.width = rect.width + "px";
this._ghost.style.height = rect.height + "px";
this._ghost.innerHTML = srcEl.innerHTML;
this._ghost.querySelector(".drop-indicator")?.remove();
const h = this._ghost.querySelector(".drag-handle");
if (h) h.style.color = "var(--accent)";
this.shadowRoot.appendChild(this._ghost);
srcEl.classList.add("is-dragging");
this._positionGhost(e.clientX, e.clientY);
document.addEventListener("pointermove", this._onMove);
document.addEventListener("pointerup", this._onUp);
document.addEventListener("pointercancel", this._onUp);
}
_positionGhost(cx, cy) {
this._ghost.style.left = cx - this._ghostOffX + "px";
this._ghost.style.top = cy - this._ghostOffY + "px";
}
_onMove(e) {
this._lastPointerX = e.clientX;
this._lastPointerY = e.clientY;
this._positionGhost(e.clientX, e.clientY);
this._updateDropTarget(e.clientY);
this._updateAutoScroll(e.clientY);
}
_onUp() {
document.removeEventListener("pointermove", this._onMove);
document.removeEventListener("pointerup", this._onUp);
document.removeEventListener("pointercancel", this._onUp);
this._stopAutoScroll();
const noMove =
this._dropTargetIdx === null || this._dropTargetIdx === this._dragSrcIdx;
this._clearDropIndicators();
let dest = null;
if (!noMove) {
const moved = this._items.splice(this._dragSrcIdx, 1)[0];
dest = this._dropTargetIdx;
if (this._dropPosition === "after") dest++;
if (dest > this._dragSrcIdx) dest--;
this._items.splice(dest, 0, moved);
}
this._dragSrcIdx = null;
this._dropTargetIdx = null;
this._dropPosition = null;
const g = this._ghost;
this._ghost = null;
if (noMove) {
if (g) g.remove();
this._render();
return;
}
this._render();
if (g && dest !== null) {
const landedEl = this._dragList.children[dest];
if (landedEl) {
landedEl.style.opacity = "0";
const r = landedEl.getBoundingClientRect();
g.getBoundingClientRect();
g.style.transition =
"left 0.15s cubic-bezier(0.22, 1, 0.36, 1), top 0.15s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.15s, opacity 0.1s 0.1s";
g.style.left = r.left + "px";
g.style.top = r.top + "px";
g.style.boxShadow = "0 1px 4px rgba(0,0,0,0.08)";
g.style.opacity = "0";
setTimeout(() => {
g.remove();
if (landedEl) landedEl.style.opacity = "";
}, 160);
} else {
g.remove();
}
} else if (g) {
g.remove();
}
}
_updateDropTarget(clientY) {
this._clearDropIndicators();
const listItems = [
...this._dragList.querySelectorAll(".drag-item:not(.is-dragging)"),
];
if (!listItems.length) return;
let best = null,
bestDist = Infinity;
listItems.forEach((li) => {
const r = li.getBoundingClientRect();
const mid = r.top + r.height / 2;
const dist = Math.abs(clientY - mid);
if (dist < bestDist) {
bestDist = dist;
best = li;
}
});
if (!best) return;
const r = best.getBoundingClientRect();
const mid = r.top + r.height / 2;
const above = clientY < mid;
const indic = best.querySelector(".drop-indicator");
this._dropTargetIdx = parseInt(best.dataset.idx);
this._dropPosition = above ? "before" : "after";
if (indic) {
indic.className = "drop-indicator " + (above ? "top" : "bottom");
}
}
_clearDropIndicators() {
this._dragList.querySelectorAll(".drop-indicator").forEach((el) => {
el.className = "drop-indicator";
});
}
_updateAutoScroll(clientY) {
const rect = this._listWrap.getBoundingClientRect();
const relY = clientY - rect.top;
const distTop = relY;
const distBot = rect.height - relY;
const inTop = distTop < this._SCROLL_ZONE && distTop >= 0;
const inBot = distBot < this._SCROLL_ZONE && distBot >= 0;
this._pulseTop.classList.toggle("active", inTop);
this._pulseBot.classList.toggle("active", inBot);
if ((inTop || inBot) && !this._autoScrollRAF) {
let lastTime = null;
const loop = (ts) => {
if (!this._ghost) {
this._stopAutoScroll();
return;
}
if (lastTime !== null) {
const dt = ts - lastTime;
const rect2 = this._listWrap.getBoundingClientRect();
const relY2 = this._lastPointerY - rect2.top;
const dTop = relY2;
const dBot = rect2.height - relY2;
if (dTop < this._SCROLL_ZONE && dTop >= 0) {
const factor = 1 - dTop / this._SCROLL_ZONE;
this._listWrap.scrollTop -= this._SCROLL_SPEED * dt * factor * 2.5;
} else if (dBot < this._SCROLL_ZONE && dBot >= 0) {
const factor = 1 - dBot / this._SCROLL_ZONE;
this._listWrap.scrollTop += this._SCROLL_SPEED * dt * factor * 2.5;
} else {
this._stopAutoScroll();
return;
}
this._updateDropTarget(this._lastPointerY);
}
lastTime = ts;
this._autoScrollRAF = requestAnimationFrame(loop);
};
this._autoScrollRAF = requestAnimationFrame(loop);
}
if (!inTop && !inBot) this._stopAutoScroll();
}
_stopAutoScroll() {
if (this._autoScrollRAF) {
cancelAnimationFrame(this._autoScrollRAF);
this._autoScrollRAF = null;
}
this._pulseTop.classList.remove("active");
this._pulseBot.classList.remove("active");
}
}
customElements.define("column-chooser", ColumnChooser);

View file

@ -82,19 +82,6 @@ const datasetteManager = {
return columnActions; return columnActions;
}, },
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 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 * In future, panels could be repeated. We omit that for now since so many plugins depend on
@ -205,6 +192,7 @@ const initializeDatasette = () => {
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window. // DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
window.__DATASETTE__ = datasetteManager; window.__DATASETTE__ = datasetteManager;
console.debug("Datasette Manager Created!");
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, { const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
detail: datasetteManager, detail: datasetteManager,

View file

@ -1,318 +0,0 @@
var MOBILE_COLUMN_BREAKPOINT = 576;
var MOBILE_COLUMN_DIALOG_ID = "mobile-column-actions-dialog";
var MOBILE_COLUMN_DIALOG_TITLE_ID = "mobile-column-actions-title";
function mobileColumnHeaders(manager) {
return Array.from(
document.querySelectorAll(manager.selectors.tableHeaders),
).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1");
}
function mobileColumnMetaText(th) {
var parts = [];
if (th.dataset.columnType) {
parts.push(th.dataset.columnType);
}
if (th.dataset.isPk === "1") {
parts.push("pk");
}
if (th.dataset.columnNotNull === "1") {
parts.push("not null");
}
return parts.join(", ");
}
function createMobileColumnActionNode(itemConfig, closeDialog) {
var actionNode;
if (itemConfig.href) {
actionNode = document.createElement("a");
actionNode.href = itemConfig.href;
} else {
actionNode = document.createElement("button");
actionNode.type = "button";
}
actionNode.textContent = itemConfig.label;
if (itemConfig.onClick) {
actionNode.addEventListener("click", function (ev) {
try {
itemConfig.onClick.call(actionNode, ev);
} finally {
closeDialog({ restoreFocus: false });
}
});
}
return actionNode;
}
function initMobileColumnActions(manager) {
var triggerButton = document.querySelector(".column-actions-mobile");
if (!triggerButton) {
return;
}
if (
!window.URLSearchParams ||
!window.HTMLDialogElement ||
!manager.columnActions
) {
triggerButton.style.display = "none";
return;
}
if (!mobileColumnHeaders(manager).length) {
triggerButton.style.display = "none";
return;
}
var dialog = document.createElement("dialog");
dialog.className = "mobile-column-actions-dialog";
dialog.id = MOBILE_COLUMN_DIALOG_ID;
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
<span class="modal-meta"></span>
</div>
<div class="list-wrap mobile-column-list"></div>
<div class="modal-footer">
<span class="footer-info">Tap a column to reveal actions.</span>
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
</div>
`;
document.body.appendChild(dialog);
triggerButton.setAttribute("aria-haspopup", "dialog");
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
triggerButton.setAttribute("aria-expanded", "false");
var countEl = dialog.querySelector(".modal-meta");
var listWrap = dialog.querySelector(".mobile-column-list");
var doneButton = dialog.querySelector(".mobile-column-actions-done");
var expandedSectionId = null;
var shouldRestoreFocus = true;
function updateExpandedSection() {
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
var controlsId = button.getAttribute("aria-controls");
var actionList = dialog.querySelector("#" + controlsId);
var isExpanded = controlsId === expandedSectionId;
button.setAttribute("aria-expanded", isExpanded ? "true" : "false");
actionList.hidden = !isExpanded;
actionList.classList.toggle("expanded", isExpanded);
});
}
function scrollExpandedSectionIntoView(section) {
var sectionTop = section.offsetTop;
var sectionBottom = sectionTop + section.offsetHeight;
var visibleTop = listWrap.scrollTop;
var visibleBottom = visibleTop + listWrap.clientHeight;
var sectionHeight = section.offsetHeight;
if (sectionTop < visibleTop) {
listWrap.scrollTop = sectionTop;
return;
}
if (sectionBottom <= visibleBottom) {
return;
}
if (sectionHeight <= listWrap.clientHeight) {
listWrap.scrollTop = sectionBottom - listWrap.clientHeight;
} else {
listWrap.scrollTop = sectionTop;
}
}
function closeDialog(options) {
options = options || {};
shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
} else {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
}
}
function renderDialog() {
var headers = mobileColumnHeaders(manager);
if (!headers.length) {
closeDialog({ restoreFocus: false });
triggerButton.style.display = "none";
return false;
}
if (
!headers.some(
(_th, index) => `mobile-column-actions-${index}` === expandedSectionId,
)
) {
expandedSectionId = null;
}
countEl.textContent = `${headers.length} column${
headers.length === 1 ? "" : "s"
}`;
listWrap.innerHTML = "";
if (manager.columnActions.shouldShowShowAllColumns()) {
var topActions = document.createElement("div");
topActions.className = "mobile-column-top-actions";
var showAllColumns = document.createElement("a");
showAllColumns.className = "btn btn-ghost mobile-column-top-action";
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
showAllColumns.textContent = "Show all columns";
topActions.appendChild(showAllColumns);
listWrap.appendChild(topActions);
}
headers.forEach((th, index) => {
var sectionId = `mobile-column-actions-${index}`;
var actionState = manager.columnActions.buildColumnActionState(th, {
includeChooseColumns: false,
includeShowAllColumns: false,
});
var section = document.createElement("section");
section.className = "mobile-column-section";
var headerButton = document.createElement("button");
headerButton.type = "button";
headerButton.className = "col-header";
headerButton.setAttribute("aria-controls", sectionId);
headerButton.setAttribute("aria-expanded", "false");
var headerText = document.createElement("span");
headerText.className = "mobile-column-header-text";
var name = document.createElement("span");
name.className = "mobile-column-name";
name.textContent = th.dataset.column;
headerText.appendChild(name);
var metaText = mobileColumnMetaText(th);
if (metaText) {
var meta = document.createElement("span");
meta.className = "mobile-column-meta";
meta.textContent = metaText;
headerText.appendChild(meta);
}
var chevron = document.createElement("span");
chevron.className = "mobile-column-chevron";
chevron.setAttribute("aria-hidden", "true");
chevron.textContent = "▾";
headerButton.appendChild(headerText);
headerButton.appendChild(chevron);
headerButton.addEventListener("click", function () {
expandedSectionId = expandedSectionId === sectionId ? null : sectionId;
updateExpandedSection();
if (expandedSectionId === sectionId) {
scrollExpandedSectionIntoView(section);
}
});
var actionContainer = document.createElement("div");
actionContainer.id = sectionId;
actionContainer.className = "col-actions";
actionContainer.hidden = true;
if (actionState.columnDescription) {
var description = document.createElement("p");
description.className = "mobile-column-description";
description.textContent = actionState.columnDescription;
actionContainer.appendChild(description);
}
if (actionState.actionItems.length) {
var actionList = document.createElement("ul");
actionState.actionItems.forEach((itemConfig) => {
var actionItem = document.createElement("li");
actionItem.appendChild(
createMobileColumnActionNode(itemConfig, closeDialog),
);
actionList.appendChild(actionItem);
});
actionContainer.appendChild(actionList);
} else {
var noActions = document.createElement("p");
noActions.className = "mobile-column-no-actions";
noActions.textContent = "No actions available";
actionContainer.appendChild(noActions);
}
section.appendChild(headerButton);
section.appendChild(actionContainer);
listWrap.appendChild(section);
});
updateExpandedSection();
return true;
}
function openDialog() {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT) {
return;
}
if (!renderDialog()) {
return;
}
if (!dialog.open) {
dialog.showModal();
}
triggerButton.setAttribute("aria-expanded", "true");
var focusTarget =
dialog.querySelector(".mobile-column-top-action") ||
dialog.querySelector(".col-header") ||
doneButton;
focusTarget.focus();
}
triggerButton.addEventListener("click", function () {
if (dialog.open) {
closeDialog();
} else {
openDialog();
}
});
doneButton.addEventListener("click", function () {
closeDialog();
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeDialog();
}
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
closeDialog();
});
dialog.addEventListener("close", function () {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
});
window.addEventListener("resize", function () {
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) {
closeDialog({ restoreFocus: false });
}
});
}
document.addEventListener("datasette_init", function (evt) {
initMobileColumnActions(evt.detail);
});

View file

@ -1,22 +1,10 @@
let navigationSearchInstanceCounter = 0;
class NavigationSearch extends HTMLElement { class NavigationSearch extends HTMLElement {
constructor() { constructor() {
super(); 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.attachShadow({ mode: "open" });
this.selectedIndex = -1; this.selectedIndex = -1;
this.matches = []; this.matches = [];
this.renderedMatches = [];
this.debounceTimer = null; this.debounceTimer = null;
this.restoreFocusTarget = null;
this.shouldRestoreFocus = true;
this.render(); this.render();
this.setupEventListeners(); this.setupEventListeners();
@ -31,20 +19,19 @@ class NavigationSearch extends HTMLElement {
dialog { dialog {
border: none; border: none;
border-radius: var(--modal-border-radius, 0.75rem); border-radius: 0.75rem;
padding: 0; padding: 0;
max-width: 90vw; max-width: 90vw;
width: 600px; width: 600px;
max-height: 80vh; max-height: 80vh;
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out; animation: slideIn 0.2s ease-out;
} }
dialog::backdrop { dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); background: rgba(0, 0, 0, 0.5);
backdrop-filter: var(--modal-backdrop-blur, blur(4px)); backdrop-filter: blur(4px);
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); animation: fadeIn 0.2s ease-out;
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
} }
@keyframes slideIn { @keyframes slideIn {
@ -66,20 +53,16 @@ class NavigationSearch extends HTMLElement {
.search-container { .search-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
} }
.search-input-wrapper { .search-input-wrapper {
padding: 1.25rem; padding: 1.25rem;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 0.5rem;
align-items: center;
} }
.search-input { .search-input {
width: 100%; width: 100%;
flex: 1;
min-width: 0;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 1rem; font-size: 1rem;
border: 2px solid #e5e7eb; border: 2px solid #e5e7eb;
@ -93,36 +76,12 @@ class NavigationSearch extends HTMLElement {
border-color: #2563eb; 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 { .results-container {
overflow-y: auto; overflow-y: auto;
height: calc(80vh - 180px); height: calc(80vh - 180px);
padding: 0.5rem; padding: 0.5rem;
} }
.results-list:empty {
display: none;
}
.result-item { .result-item {
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
cursor: pointer; cursor: pointer;
@ -141,81 +100,16 @@ class NavigationSearch extends HTMLElement {
background-color: #dbeafe; 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 { .result-name {
font-weight: 500; font-weight: 500;
color: #111827; 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 { .result-url {
font-size: 0.875rem; font-size: 0.875rem;
color: #6b7280; 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 { .no-results {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
@ -241,18 +135,6 @@ class NavigationSearch extends HTMLElement {
font-family: monospace; 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 */ /* Mobile optimizations */
@media (max-width: 640px) { @media (max-width: 640px) {
dialog { dialog {
@ -280,29 +162,19 @@ class NavigationSearch extends HTMLElement {
} }
</style> </style>
<dialog aria-modal="true" aria-labelledby="${this.titleId}"> <dialog>
<div class="search-container"> <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"> <div class="search-input-wrapper">
<input <input
id="${this.inputId}"
type="text" type="text"
class="search-input" class="search-input"
placeholder="Jump to..." placeholder="Search..."
aria-label="Jump to" aria-label="Search navigation"
aria-describedby="${this.instructionsId}"
role="combobox"
aria-autocomplete="list"
aria-controls="${this.listboxId}"
aria-expanded="false"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
> >
<button type="button" class="close-search" aria-label="Close jump menu">&times;</button>
</div> </div>
<div class="results-container"></div> <div class="results-container" role="listbox"></div>
<div class="hint-text"> <div class="hint-text">
<span><kbd></kbd> <kbd></kbd> Navigate</span> <span><kbd></kbd> <kbd></kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span> <span><kbd>Enter</kbd> Select</span>
@ -316,7 +188,6 @@ class NavigationSearch extends HTMLElement {
setupEventListeners() { setupEventListeners() {
const dialog = this.shadowRoot.querySelector("dialog"); const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input"); const input = this.shadowRoot.querySelector(".search-input");
const closeButton = this.shadowRoot.querySelector(".close-search");
const resultsContainer = const resultsContainer =
this.shadowRoot.querySelector(".results-container"); this.shadowRoot.querySelector(".results-container");
@ -328,17 +199,6 @@ 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 event
input.addEventListener("input", (e) => { input.addEventListener("input", (e) => {
this.handleSearch(e.target.value); this.handleSearch(e.target.value);
@ -360,19 +220,8 @@ class NavigationSearch extends HTMLElement {
} }
}); });
closeButton.addEventListener("click", () => {
this.closeMenu();
});
// Click on result item // Click on result item
resultsContainer.addEventListener("click", (e) => { 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"); const item = e.target.closest(".result-item");
if (item) { if (item) {
const index = parseInt(item.dataset.index); const index = parseInt(item.dataset.index);
@ -387,15 +236,6 @@ class NavigationSearch extends HTMLElement {
} }
}); });
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this.closeMenu();
});
dialog.addEventListener("close", () => {
this.onMenuClosed();
});
// Initial load // Initial load
this.loadInitialData(); this.loadInitialData();
} }
@ -410,106 +250,6 @@ 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() { loadInitialData() {
const itemsAttr = this.getAttribute("items"); const itemsAttr = this.getAttribute("items");
if (itemsAttr) { if (itemsAttr) {
@ -526,11 +266,6 @@ class NavigationSearch extends HTMLElement {
handleSearch(query) { handleSearch(query) {
clearTimeout(this.debounceTimer); clearTimeout(this.debounceTimer);
if (query.trim()) {
this.setStatus("Searching...");
} else {
this.setStatus("");
}
this.debounceTimer = setTimeout(() => { this.debounceTimer = setTimeout(() => {
const url = this.getAttribute("url"); const url = this.getAttribute("url");
@ -553,262 +288,65 @@ class NavigationSearch extends HTMLElement {
this.matches = data.matches || []; this.matches = data.matches || [];
this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults(); this.renderResults();
if (query.trim()) {
this.setStatus(this.resultsStatus(this.matches.length, data.truncated));
} else {
this.setStatus("");
}
} catch (e) { } catch (e) {
console.error("Failed to fetch search results:", e); console.error("Failed to fetch search results:", e);
this.matches = []; this.matches = [];
this.renderResults(); this.renderResults();
this.setStatus("Search failed.");
} }
} }
filterLocalItems(query) { filterLocalItems(query) {
if (!query.trim()) { if (!query.trim()) {
this.matches = this.allItems || []; this.matches = [];
} else { } else {
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
this.matches = (this.allItems || []).filter( this.matches = (this.allItems || []).filter(
(item) => (item) =>
item.name.toLowerCase().includes(lowerQuery) || item.name.toLowerCase().includes(lowerQuery) ||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
item.url.toLowerCase().includes(lowerQuery), item.url.toLowerCase().includes(lowerQuery),
); );
} }
this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults(); this.renderResults();
if (query.trim()) {
this.setStatus(this.resultsStatus(this.matches.length, false));
} else {
this.setStatus("");
}
}
recentItemsStorageKey() {
return "datasette.navigationSearch.recentItems";
}
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;
}
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>
${type}
<div class="result-name">${this.escapeHtml(displayName)}</div>
${label}
<div class="result-url">${this.escapeHtml(match.url)}</div>
${description}
</div>
</div>
`;
} }
renderResults() { renderResults() {
const container = this.shadowRoot.querySelector(".results-container"); const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input"); 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.matches.length === 0) {
if ( const message = input.value.trim()
this.selectedIndex < 0 || ? "No results found"
this.selectedIndex >= renderedMatches.length : "Start typing to search...";
) { container.innerHTML = `<div class="no-results">${message}</div>`;
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; return;
} }
const recentHeading = recentItems.length container.innerHTML = this.matches
? `<div class="results-heading" id="${this.recentHeadingId}">Recent</div>` .map(
: ""; (match, index) => `
const recentGroup = recentItems.length <div
? `<div role="group" aria-labelledby="${this.recentHeadingId}">${recentItems class="result-item ${
.map((match, index) => this.resultItemHtml(match, index)) index === this.selectedIndex ? "selected" : ""
.join("")}</div>` }"
: ""; data-index="${index}"
const recentActions = recentItems.length role="option"
? `<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>` aria-selected="${index === this.selectedIndex}"
: ""; >
const defaultHtml = defaultMatches <div>
.map((match, index) => <div class="result-name">${this.escapeHtml(
this.resultItemHtml(match, recentItems.length + index), match.name,
)}</div>
<div class="result-url">${this.escapeHtml(match.url)}</div>
</div>
</div>
`,
) )
.join(""); .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 // Scroll selected item into view
if (this.selectedIndex >= 0) { if (this.selectedIndex >= 0) {
const selectedItem = container.querySelector( const selectedItem = container.children[this.selectedIndex];
`.result-item[data-index="${this.selectedIndex}"]`,
);
if (selectedItem) { if (selectedItem) {
selectedItem.scrollIntoView({ block: "nearest" }); selectedItem.scrollIntoView({ block: "nearest" });
} }
@ -816,27 +354,22 @@ class NavigationSearch extends HTMLElement {
} }
moveSelection(direction) { moveSelection(direction) {
const matches = this.renderedMatches || this.matches;
const newIndex = this.selectedIndex + direction; const newIndex = this.selectedIndex + direction;
if (newIndex >= 0 && newIndex < matches.length) { if (newIndex >= 0 && newIndex < this.matches.length) {
this.selectedIndex = newIndex; this.selectedIndex = newIndex;
this.renderResults(); this.renderResults();
} }
} }
selectCurrentItem() { selectCurrentItem() {
const matches = this.renderedMatches || this.matches; if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
this.selectItem(this.selectedIndex); this.selectItem(this.selectedIndex);
} }
} }
selectItem(index) { selectItem(index) {
const matches = this.renderedMatches || this.matches; const match = this.matches[index];
const match = matches[index];
if (match) { if (match) {
this.saveRecentItem(match);
// Dispatch custom event // Dispatch custom event
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("select", { new CustomEvent("select", {
@ -849,59 +382,32 @@ class NavigationSearch extends HTMLElement {
// Navigate to URL // Navigate to URL
window.location.href = match.url; window.location.href = match.url;
this.closeMenu({ restoreFocus: false }); this.closeMenu();
} }
} }
openMenu(trigger) { openMenu() {
const dialog = this.shadowRoot.querySelector("dialog"); const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input"); const input = this.shadowRoot.querySelector(".search-input");
this.restoreFocusTarget = this.focusRestoreTarget(trigger); dialog.showModal();
this.shouldRestoreFocus = true;
if (!dialog.open) {
dialog.showModal();
}
this.setNavigationTriggersExpanded(true);
input.value = ""; input.value = "";
input.focus(); input.focus();
// Reset state, then populate the default jump list. // Reset state - start with no items shown
this.matches = []; this.matches = [];
this.selectedIndex = -1; this.selectedIndex = -1;
this.renderResults(); this.renderResults();
this.setStatus("");
} }
closeMenu(options = {}) { closeMenu() {
const dialog = this.shadowRoot.querySelector("dialog"); const dialog = this.shadowRoot.querySelector("dialog");
this.shouldRestoreFocus = options.restoreFocus !== false; dialog.close();
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) { escapeHtml(text) {
const div = document.createElement("div"); const div = document.createElement("div");
div.textContent = text == null ? "" : text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
} }

View file

@ -1,6 +1,13 @@
var DROPDOWN_HTML = `<div class="dropdown-menu"> var DROPDOWN_HTML = `<div class="dropdown-menu">
<div class="hook"></div> <div class="hook"></div>
<ul class="dropdown-actions"></ul> <ul>
<li><a class="dropdown-sort-asc" href="#">Sort ascending</a></li>
<li><a class="dropdown-sort-desc" href="#">Sort descending</a></li>
<li><a class="dropdown-facet" href="#">Facet by this</a></li>
<li><a class="dropdown-hide-column" href="#">Hide this column</a></li>
<li><a class="dropdown-show-all-columns" href="#">Show all columns</a></li>
<li><a class="dropdown-not-blank" href="#">Show not-blank rows</a></li>
</ul>
<p class="dropdown-column-type"></p> <p class="dropdown-column-type"></p>
<p class="dropdown-column-description"></p> <p class="dropdown-column-description"></p>
</div>`; </div>`;
@ -10,509 +17,54 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>`; </svg>`;
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
var setColumnTypeDialogState = null;
function getParams() {
return new URLSearchParams(location.search);
}
function paramsToUrl(params) {
var s = params.toString();
return s ? "?" + s : location.pathname;
}
function sortDescUrl(column) {
var params = getParams();
params.set("_sort_desc", column);
params.delete("_sort");
params.delete("_next");
return paramsToUrl(params);
}
function sortAscUrl(column) {
var params = getParams();
params.set("_sort", column);
params.delete("_sort_desc");
params.delete("_next");
return paramsToUrl(params);
}
function facetUrl(column) {
var params = getParams();
params.append("_facet", column);
return paramsToUrl(params);
}
function hideColumnUrl(column) {
var params = getParams();
params.append("_nocol", column);
return paramsToUrl(params);
}
function showAllColumnsUrl() {
var params = getParams();
params.delete("_nocol");
params.delete("_col");
return paramsToUrl(params);
}
function notBlankUrl(column) {
var params = getParams();
params.set(`${column}__notblank`, "1");
return paramsToUrl(params);
}
function getDisplayedFacets() {
return Array.from(document.querySelectorAll(".facet-info")).map(
(el) => el.dataset.column,
);
}
function getColumnClassName(th) {
return Array.from(th.classList).find((className) =>
className.startsWith("col-"),
);
}
function getColumnCells(th) {
var table = th.closest("table");
var columnClassName = getColumnClassName(th);
if (!table || !columnClassName) {
return [];
}
return Array.from(table.querySelectorAll("td." + columnClassName));
}
function getColumnMeta(th) {
return {
columnName: th.dataset.column,
columnNotNull: th.dataset.columnNotNull === "1",
columnType: th.dataset.columnType,
isPk: th.dataset.isPk === "1",
};
}
function getColumnTypeText(th) {
var columnType = th.dataset.columnType;
if (!columnType) {
return null;
}
var notNull = th.dataset.columnNotNull === "1" ? " NOT NULL" : "";
return `Type: ${columnType.toUpperCase()}${notNull}`;
}
function getSetColumnTypeData() {
return window._setColumnTypeData || null;
}
function getSetColumnTypeConfig(column) {
var data = getSetColumnTypeData();
if (!data || !data.columns) {
return null;
}
return data.columns[column] || null;
}
function canSetColumnType() {
return !!(getSetColumnTypeData() && window.HTMLDialogElement && window.fetch);
}
function setColumnTypeActionLabel(column) {
var columnConfig = getSetColumnTypeConfig(column);
if (!columnConfig) {
return null;
}
return columnConfig.current
? `Custom type: ${columnConfig.current.type}`
: "Set custom type";
}
function createSetColumnTypeOption(value, name, description, checked) {
var label = document.createElement("label");
label.className = "set-column-type-option";
var input = document.createElement("input");
input.type = "radio";
input.name = "set-column-type-choice";
input.value = value;
input.checked = checked;
var content = document.createElement("span");
content.className = "set-column-type-option-content";
var title = document.createElement("span");
title.className = "set-column-type-option-name";
title.textContent = name;
var detail = document.createElement("span");
detail.className = "set-column-type-option-description";
detail.textContent = description;
content.appendChild(title);
content.appendChild(detail);
label.appendChild(input);
label.appendChild(content);
return label;
}
function setSetColumnTypeDialogBusy(state, isBusy) {
state.isBusy = isBusy;
state.saveButton.disabled = isBusy;
state.cancelButton.disabled = isBusy;
Array.from(
state.optionsWrap.querySelectorAll('input[name="set-column-type-choice"]'),
).forEach(function (input) {
input.disabled = isBusy;
});
state.saveButton.textContent = isBusy ? "Saving..." : "Save";
}
function clearSetColumnTypeDialogError(state) {
state.error.hidden = true;
state.error.textContent = "";
}
function showSetColumnTypeDialogError(state, message) {
state.error.hidden = false;
state.error.textContent = message;
}
function ensureSetColumnTypeDialog() {
if (setColumnTypeDialogState) {
return setColumnTypeDialogState;
}
if (!window.HTMLDialogElement) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
dialog.className = "set-column-type-dialog";
dialog.setAttribute("aria-labelledby", "set-column-type-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="set-column-type-title">Set custom type</span>
<span class="modal-meta"></span>
</div>
<p class="set-column-type-status"></p>
<p class="set-column-type-error" hidden></p>
<div class="set-column-type-options"></div>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
</div>
`;
document.body.appendChild(dialog);
setColumnTypeDialogState = {
dialog: dialog,
meta: dialog.querySelector(".modal-meta"),
status: dialog.querySelector(".set-column-type-status"),
error: dialog.querySelector(".set-column-type-error"),
optionsWrap: dialog.querySelector(".set-column-type-options"),
footerInfo: dialog.querySelector(".footer-info"),
cancelButton: dialog.querySelector(".set-column-type-cancel"),
saveButton: dialog.querySelector(".set-column-type-save"),
currentColumn: null,
currentConfig: null,
isBusy: false,
};
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
if (!setColumnTypeDialogState.isBusy) {
dialog.close();
}
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog && !setColumnTypeDialogState.isBusy) {
dialog.close();
}
});
dialog.addEventListener("cancel", function (ev) {
if (setColumnTypeDialogState.isBusy) {
ev.preventDefault();
}
});
dialog.addEventListener("close", function () {
clearSetColumnTypeDialogError(setColumnTypeDialogState);
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
});
setColumnTypeDialogState.saveButton.addEventListener("click", async function () {
var state = setColumnTypeDialogState;
var selected = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
var selectedType = selected ? selected.value : "";
var currentType = state.currentConfig.current
? state.currentConfig.current.type
: "";
if (selectedType === currentType) {
state.dialog.close();
return;
}
clearSetColumnTypeDialogError(state);
setSetColumnTypeDialogBusy(state, true);
var payload = {
column: state.currentColumn,
column_type: selectedType ? { type: selectedType } : null,
};
try {
var response = await fetch(getSetColumnTypeData().path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
var data = await response.json();
if (!response.ok || data.ok === false) {
var message = (data.errors || ["Request failed"]).join(" ");
throw new Error(message);
}
location.reload();
} catch (error) {
setSetColumnTypeDialogBusy(state, false);
showSetColumnTypeDialogError(state, error.message || "Request failed");
}
});
return setColumnTypeDialogState;
}
function openSetColumnTypeDialog(th) {
var column = th.dataset.column;
var columnConfig = getSetColumnTypeConfig(column);
if (!columnConfig) {
return;
}
var state = ensureSetColumnTypeDialog();
if (!state) {
return;
}
clearSetColumnTypeDialogError(state);
setSetColumnTypeDialogBusy(state, false);
state.currentColumn = column;
state.currentConfig = columnConfig;
state.status.textContent = `Column: ${column}`;
state.meta.textContent = getColumnTypeText(th) || "Type unavailable";
state.footerInfo.textContent = columnConfig.current
? `Current custom type: ${columnConfig.current.type}`
: "No custom type set.";
state.optionsWrap.innerHTML = "";
var currentType = columnConfig.current ? columnConfig.current.type : "";
state.optionsWrap.appendChild(
createSetColumnTypeOption(
"",
"No custom type",
"Use standard Datasette rendering without a custom type.",
currentType === "",
),
);
columnConfig.options.forEach(function (option) {
state.optionsWrap.appendChild(
createSetColumnTypeOption(
option.name,
option.name,
option.description,
option.name === currentType,
),
);
});
if (!columnConfig.options.length) {
var emptyState = document.createElement("p");
emptyState.className = "set-column-type-empty";
emptyState.textContent =
"No registered custom types are compatible with this SQLite type.";
state.optionsWrap.appendChild(emptyState);
}
if (!state.dialog.open) {
state.dialog.showModal();
}
var selectedOption = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
if (selectedOption) {
selectedOption.focus();
} else {
state.saveButton.focus();
}
}
function canChooseColumns() {
return !!(
document.querySelector("column-chooser") && window._columnChooserData
);
}
function shouldShowShowAllColumns() {
var params = getParams();
return params.getAll("_nocol").length || params.getAll("_col").length;
}
function hasMultipleVisibleColumns(manager) {
return (
Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter(
(th) => th.dataset.column && th.dataset.isLinkColumn !== "1",
).length > 1
);
}
function buildColumnActionItems(manager, th, options) {
options = options || {};
var params = getParams();
var column = th.dataset.column;
var columnActions = [];
var isSortable = !!th.querySelector("a");
var isFirstColumn = th.parentElement.querySelector("th:first-of-type") === th;
var isSinglePk =
th.dataset.isPk === "1" &&
document.querySelectorAll('th[data-is-pk="1"]').length === 1;
var hasBlankValues = getColumnCells(th).some(
(el) => el.innerText.trim() === "",
);
if (isSortable && params.get("_sort") !== column) {
columnActions.push({
label: "Sort ascending",
href: sortAscUrl(column),
});
}
if (isSortable && params.get("_sort_desc") !== column) {
columnActions.push({
label: "Sort descending",
href: sortDescUrl(column),
});
}
if (
DATASETTE_ALLOW_FACET &&
!isFirstColumn &&
!getDisplayedFacets().includes(column) &&
!isSinglePk
) {
columnActions.push({
label: "Facet by this",
href: facetUrl(column),
});
}
if (options.includeChooseColumns && canChooseColumns()) {
columnActions.push({
label: "Choose columns",
href: "#",
onClick:
options.onChooseColumns ||
function (ev) {
ev.preventDefault();
openColumnChooser();
},
});
}
if (canSetColumnType() && getSetColumnTypeConfig(column)) {
columnActions.push({
label: setColumnTypeActionLabel(column),
href: "#",
onClick:
options.onSetColumnType ||
function (ev) {
ev.preventDefault();
window.setTimeout(function () {
openSetColumnTypeDialog(th);
}, 0);
},
});
}
if (th.dataset.isPk !== "1" && hasMultipleVisibleColumns(manager)) {
columnActions.push({
label: "Hide this column",
href: hideColumnUrl(column),
});
}
if (options.includeShowAllColumns && shouldShowShowAllColumns()) {
columnActions.push({
label: "Show all columns",
href: showAllColumnsUrl(),
});
}
if (params.get(`${column}__notblank`) !== "1" && hasBlankValues) {
columnActions.push({
label: "Show not-blank rows",
href: notBlankUrl(column),
});
}
return columnActions.concat(manager.makeColumnActions(getColumnMeta(th)));
}
function buildColumnActionState(manager, th, options) {
return {
column: th.dataset.column,
columnDescription: th.dataset.columnDescription || null,
columnMeta: getColumnMeta(th),
columnTypeText: getColumnTypeText(th),
actionItems: buildColumnActionItems(manager, th, options),
};
}
function initializeColumnActions(manager) {
manager.columnActions = {
buildColumnActionState: function (th, options) {
return buildColumnActionState(manager, th, options);
},
buildColumnActionItems: function (th, options) {
return buildColumnActionItems(manager, th, options);
},
canChooseColumns: canChooseColumns,
facetUrl: facetUrl,
getColumnMeta: getColumnMeta,
getColumnTypeText: getColumnTypeText,
hideColumnUrl: hideColumnUrl,
notBlankUrl: notBlankUrl,
shouldShowShowAllColumns: shouldShowShowAllColumns,
showAllColumnsUrl: showAllColumnsUrl,
sortAscUrl: sortAscUrl,
sortDescUrl: sortDescUrl,
};
}
function renderActionLink(itemConfig) {
var newLink = document.createElement("a");
newLink.textContent = itemConfig.label;
newLink.href = itemConfig.href || "#";
if (itemConfig.onClick) {
newLink.addEventListener("click", itemConfig.onClick);
}
return newLink;
}
/** Main initialization function for Datasette Table interactions */ /** Main initialization function for Datasette Table interactions */
const initDatasetteTable = function (manager) { const initDatasetteTable = function (manager) {
// Feature detection // Feature detection
if (!window.URLSearchParams) { if (!window.URLSearchParams) {
return; return;
} }
function getParams() {
return new URLSearchParams(location.search);
}
function paramsToUrl(params) {
var s = params.toString();
return s ? "?" + s : location.pathname;
}
function sortDescUrl(column) {
var params = getParams();
params.set("_sort_desc", column);
params.delete("_sort");
params.delete("_next");
return paramsToUrl(params);
}
function sortAscUrl(column) {
var params = getParams();
params.set("_sort", column);
params.delete("_sort_desc");
params.delete("_next");
return paramsToUrl(params);
}
function facetUrl(column) {
var params = getParams();
params.append("_facet", column);
return paramsToUrl(params);
}
function hideColumnUrl(column) {
var params = getParams();
params.append("_nocol", column);
return paramsToUrl(params);
}
function showAllColumnsUrl() {
var params = getParams();
params.delete("_nocol");
params.delete("_col");
return paramsToUrl(params);
}
function notBlankUrl(column) {
var params = getParams();
params.set(`${column}__notblank`, "1");
return paramsToUrl(params);
}
function closeMenu() { function closeMenu() {
menu.style.display = "none"; menu.style.display = "none";
menu.classList.remove("anim-scale-in"); menu.classList.remove("anim-scale-in");
@ -544,41 +96,87 @@ const initDatasetteTable = function (manager) {
var rect = th.getBoundingClientRect(); var rect = th.getBoundingClientRect();
var menuTop = rect.bottom + window.scrollY; var menuTop = rect.bottom + window.scrollY;
var menuLeft = rect.left + window.scrollX; var menuLeft = rect.left + window.scrollX;
var actionState = manager.columnActions.buildColumnActionState(th, { var column = th.getAttribute("data-column");
includeChooseColumns: true, var params = getParams();
includeShowAllColumns: true, var sort = menu.querySelector("a.dropdown-sort-asc");
onChooseColumns: function (ev) { var sortDesc = menu.querySelector("a.dropdown-sort-desc");
ev.preventDefault(); var facetItem = menu.querySelector("a.dropdown-facet");
closeMenu(); var notBlank = menu.querySelector("a.dropdown-not-blank");
openColumnChooser(); var hideColumn = menu.querySelector("a.dropdown-hide-column");
}, var showAllColumns = menu.querySelector("a.dropdown-show-all-columns");
onSetColumnType: function (ev) { if (params.get("_sort") == column) {
ev.preventDefault(); sort.parentNode.style.display = "none";
closeMenu(); } else {
window.setTimeout(function () { sort.parentNode.style.display = "block";
openSetColumnTypeDialog(th); sort.setAttribute("href", sortAscUrl(column));
}, 0); }
}, if (params.get("_sort_desc") == column) {
}); sortDesc.parentNode.style.display = "none";
var menuList = menu.querySelector("ul.dropdown-actions"); } else {
menuList.innerHTML = ""; sortDesc.parentNode.style.display = "block";
actionState.actionItems.forEach((itemConfig) => { sortDesc.setAttribute("href", sortDescUrl(column));
var menuItem = document.createElement("li"); }
menuItem.appendChild(renderActionLink(itemConfig)); /* Show hide columns options */
menuList.appendChild(menuItem); if (params.get("_nocol") || params.get("_col")) {
}); showAllColumns.parentNode.style.display = "block";
showAllColumns.setAttribute("href", showAllColumnsUrl());
} else {
showAllColumns.parentNode.style.display = "none";
}
if (th.getAttribute("data-is-pk") != "1") {
hideColumn.parentNode.style.display = "block";
hideColumn.setAttribute("href", hideColumnUrl(column));
} else {
hideColumn.parentNode.style.display = "none";
}
/* Only show "Facet by this" if it's not the first column, not selected,
not a single PK and the Datasette allow_facet setting is True */
var displayedFacets = Array.from(
document.querySelectorAll(".facet-info"),
).map((el) => el.dataset.column);
var isFirstColumn =
th.parentElement.querySelector("th:first-of-type") == th;
var isSinglePk =
th.getAttribute("data-is-pk") == "1" &&
document.querySelectorAll('th[data-is-pk="1"]').length == 1;
if (
!DATASETTE_ALLOW_FACET ||
isFirstColumn ||
displayedFacets.includes(column) ||
isSinglePk
) {
facetItem.parentNode.style.display = "none";
} else {
facetItem.parentNode.style.display = "block";
facetItem.setAttribute("href", facetUrl(column));
}
/* Show notBlank option if not selected AND at least one visible blank value */
var tdsForThisColumn = Array.from(
th.closest("table").querySelectorAll("td." + th.className),
);
if (
params.get(`${column}__notblank`) != "1" &&
tdsForThisColumn.filter((el) => el.innerText.trim() == "").length
) {
notBlank.parentNode.style.display = "block";
notBlank.setAttribute("href", notBlankUrl(column));
} else {
notBlank.parentNode.style.display = "none";
}
var columnTypeP = menu.querySelector(".dropdown-column-type"); var columnTypeP = menu.querySelector(".dropdown-column-type");
if (actionState.columnTypeText) { var columnType = th.dataset.columnType;
var notNull = th.dataset.columnNotNull == 1 ? " NOT NULL" : "";
if (columnType) {
columnTypeP.style.display = "block"; columnTypeP.style.display = "block";
columnTypeP.innerText = actionState.columnTypeText; columnTypeP.innerText = `Type: ${columnType.toUpperCase()}${notNull}`;
} else { } else {
columnTypeP.style.display = "none"; columnTypeP.style.display = "none";
} }
var columnDescriptionP = menu.querySelector(".dropdown-column-description"); var columnDescriptionP = menu.querySelector(".dropdown-column-description");
if (actionState.columnDescription) { if (th.dataset.columnDescription) {
columnDescriptionP.innerText = actionState.columnDescription; columnDescriptionP.innerText = th.dataset.columnDescription;
columnDescriptionP.style.display = "block"; columnDescriptionP.style.display = "block";
} else { } else {
columnDescriptionP.style.display = "none"; columnDescriptionP.style.display = "none";
@ -589,6 +187,39 @@ const initDatasetteTable = function (manager) {
menu.style.display = "block"; menu.style.display = "block";
menu.classList.add("anim-scale-in"); menu.classList.add("anim-scale-in");
// Custom menu items on each render
// Plugin hook: allow adding JS-based additional menu items
const columnActionsPayload = {
columnName: th.dataset.column,
columnNotNull: th.dataset.columnNotNull === "1",
columnType: th.dataset.columnType,
isPk: th.dataset.isPk === "1",
};
const columnItemConfigs = manager.makeColumnActions(columnActionsPayload);
const menuList = menu.querySelector("ul");
columnItemConfigs.forEach((itemConfig) => {
// Remove items from previous render. We assume entries have unique labels.
const existingItems = menuList.querySelectorAll(`li`);
Array.from(existingItems)
.filter((item) => item.innerText === itemConfig.label)
.forEach((node) => {
node.remove();
});
const newLink = document.createElement("a");
newLink.textContent = itemConfig.label;
newLink.href = itemConfig.href ?? "#";
if (itemConfig.onClick) {
newLink.onclick = itemConfig.onClick;
}
// Attach new elements to DOM
const menuItem = document.createElement("li");
menuItem.appendChild(newLink);
menuList.appendChild(menuItem);
});
// Measure width of menu and adjust position if too far right // Measure width of menu and adjust position if too far right
const menuWidth = menu.offsetWidth; const menuWidth = menu.offsetWidth;
const windowWidth = window.innerWidth; const windowWidth = window.innerWidth;
@ -699,55 +330,10 @@ function initAutocompleteForFilterValues(manager) {
}); });
} }
/** Open the column-chooser web component */
function openColumnChooser() {
var chooser = document.querySelector("column-chooser");
var data = window._columnChooserData;
if (!chooser || !data) return;
var nonPkColumns = data.allColumns.filter(function (col) {
return data.primaryKeys.indexOf(col) === -1;
});
var selected = data.selectedColumns.filter(function (col) {
return data.primaryKeys.indexOf(col) === -1;
});
chooser.open({
columns: nonPkColumns,
selected: selected,
onApply: function (cols) {
var params = new URLSearchParams(location.search);
params.delete("_col");
params.delete("_nocol");
params.delete("_next");
if (cols.length === nonPkColumns.length) {
// Check if order matches original - if so, no params needed
var orderMatches = cols.every(function (col, i) {
return col === nonPkColumns[i];
});
if (!orderMatches) {
cols.forEach(function (col) {
params.append("_col", col);
});
}
} else {
cols.forEach(function (col) {
params.append("_col", col);
});
}
var qs = params.toString();
location.href = qs ? "?" + qs : location.pathname;
},
});
}
// Ensures Table UI is initialized only after the Manager is ready. // Ensures Table UI is initialized only after the Manager is ready.
document.addEventListener("datasette_init", function (evt) { document.addEventListener("datasette_init", function (evt) {
const { detail: manager } = evt; const { detail: manager } = evt;
initializeColumnActions(manager);
// Main table // Main table
initDatasetteTable(manager); initDatasetteTable(manager);

View file

@ -1,581 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Iterable
from .utils import tilde_encode, urlsafe_components
UNCHANGED = object()
QUERY_OPTION_FIELDS = (
"hide_sql",
"fragment",
"on_success_message",
"on_success_message_sql",
"on_success_redirect",
"on_error_message",
"on_error_redirect",
)
@dataclass
class StoredQuery:
database: str
name: str
sql: str
title: str | None
description: str | None
description_html: str | None
hide_sql: bool
fragment: str | None
parameters: list[str]
is_write: bool
is_private: bool
is_trusted: bool
source: str
owner_id: str | None
on_success_message: str | None
on_success_message_sql: str | None
on_success_redirect: str | None
on_error_message: str | None
on_error_redirect: str | None
private: bool | None = None
@dataclass
class StoredQueryPage:
queries: list[StoredQuery]
next: str | None
has_more: bool
limit: int
def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
data = {
"database": query.database,
"name": query.name,
"sql": query.sql,
"title": query.title,
"description": query.description,
"description_html": query.description_html,
"hide_sql": query.hide_sql,
"fragment": query.fragment,
"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,
)

View file

@ -1,7 +1,7 @@
{% if action_links %} {% if action_links %}
<div class="page-action-menu"> <div class="page-action-menu">
<details class="actions-menu-links details-menu"> <details class="actions-menu-links details-menu">
<summary aria-haspopup="menu" aria-expanded="false"> <summary>
<div class="icon-text"> <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"> <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> <title id="actions-menu-links-title">{{ action_title }}</title>
@ -13,9 +13,9 @@
</summary> </summary>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div class="hook"></div> <div class="hook"></div>
<ul role="menu"> <ul>
{% for link in action_links %} {% for link in action_links %}
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }} <li><a href="{{ link.href }}">{{ link.label }}
{% if link.description %} {% if link.description %}
<p class="dropdown-description">{{ link.description }}</p> <p class="dropdown-description">{{ link.description }}</p>
{% endif %}</a> {% endif %}</a>

View file

@ -13,50 +13,4 @@ document.body.addEventListener('click', (ev) => {
(details) => details.open && details != detailsClickedWithin (details) => details.open && details != detailsClickedWithin
).forEach(details => details.open = false); ).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> </script>

View file

@ -1,111 +0,0 @@
<script>
window.datasetteSqlAnalysis = (() => {
if (
window.datasetteSqlAnalysis &&
window.datasetteSqlAnalysis.renderAnalysis
) {
return window.datasetteSqlAnalysis;
}
function appendCodeCell(row, value, emptyText) {
const cell = document.createElement("td");
if (value) {
const code = document.createElement("code");
code.textContent = value;
cell.appendChild(code);
} else if (emptyText) {
appendNotApplicable(cell);
}
row.appendChild(cell);
}
function appendNotApplicable(cell) {
const notApplicable = document.createElement("span");
notApplicable.className = "execute-write-analysis-na";
notApplicable.textContent = "n/a";
cell.appendChild(notApplicable);
}
function renderAnalysis(section, data) {
if (!section) {
return;
}
section.replaceChildren();
if (data.has_sql === false) {
section.hidden = true;
return;
}
section.hidden = false;
const heading = document.createElement("h2");
heading.textContent = "Query operations";
section.appendChild(heading);
if (data.analysis_error) {
const error = document.createElement("p");
error.className = "message-error";
error.textContent = data.analysis_error;
section.appendChild(error);
return;
}
const rows = data.analysis_rows || [];
if (!rows.length) {
const empty = document.createElement("p");
empty.textContent =
"Analysis will show each affected table and required permission.";
section.appendChild(empty);
return;
}
const wrapper = document.createElement("div");
wrapper.className = "table-wrapper";
const table = document.createElement("table");
table.className = "execute-write-analysis";
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
[
"Operation",
"Database",
"Table",
"Required permission",
"Allowed",
].forEach((label) => {
const th = document.createElement("th");
th.scope = "col";
th.textContent = label;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
rows.forEach((analysisRow) => {
const row = document.createElement("tr");
appendCodeCell(row, analysisRow.operation);
appendCodeCell(row, analysisRow.database);
appendCodeCell(row, analysisRow.table);
appendCodeCell(row, analysisRow.required_permission, "n/a");
const allowedCell = document.createElement("td");
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
const allowed = document.createElement("span");
allowed.className = analysisRow.allowed
? "execute-write-analysis-allowed"
: "execute-write-analysis-denied";
allowed.textContent = analysisRow.allowed ? "yes" : "no";
allowedCell.appendChild(allowed);
} else {
appendNotApplicable(allowedCell);
}
row.appendChild(allowedCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
wrapper.appendChild(table);
section.appendChild(wrapper);
}
return { renderAnalysis };
})();
</script>

View file

@ -1,41 +0,0 @@
<style>
.execute-write-analysis {
border-collapse: collapse;
font-size: 0.9rem;
margin: 0.25rem 0 1rem;
min-width: 44rem;
}
.execute-write-analysis th,
.execute-write-analysis td {
border-bottom: 1px solid #d7dde5;
padding: 0.45rem 0.7rem;
text-align: left;
vertical-align: top;
}
.execute-write-analysis th {
background-color: #edf6fb;
border-top: 1px solid #d7dde5;
color: #39445a;
font-weight: 700;
}
.execute-write-analysis tbody tr:nth-child(even) {
background-color: rgba(39, 104, 144, 0.05);
}
.execute-write-analysis code {
background: transparent;
font-size: 0.9em;
white-space: nowrap;
}
.execute-write-analysis-allowed {
color: #267a3e;
font-weight: 700;
}
.execute-write-analysis-denied {
color: #b00020;
font-weight: 700;
}
.execute-write-analysis-na {
color: #687386;
font-style: italic;
}
</style>

View file

@ -1,20 +0,0 @@
{% if display_rows %}
<div class="table-wrapper"><table class="rows-and-columns">
<thead>
<tr>
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for column, td in zip(columns, row) %}
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% elif show_zero_results %}
<p class="zero-results">0 results</p>
{% endif %}

View file

@ -1,293 +0,0 @@
<script>
window.datasetteSqlParameters = (() => {
if (
window.datasetteSqlParameters &&
window.datasetteSqlParameters.setupSqlParameterRefresh
) {
return window.datasetteSqlParameters;
}
function currentSql(form) {
if (window.editor) {
return window.editor.state.doc.toString();
}
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
return sqlInput ? sqlInput.value : "";
}
function controlState(control) {
return {
value: control.value,
expanded: control.tagName.toLowerCase() === "textarea",
};
}
function syncParameterState(manager) {
manager.parameterState = new Map();
manager.section
.querySelectorAll("[data-parameter-control]")
.forEach((control) => {
manager.parameterState.set(control.name, controlState(control));
});
}
function createControl(parameter, id, state) {
const control = document.createElement(state.expanded ? "textarea" : "input");
control.id = id;
control.name = parameter;
control.value = state.value;
control.setAttribute("data-parameter-control", "");
if (state.expanded) {
control.rows = 5;
} else {
control.type = "text";
}
return control;
}
function replaceParameterControl(
manager,
control,
button,
expand,
value,
selectionStart
) {
const replacement = createControl(control.name, control.id, {
value: value === undefined ? control.value : value,
expanded: expand,
});
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(replacement.name, 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);
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.name, 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,
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>

View file

@ -1,58 +0,0 @@
<style>
form.sql .sql-editor {
max-width: 52rem;
}
form.sql .sql-editor textarea#sql-editor {
width: 100%;
}
form.sql .sql-parameters-section {
max-width: 52rem;
}
form.sql .sql-parameter-row {
align-items: start;
column-gap: 0.6rem;
display: grid;
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
margin: 0 0 0.65rem;
max-width: 52rem;
}
form.sql .sql-parameter-row label {
overflow-wrap: anywhere;
padding-top: 0.55rem;
width: auto;
}
form.sql .sql-parameter-row input[data-parameter-control],
form.sql .sql-parameter-row textarea[data-parameter-control] {
box-sizing: border-box;
width: 100%;
}
form.sql .sql-parameter-row textarea[data-parameter-control] {
border: 1px solid #ccc;
border-radius: 3px;
display: block;
font-family: Helvetica, sans-serif;
font-size: 1em;
min-height: 7rem;
padding: 9px 4px;
}
form.sql.core button.sql-parameter-toggle[type=button] {
font-size: 0.72rem;
height: 1.8rem;
line-height: 1;
margin: 0.25rem 0 0;
padding: 0.25rem 0.45rem;
}
@media (max-width: 480px) {
form.sql .sql-parameter-row {
grid-template-columns: 1fr;
row-gap: 0.25rem;
}
form.sql .sql-parameter-row label {
padding-top: 0;
}
form.sql.core button.sql-parameter-toggle[type=button] {
justify-self: start;
margin-top: 0;
}
}
</style>

View file

@ -1,9 +0,0 @@
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% 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="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control>{% 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>

View file

@ -1,12 +1,12 @@
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available --> <!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
<div class="above-table-panel"> </div> <div class="above-table-panel"> </div>
{% if display_columns %} {% if display_rows %}
<div class="table-wrapper"> <div class="table-wrapper">
<table class="rows-and-columns"> <table class="rows-and-columns">
<thead> <thead>
<tr> <tr>
{% for column in display_columns %} {% for column in display_columns %}
<th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}"{% if column.is_special_link_column %} data-is-link-column="1"{% endif %}> <th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}">
{% if not column.sortable %} {% if not column.sortable %}
{{ column.name }} {{ column.name }}
{% else %} {% else %}
@ -31,7 +31,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% endif %} {% else %}
{% if not display_rows %}
<p class="zero-results">0 records</p> <p class="zero-results">0 records</p>
{% endif %} {% endif %}

View file

@ -19,7 +19,7 @@
</p> </p>
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em"> <details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
<summary style="cursor: pointer;">GET</summary> <summary style="cursor: pointer;">GET</summary>
<form class="core" method="get" action="{{ urls.path('-/api') }}" id="api-explorer-get" style="margin-top: 0.7em"> <form class="core" method="get" id="api-explorer-get" style="margin-top: 0.7em">
<div> <div>
<label for="path">API path:</label> <label for="path">API path:</label>
<input type="text" id="path" name="path" style="width: 60%"> <input type="text" id="path" name="path" style="width: 60%">
@ -29,7 +29,7 @@
</details> </details>
<details style="border: 2px solid #ccc; padding: 0.5em"> <details style="border: 2px solid #ccc; padding: 0.5em">
<summary style="cursor: pointer">POST</summary> <summary style="cursor: pointer">POST</summary>
<form class="core" method="post" action="{{ urls.path('-/api') }}" id="api-explorer-post" style="margin-top: 0.7em"> <form class="core" method="post" id="api-explorer-post" style="margin-top: 0.7em">
<div> <div>
<label for="path">API path:</label> <label for="path">API path:</label>
<input type="text" id="path" name="path" style="width: 60%"> <input type="text" id="path" name="path" style="width: 60%">

View file

@ -20,7 +20,7 @@
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
<div class="not-footer"> <div class="not-footer">
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %} <header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
{% set links = menu_links() %} {% set links = menu_links() %}{% if links or show_logout %}
<details class="nav-menu details-menu"> <details class="nav-menu details-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img" <summary><svg aria-labelledby="nav-menu-svg-title" role="img"
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
@ -29,18 +29,20 @@
<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> <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> </svg></summary>
<div class="nav-menu-inner"> <div class="nav-menu-inner">
{% if links %}
<ul> <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 %} {% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li> <li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %}
{% if show_logout %} {% if show_logout %}
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post"> <form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<button class="button-as-link">Log out</button> <button class="button-as-link">Log out</button>
</form>{% endif %} </form>{% endif %}
</div> </div>
</details> </details>{% endif %}
{% if actor %} {% if actor %}
<div class="actor"> <div class="actor">
<strong>{{ display_actor(actor) }}</strong> <strong>{{ display_actor(actor) }}</strong>
@ -71,6 +73,6 @@
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %} {% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}" defer></script> <script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search> <navigation-search url="/-/tables"></navigation-search>
</body> </body>
</html> </html>

View file

@ -50,6 +50,7 @@
</select> </select>
</div> </div>
<input type="text" name="expire_duration" style="width: 10%"> <input type="text" name="expire_duration" style="width: 10%">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Create token"> <input type="submit" value="Create token">
<details style="margin-top: 1em" id="restrict-permissions"> <details style="margin-top: 1em" id="restrict-permissions">

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}CSRF check failed{% endblock %} {% block title %}CSRF check failed){% endblock %}
{% block content %} {% block content %}
<h1>Form origin check failed</h1> <h1>Form origin check failed</h1>
@ -7,7 +7,7 @@
<details><summary>Technical details</summary> <details><summary>Technical details</summary>
<p>Developers: consult Datasette's <a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection">CSRF protection documentation</a>.</p> <p>Developers: consult Datasette's <a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection">CSRF protection documentation</a>.</p>
<p>Reason: {{ reason }}</p> <p>Error code is {{ message_name }}.</p>
</details> </details>
{% endblock %} {% endblock %}

View file

@ -5,7 +5,6 @@
{% block extra_head %} {% block extra_head %}
{{- super() -}} {{- super() -}}
{% include "_codemirror.html" %} {% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %} {% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
@ -26,13 +25,9 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if allow_execute_sql %} {% if allow_execute_sql %}
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters"> <form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
<h3>Custom SQL query</h3> <h3>Custom SQL query</h3>
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p> <p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
{% set parameter_names = [] %}
{% set parameter_values = {} %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p> <p>
<button id="sql-format" type="button" hidden>Format SQL</button> <button id="sql-format" type="button" hidden>Format SQL</button>
<input type="submit" value="Run SQL"> <input type="submit" value="Run SQL">
@ -58,9 +53,6 @@
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li> <li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if queries_more %}
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
{% endif %}
{% endif %} {% endif %}
{% if tables %} {% if tables %}
@ -95,11 +87,5 @@
{% endif %} {% endif %}
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %} {% endblock %}

View file

@ -31,7 +31,7 @@
<td><strong>{{ action.name }}</strong></td> <td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td> <td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
<td>{{ action.description or "" }}</td> <td>{{ action.description or "" }}</td>
<td>{% if action.resource_class %}<code>{{ action.resource_class }}</code>{% endif %}</td> <td><code>{{ action.resource_class }}</code></td>
<td>{% if action.takes_parent %}✓{% endif %}</td> <td>{% if action.takes_parent %}✓{% endif %}</td>
<td>{% if action.takes_child %}✓{% endif %}</td> <td>{% if action.takes_child %}✓{% endif %}</td>
<td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td> <td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td>

View file

@ -52,6 +52,7 @@ textarea {
<div class="permission-form"> <div class="permission-form">
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post"> <form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<div class="two-col"> <div class="two-col">
<div class="form-section"> <div class="form-section">
<label>Actor</label> <label>Actor</label>

View file

@ -1,299 +0,0 @@
{% extends "base.html" %}
{% block title %}Write to this database{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
<style>
.execute-write-template-menu {
margin: 0.9rem 0 0.8rem;
max-width: 52rem;
}
.execute-write-template-menu summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 0.35rem;
}
.execute-write-template-controls {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.4rem 0 0.7rem;
}
.execute-write-template-menu .execute-write-template-controls label {
margin-right: 0.25rem;
width: auto;
}
.execute-write-template-controls select,
.execute-write-template-controls button[type=button] {
box-sizing: border-box;
font-size: 0.78rem;
height: 2rem;
line-height: 1.1;
padding: 0.35rem 0.55rem;
}
.execute-write-template-controls select {
background-color: #fff;
border: 1px solid #777;
border-radius: 0.25rem;
min-width: 13rem;
}
.execute-write-submit-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.75rem;
}
.execute-write-submit-row [hidden] {
display: none;
}
form.sql.core input[data-execute-write-submit]:disabled {
background: #d0d7de;
border-color: #b6c0cc;
color: #5f6975;
cursor: not-allowed;
opacity: 1;
}
.execute-write-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 %}

View file

@ -10,6 +10,7 @@
<form class="core" action="{{ urls.logout() }}" method="post"> <form class="core" action="{{ urls.logout() }}" method="post">
<div> <div>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Log out"> <input type="submit" value="Log out">
</div> </div>
</form> </form>

View file

@ -19,6 +19,7 @@
<option>all</option> <option>all</option>
</select> </select>
</div> </div>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<input type="submit" value="Add message"> <input type="submit" value="Add message">
</div> </div>
</form> </form>

View file

@ -11,7 +11,7 @@
<header class="hd"><nav> <header class="hd"><nav>
<p class="crumbs"> <p class="crumbs">
<a href="{{ base_url }}">home</a> <a href="/">home</a>
</p> </p>
<details class="nav-menu details-menu"> <details class="nav-menu details-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img" <summary><svg aria-labelledby="nav-menu-svg-title" role="img"
@ -22,11 +22,11 @@
</svg></summary> </svg></summary>
<div class="nav-menu-inner"> <div class="nav-menu-inner">
<ul> <ul>
<li><a href="{{ base_url }}-/databases">Databases</a></li> <li><a href="/-/databases">Databases</a></li>
<li><a href="{{ base_url }}-/plugins">Installed plugins</a></li> <li><a href="/-/plugins">Installed plugins</a></li>
<li><a href="{{ base_url }}-/versions">Version info</a></li> <li><a href="/-/versions">Version info</a></li>
</ul> </ul>
<form class="nav-menu-logout" action="{{ base_url }}-/logout" method="post"> <form class="nav-menu-logout" action="/-/logout" method="post">
<button class="button-as-link">Log out</button> <button class="button-as-link">Log out</button>
</form> </form>
</div> </div>
@ -48,9 +48,9 @@
<header class="hd"> <header class="hd">
<nav> <nav>
<p class="crumbs"> <p class="crumbs">
<a href="{{ base_url }}">home</a> / <a href="/">home</a> /
<a href="{{ base_url }}fixtures">fixtures</a> / <a href="/fixtures">fixtures</a> /
<a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a> <a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
</p> </p>
<div class="actor"> <div class="actor">
<strong>testuser</strong> <strong>testuser</strong>
@ -80,16 +80,16 @@
<a href="https://github.com/simonw/datasette"> <a href="https://github.com/simonw/datasette">
About Datasette</a> About Datasette</a>
</p> </p>
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="{{ base_url }}fixtures">fixtures</a></h2> <h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="/fixtures">fixtures</a></h2>
<p> <p>
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views 1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
</p> </p>
<p><a href="{{ base_url }}fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="{{ base_url }}fixtures/sortable" title="201 rows">sortable</a>, <a href="{{ base_url }}fixtures/facetable" title="15 rows">facetable</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="{{ base_url }}fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="{{ base_url }}fixtures">...</a></p> <p><a href="/fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="/fixtures/sortable" title="201 rows">sortable</a>, <a href="/fixtures/facetable" title="15 rows">facetable</a>, <a href="/fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="/fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="/fixtures">...</a></p>
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="{{ base_url }}data">data</a></h2> <h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="/data">data</a></h2>
<p> <p>
6 rows in 2 tables 6 rows in 2 tables
</p> </p>
<p><a href="{{ base_url }}data/names" title="6 rows">names</a>, <a href="{{ base_url }}data/foo">foo</a></p> <p><a href="/data/names" title="6 rows">names</a>, <a href="/data/foo">foo</a></p>
</section> </section>
<h2 class="pattern-heading">.bd for /database</h2> <h2 class="pattern-heading">.bd for /database</h2>
@ -134,7 +134,7 @@
<a href="https://github.com/simonw/datasette"> <a href="https://github.com/simonw/datasette">
About Datasette</a> About Datasette</a>
</p> </p>
<form class="sql" action="{{ base_url }}fixtures" method="get"> <form class="sql" action="/fixtures" method="get">
<h3>Custom SQL query</h3> <h3>Custom SQL query</h3>
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p> <p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
<p> <p>
@ -143,17 +143,17 @@
</p> </p>
</form> </form>
<div class="db-table"> <div class="db-table">
<h2><a href="{{ base_url }}fixtures/123_starts_with_digits">123_starts_with_digits</a></h2> <h2><a href="/fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
<p><em>content</em></p> <p><em>content</em></p>
<p>0 rows</p> <p>0 rows</p>
</div> </div>
<div class="db-table"> <div class="db-table">
<h2><a href="{{ base_url }}fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2> <h2><a href="/fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
<p><em>pk, content</em></p> <p><em>pk, content</em></p>
<p>0 rows</p> <p>0 rows</p>
</div> </div>
<div class="db-table"> <div class="db-table">
<h2><a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a></h2> <h2><a href="/fixtures/attraction_characteristic">attraction_characteristic</a></h2>
<p><em>pk, name</em></p> <p><em>pk, name</em></p>
<p>2 rows</p> <p>2 rows</p>
</div> </div>
@ -202,7 +202,7 @@
<h3>3 rows <h3>3 rows
where characteristic_id = 2 where characteristic_id = 2
</h3> </h3>
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get"> <form class="filters" action="/fixtures/roadside_attraction_characteristics" method="get">
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div> <div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
<div class="filter-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
@ -290,16 +290,16 @@
<h3>2 extra where clauses</h3> <h3>2 extra where clauses</h3>
<ul> <ul>
<li><code>planet_int=1</code> [<a href="{{ base_url }}fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li> <li><code>planet_int=1</code> [<a href="/fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
<li><code>state='CA'</code> [<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1">remove</a>]</li> <li><code>state='CA'</code> [<a href="/fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
</ul> </ul>
</div> </div>
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where &#34;characteristic_id&#34; = :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&amp;p0=2">&#x270e; <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 &#34;characteristic_id&#34; = :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&amp;p0=2">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
<p class="export-links">This data as <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">json</a>, <a href="{{ base_url }}fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&amp;_labels=on&amp;_size=max">CSV</a> (<a href="#export">advanced</a>)</p> <p class="export-links">This data as <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">json</a>, <a href="/fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&amp;_labels=on&amp;_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
<p class="suggested-facets"> <p class="suggested-facets">
Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_array=tags#facet-tags">tags</a> (array) Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created&amp;_facet=complex_array&amp;_facet_array=tags#facet-tags">tags</a> (array)
@ -311,7 +311,7 @@
<p class="facet-info-name"> <p class="facet-info-name">
<strong>tags (array)</strong> <strong>tags (array)</strong>
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created" class="cross"></a> <a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet=created" class="cross"></a>
</p> </p>
<ul> <ul>
@ -336,7 +336,7 @@
<p class="facet-info-name"> <p class="facet-info-name">
<strong>created</strong> <strong>created</strong>
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet_array=tags" class="cross"></a> <a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=city_id&amp;_facet_array=tags" class="cross"></a>
</p> </p>
<ul> <ul>
@ -361,7 +361,7 @@
<p class="facet-info-name"> <p class="facet-info-name">
<strong>city_id</strong> <strong>city_id</strong>
<a href="{{ base_url }}fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=created&amp;_facet_array=tags" class="cross"></a> <a href="/fixtures/facetable?_where=planet_int%3D1&amp;_where=state%3D%27CA%27&amp;_facet=created&amp;_facet_array=tags" class="cross"></a>
</p> </p>
<ul> <ul>
@ -387,45 +387,45 @@
Link Link
</th> </th>
<th class="col-rowid" scope="col"> <th class="col-rowid" scope="col">
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort_desc=rowid" rel="nofollow">rowid&nbsp;</a> <a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort_desc=rowid" rel="nofollow">rowid&nbsp;</a>
</th> </th>
<th class="col-attraction_id" scope="col"> <th class="col-attraction_id" scope="col">
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=attraction_id" rel="nofollow">attraction_id</a> <a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=attraction_id" rel="nofollow">attraction_id</a>
</th> </th>
<th class="col-characteristic_id" scope="col"> <th class="col-characteristic_id" scope="col">
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=characteristic_id" rel="nofollow">characteristic_id</a> <a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&amp;_sort=characteristic_id" rel="nofollow">characteristic_id</a>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/1">1</a></td> <td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/1">1</a></td>
<td class="col-rowid">1</td> <td class="col-rowid">1</td>
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/1">The Mystery Spot</a>&nbsp;<em>1</em></td> <td class="col-attraction_id"><a href="/fixtures/roadside_attractions/1">The Mystery Spot</a>&nbsp;<em>1</em></td>
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td> <td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr> </tr>
<tr> <tr>
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/2">2</a></td> <td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/2">2</a></td>
<td class="col-rowid">2</td> <td class="col-rowid">2</td>
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/2">Winchester Mystery House</a>&nbsp;<em>2</em></td> <td class="col-attraction_id"><a href="/fixtures/roadside_attractions/2">Winchester Mystery House</a>&nbsp;<em>2</em></td>
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td> <td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr> </tr>
<tr> <tr>
<td class="col-Link"><a href="{{ base_url }}fixtures/roadside_attraction_characteristics/3">3</a></td> <td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/3">3</a></td>
<td class="col-rowid">3</td> <td class="col-rowid">3</td>
<td class="col-attraction_id"><a href="{{ base_url }}fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a>&nbsp;<em>4</em></td> <td class="col-attraction_id"><a href="/fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a>&nbsp;<em>4</em></td>
<td class="col-characteristic_id"><a href="{{ base_url }}fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td> <td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div id="export" class="advanced-export"> <div id="export" class="advanced-export">
<h3>Advanced export</h3> <h3>Advanced export</h3>
<p>JSON shape: <p>JSON shape:
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">default</a>, <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on">default</a>,
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array">array</a>, <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array">array</a>,
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array&amp;_nl=on">newline-delimited</a> <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&amp;_labels=on&amp;_shape=array&amp;_nl=on">newline-delimited</a>
</p> </p>
<form action="{{ base_url }}fixtures/roadside_attraction_characteristics.csv" method="get"> <form action="/fixtures/roadside_attraction_characteristics.csv" method="get">
<p> <p>
CSV options: CSV options:
<label><input type="checkbox" name="_dl"> download file</label> <label><input type="checkbox" name="_dl"> download file</label>
@ -445,7 +445,7 @@
<h2 class="pattern-heading">.bd for /database/table/row</h2> <h2 class="pattern-heading">.bd for /database/table/row</h2>
<section class="content"> <section class="content">
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1> <h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
<p>This data as <a href="{{ base_url }}fixtures/roadside_attractions/2.json">json</a></p> <p>This data as <a href="/fixtures/roadside_attractions/2.json">json</a></p>
<table class="rows-and-columns"> <table class="rows-and-columns">
<thead> <thead>
<tr> <tr>
@ -479,7 +479,7 @@
<h2>Links from other tables</h2> <h2>Links from other tables</h2>
<ul> <ul>
<li> <li>
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?attraction_id=2"> <a href="/fixtures/roadside_attraction_characteristics?attraction_id=2">
1 row</a> 1 row</a>
from attraction_id in roadside_attraction_characteristics from attraction_id in roadside_attraction_characteristics
</li> </li>

View file

@ -14,10 +14,9 @@
</style> </style>
{% endif %} {% endif %}
{% include "_codemirror.html" %} {% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% endblock %} {% endblock %}
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %} {% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
{% block crumbs %} {% block crumbs %}
{{ crumbs.nav(request=request, database=database) }} {{ crumbs.nav(request=request, database=database) }}
@ -25,19 +24,19 @@
{% block content %} {% block content %}
{% if stored_query_write and db_is_immutable %} {% if canned_query_write and db_is_immutable %}
<p class="message-error">This query cannot be executed because the database is immutable.</p> <p class="message-error">This query cannot be executed because the database is immutable.</p>
{% endif %} {% endif %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}</h1> <h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
{% set action_links, action_title = query_actions(), "Query actions" %} {% set action_links, action_title = query_actions(), "Query actions" %}
{% include "_action_menu.html" %} {% include "_action_menu.html" %}
{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} {% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<form class="sql core" action="{{ urls.database(database) }}{% if stored_query %}/{{ stored_query }}{% endif %}" method="{% if stored_query_write %}post{% else %}get{% endif %}" data-parameters-url="{{ urls.database(database) }}/-/query/parameters"> <form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} <h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span> <span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
{% endif %}</h3> {% endif %}</h3>
@ -46,43 +45,57 @@
{% endif %} {% endif %}
{% if not hide_sql %} {% if not hide_sql %}
{% if editable and allow_execute_sql %} {% if editable and allow_execute_sql %}
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %} <p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p> >{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
{% else %} {% else %}
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre> <pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
{% endif %} {% endif %}
{% else %} {% else %}
{% if not stored_query %} {% if not canned_query %}
<input type="hidden" name="sql" <input type="hidden" name="sql"
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}" value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
> >
{% endif %} {% endif %}
{% endif %} {% endif %}
{% set parameter_names = named_parameter_values.keys()|list %} {% if named_parameter_values %}
{% set parameter_values = named_parameter_values %} <h3>Query parameters</h3>
{% set sql_parameters_allow_expand = false %} {% for name, value in named_parameter_values.items() %}
{% include "_sql_parameters.html" %} <p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
{% endfor %}
{% endif %}
<p> <p>
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %} {% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}> {% if canned_query_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
{{ show_hide_hidden }} {{ show_hide_hidden }}
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %} {% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
</p> </p>
</form> </form>
{% if display_rows %} {% if display_rows %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p> <p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
<div class="table-wrapper"><table class="rows-and-columns">
<thead>
<tr>
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for column, td in zip(columns, row) %}
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
{% if not canned_query_write and not error %}
<p class="zero-results">0 results</p>
{% endif %}
{% endif %} {% endif %}
{% set show_zero_results = not stored_query_write and not error %}
{% include "_query_results.html" %}
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %} {% endblock %}

View file

@ -1,295 +0,0 @@
{% extends "base.html" %}
{% block title %}Create query{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
<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-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>
{% 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 %}

View file

@ -1,281 +0,0 @@
{% extends "base.html" %}
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
{% block extra_head %}
{{- super() -}}
<style>
.query-list-page {
max-width: 64rem;
}
.query-list-filters {
margin: 0.5rem 0 0.75rem;
}
.query-list-search {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin: 0 0 0.75rem;
}
.query-list-search label {
width: auto;
}
.query-list-search input[type=search] {
box-sizing: border-box;
flex: 1 1 18rem;
max-width: 24rem;
}
.query-list-search button[type=submit] {
font-size: 0.78rem;
height: 2rem;
line-height: 1.1;
padding: 0.35rem 0.65rem;
}
.query-list-facets {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 1rem 1.6rem;
margin: 0 0 1rem;
}
.query-list-facet {
margin: 0;
}
.query-list-facet h2 {
font-size: 0.9rem;
line-height: 1.2;
margin: 0 0 0.35rem;
}
.query-list-facet ul {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.query-list-facet-link,
.query-list-facet-link:link,
.query-list-facet-link:visited,
.query-list-facet-link:hover,
.query-list-facet-link:focus,
.query-list-facet-link:active {
align-items: center;
border: 1px solid #c8d1dc;
border-radius: 0.25rem;
color: #39445a;
display: inline-flex;
font-size: 0.82rem;
gap: 0.4rem;
line-height: 1.1;
padding: 0.35rem 0.55rem;
text-decoration: none;
}
.query-list-facet-link:hover {
border-color: #7ca5c8;
color: #1f5d85;
}
.query-list-facet-link-active {
background-color: #edf6fb;
border-color: #6d9fc0;
font-weight: 700;
}
.query-list-facet-disabled {
color: #7b8794;
cursor: default;
}
.query-list-facet-count {
color: #4f5b6d;
font-variant-numeric: tabular-nums;
}
.query-list-results {
border-collapse: collapse;
font-size: 0.9rem;
margin: 0.25rem 0 1rem;
min-width: 42rem;
width: 100%;
}
.query-list-results th,
.query-list-results td {
border-bottom: 1px solid #d7dde5;
padding: 0.45rem 0.7rem;
text-align: left;
vertical-align: top;
}
.query-list-results th {
background-color: #edf6fb;
border-top: 1px solid #d7dde5;
color: #39445a;
font-weight: 700;
}
.query-list-results tbody tr:nth-child(even) {
background-color: rgba(39, 104, 144, 0.05);
}
.query-list-results a.query-list-title {
font-weight: 700;
}
.query-list-description {
color: #4f5b6d;
font-size: 0.78rem;
margin: 0.15rem 0 0;
}
.query-list-owner {
color: #39445a;
font-family: var(--font-monospace, monospace);
white-space: nowrap;
}
.query-list-flags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.query-list-pill {
background-color: #eef1f5;
border: 1px solid #d7dde5;
border-radius: 0.25rem;
color: #39445a;
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
padding: 0.25rem 0.4rem;
white-space: nowrap;
}
.query-list-pill-write {
background-color: #fff4db;
border-color: #e2b64e;
}
.query-list-pill-public {
background-color: #e7f5ec;
border-color: #9ecfab;
color: #267a3e;
}
.query-list-pill-private {
background-color: #f7edf0;
border-color: #dbb8c1;
}
.query-list-pill-trusted {
background-color: #e7f5ec;
border-color: #9ecfab;
color: #267a3e;
}
.query-list-empty {
color: #6b7280;
}
.query-list-footnotes {
border-top: 1px solid #d7dde5;
color: #4f5b6d;
font-size: 0.82rem;
margin: 0.35rem 0 1rem;
padding-top: 0.55rem;
}
.query-list-footnotes p {
margin: 0.25rem 0;
}
.query-list-footnotes .query-list-pill {
margin-right: 0.35rem;
}
.query-list-pagination a {
border: 1px solid #007bff;
border-radius: 0.25rem;
display: inline-block;
padding: 0.45rem 0.7rem;
}
.query-list-pagination-bottom {
margin-top: 0.75rem;
}
@media (max-width: 700px) {
.query-list-search input[type=search] {
max-width: none;
}
}
</style>
{% endblock %}
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-list-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
<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>
{% if queries %}
<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 %}

View file

@ -4,9 +4,7 @@
{% block extra_head %} {% block extra_head %}
{{- super() -}} {{- super() -}}
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
<script src="{{ urls.static('table.js') }}" defer></script> <script src="{{ urls.static('table.js') }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script> <script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style> <style>
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {
@ -138,26 +136,6 @@
{% include "_facet_results.html" %} {% include "_facet_results.html" %}
{% endif %} {% endif %}
{% if all_columns %}
<column-chooser></column-chooser>
<button class="choose-columns-mobile small-screen-only" onclick="openColumnChooser()">Choose columns</button>
<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>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Column actions</span>
</button>
<script>
window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }};
</script>
{% endif %}
{% if set_column_type_ui %}
<script>
window._setColumnTypeData = {{ set_column_type_ui|tojson }};
</script>
{% endif %}
{% include custom_table_templates %} {% include custom_table_templates %}
{% if next_url %} {% if next_url %}

View file

@ -1,193 +0,0 @@
"""
Token handler system for Datasette.
Provides a base class for token handlers and the default signed token handler.
Plugins can implement register_token_handler to provide custom token backends
(e.g. database-backed tokens that can be revoked and audited).
"""
from __future__ import annotations
import dataclasses
import time
from typing import TYPE_CHECKING, Optional
import itsdangerous
if TYPE_CHECKING:
from datasette.app import Datasette
@dataclasses.dataclass
class TokenRestrictions:
"""
Restrictions to apply to a token, limiting which actions it can perform.
Use the builder methods to construct restrictions::
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
"""
all: list[str] = dataclasses.field(default_factory=list)
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
def allow_all(self, action: str) -> "TokenRestrictions":
"""Allow an action across all databases and resources."""
self.all.append(action)
return self
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
"""Allow an action on a specific database."""
self.database.setdefault(database, []).append(action)
return self
def allow_resource(
self, database: str, resource: str, action: str
) -> "TokenRestrictions":
"""Allow an action on a specific resource within a database."""
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:
"""
Base class for token handlers.
Subclass this and implement create_token() and verify_token() to provide
a custom token backend. Return an instance from the register_token_handler hook.
"""
name: str = ""
async def create_token(
self,
datasette: "Datasette",
actor_id: str,
*,
expires_after: Optional[int] = None,
restrictions: Optional[TokenRestrictions] = None,
) -> str:
"""Create and return a token string for the given actor."""
raise NotImplementedError
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
"""
Verify a token and return an actor dict, or None if this handler
does not recognize the token.
"""
raise NotImplementedError
class SignedTokenHandler(TokenHandler):
"""
Default token handler using itsdangerous signed tokens (dstok_ prefix).
"""
name = "signed"
async def create_token(
self,
datasette: "Datasette",
actor_id: str,
*,
expires_after: Optional[int] = None,
restrictions: Optional[TokenRestrictions] = None,
) -> str:
if not datasette.setting("allow_signed_tokens"):
raise ValueError(
"Signed tokens are not enabled for this Datasette instance"
)
token = {"a": actor_id, "t": int(time.time())}
if expires_after:
token["d"] = expires_after
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]:
prefix = "dstok_"
if not datasette.setting("allow_signed_tokens"):
return None
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
if not token.startswith(prefix):
return None
raw = token[len(prefix) :]
try:
decoded = datasette.unsign(raw, namespace="token")
except itsdangerous.BadSignature:
return None
if "t" not in decoded:
return None
created = decoded["t"]
if not isinstance(created, int):
return None
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
return None
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
and max_signed_tokens_ttl
and duration > max_signed_tokens_ttl
):
duration = max_signed_tokens_ttl
if duration:
if time.time() - created > duration:
return None
actor = {"id": decoded["a"], "token": "dstok"}
if "_r" in decoded:
actor["_r"] = decoded["_r"]
if duration:
actor["token_expires"] = created + duration
return actor

View file

@ -155,15 +155,9 @@ Column = namedtuple(
functions_marked_as_documented = [] functions_marked_as_documented = []
def documented(fn=None, *, label=None): def documented(fn):
def decorate(fn): functions_marked_as_documented.append(fn)
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__) return fn
functions_marked_as_documented.append(fn)
return fn
if fn is None:
return decorate
return decorate(fn)
@documented @documented
@ -618,10 +612,7 @@ def get_outbound_foreign_keys(conn, table):
def get_all_foreign_keys(conn): def get_all_foreign_keys(conn):
tables = [ tables = [
r[0] r[0] for r in conn.execute('select name from sqlite_master where type="table"')
for r in conn.execute(
'select name from sqlite_master where type="table" order by name'
)
] ]
table_to_foreign_keys = {} table_to_foreign_keys = {}
for table in tables: for table in tables:
@ -643,15 +634,6 @@ def get_all_foreign_keys(conn):
{"other_table": table_name, "column": from_, "other_column": to_} {"other_table": table_name, "column": from_, "other_column": to_}
) )
# Sort foreign keys for deterministic ordering
for table in table_to_foreign_keys:
table_to_foreign_keys[table]["incoming"].sort(
key=lambda fk: (fk["other_table"], fk["column"], fk["other_column"])
)
table_to_foreign_keys[table]["outgoing"].sort(
key=lambda fk: (fk["other_table"], fk["column"], fk["other_column"])
)
return table_to_foreign_keys return table_to_foreign_keys
@ -683,22 +665,19 @@ def detect_fts_sql(table):
and sql like '%VIRTUAL TABLE%USING FTS%' and sql like '%VIRTUAL TABLE%USING FTS%'
) )
) )
""".format(table=table.replace("'", "''")) """.format(
table=table.replace("'", "''")
)
def detect_json1(conn=None): def detect_json1(conn=None):
close_conn = False
if conn is None: if conn is None:
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
close_conn = True
try: try:
conn.execute("SELECT json('{}')") conn.execute("SELECT json('{}')")
return True return True
except Exception: except Exception:
return False return False
finally:
if close_conn:
conn.close()
def table_columns(conn, table): def table_columns(conn, table):
@ -715,11 +694,8 @@ def table_column_details(conn, table):
).fetchall() ).fetchall()
] ]
else: else:
# First trigger a query against sqlite_master to fix an intermittent # Treat hidden as 0 for all columns
# test failure, see https://github.com/simonw/datasette/issues/2632
conn.execute("select 1 from sqlite_master limit 1").fetchall()
return [ return [
# Treat hidden as 0 for all columns.
Column(*(list(r) + [0])) Column(*(list(r) + [0]))
for r in conn.execute( for r in conn.execute(
f"PRAGMA table_info({escape_sqlite(table)});" f"PRAGMA table_info({escape_sqlite(table)});"
@ -837,8 +813,7 @@ def path_with_format(
*, request=None, path=None, format=None, extra_qs=None, replace_format=None *, request=None, path=None, format=None, extra_qs=None, replace_format=None
): ):
qs = extra_qs or {} qs = extra_qs or {}
if path is None and request: path = request.path if request else path
path = request.path
if replace_format and path.endswith(f".{replace_format}"): if replace_format and path.endswith(f".{replace_format}"):
path = path[: -(1 + len(replace_format))] path = path[: -(1 + len(replace_format))]
if "." in path: if "." in path:
@ -914,26 +889,18 @@ _infinities = {float("inf"), float("-inf")}
def remove_infinites(row): def remove_infinites(row):
""" to_check = row
Replace float('inf') and float('-inf') with None in a row.
Returns the original row object unchanged if no infinities are found.
"""
if isinstance(row, dict): if isinstance(row, dict):
for v in row.values(): to_check = row.values()
if isinstance(v, float) and v in _infinities: if not any((c in _infinities) if isinstance(c, float) else 0 for c in to_check):
return { return row
k: (None if isinstance(v2, float) and v2 in _infinities else v2) if isinstance(row, dict):
for k, v2 in row.items() return {
} k: (None if (isinstance(v, float) and v in _infinities) else v)
for k, v in row.items()
}
else: else:
for v in row: return [None if (isinstance(c, float) and c in _infinities) else c for c in row]
if isinstance(v, float) and v in _infinities:
return [
None if isinstance(v2, float) and v2 in _infinities else v2
for v2 in row
]
return row
class StaticMount(click.ParamType): class StaticMount(click.ParamType):
@ -1098,35 +1065,12 @@ def _gather_arguments(fn, kwargs):
return call_with return call_with
@documented
def call_with_supported_arguments(fn, **kwargs): def call_with_supported_arguments(fn, **kwargs):
"""
Call ``fn`` with the subset of ``**kwargs`` matching its signature.
This implements dependency injection: the caller provides all available
keyword arguments and the function receives only the ones it declares
as parameters.
:param fn: A callable (sync function)
:param kwargs: All available keyword arguments
:returns: The return value of ``fn``
"""
call_with = _gather_arguments(fn, kwargs) call_with = _gather_arguments(fn, kwargs)
return fn(*call_with) return fn(*call_with)
@documented
async def async_call_with_supported_arguments(fn, **kwargs): async def async_call_with_supported_arguments(fn, **kwargs):
"""
Async version of :func:`call_with_supported_arguments`.
Calls ``await fn(...)`` with the subset of ``**kwargs`` matching its
signature.
:param fn: An async callable
:param kwargs: All available keyword arguments
:returns: The return value of ``await fn(...)``
"""
call_with = _gather_arguments(fn, kwargs) call_with = _gather_arguments(fn, kwargs)
return await fn(*call_with) return await fn(*call_with)

View file

@ -147,9 +147,7 @@ async def _build_single_action_sql(
raise ValueError(f"Unknown action: {action}") raise ValueError(f"Unknown action: {action}")
# Get base resources SQL from the resource class # Get base resources SQL from the resource class
base_resources_sql = await action_obj.resource_class.resources_sql( base_resources_sql = await action_obj.resource_class.resources_sql(datasette)
datasette, actor=actor
)
permission_sqls = await gather_permission_sql_from_hooks( permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette, datasette=datasette,
@ -182,11 +180,13 @@ async def _build_single_action_sql(
# Skip plugins that only provide restriction_sql (no permission rules) # Skip plugins that only provide restriction_sql (no permission rules)
if permission_sql.sql is None: if permission_sql.sql is None:
continue continue
rule_sqls.append(f""" rule_sqls.append(
f"""
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM ( SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
{permission_sql.sql} {permission_sql.sql}
) )
""".strip()) """.strip()
)
# If no rules, return empty result (deny all) # If no rules, return empty result (deny all)
if not rule_sqls: if not rule_sqls:
@ -241,14 +241,6 @@ async def _build_single_action_sql(
"),", "),",
] ]
) )
else:
query_parts.extend(
[
"anon_rules AS (",
" SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0",
"),",
]
)
# Continue with the cascading logic # Continue with the cascading logic
query_parts.extend( query_parts.extend(
@ -413,12 +405,14 @@ async def _build_single_action_sql(
# Add restriction filter if there are restrictions # Add restriction filter if there are restrictions
if restriction_sqls: if restriction_sqls:
query_parts.append(""" query_parts.append(
"""
AND EXISTS ( AND EXISTS (
SELECT 1 FROM restriction_list r SELECT 1 FROM restriction_list r
WHERE (r.parent = decisions.parent OR r.parent IS NULL) WHERE (r.parent = decisions.parent OR r.parent IS NULL)
AND (r.child = decisions.child OR r.child IS NULL) AND (r.child = decisions.child OR r.child IS NULL)
)""") )"""
)
# Add parent filter if specified # Add parent filter if specified
if parent is not None: if parent is not None:
@ -485,11 +479,13 @@ async def build_permission_rules_sql(
if permission_sql.sql is None: if permission_sql.sql is None:
continue continue
union_parts.append(f""" union_parts.append(
f"""
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM ( SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
{permission_sql.sql} {permission_sql.sql}
) )
""".strip()) """.strip()
)
rules_union = " UNION ALL ".join(union_parts) rules_union = " UNION ALL ".join(union_parts)
return rules_union, all_params, restriction_sqls return rules_union, all_params, restriction_sqls

View file

@ -1,21 +1,5 @@
import json import json
from typing import Optional
from datasette.utils import MultiParams, calculate_etag from datasette.utils import MultiParams, calculate_etag
from datasette.utils.multipart import (
parse_form_data,
MultipartParseError,
FormData,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MAX_FIELDS,
DEFAULT_MAX_FILES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_FIELD_SIZE,
DEFAULT_MAX_MEMORY_FILE_SIZE,
DEFAULT_MAX_PART_HEADER_BYTES,
DEFAULT_MAX_PART_HEADER_LINES,
DEFAULT_MIN_FREE_DISK_BYTES,
)
from mimetypes import guess_type from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path from pathlib import Path
@ -155,71 +139,6 @@ class Request:
body = await self.post_body() body = await self.post_body()
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True)) return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
async def form(
self,
files: bool = False,
max_file_size: int = DEFAULT_MAX_FILE_SIZE,
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES,
min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES,
) -> FormData:
"""
Parse form data from the request body.
Supports both application/x-www-form-urlencoded and multipart/form-data.
Args:
files: If True, store file uploads; if False (default), discard them
max_file_size: Maximum size per file in bytes (default 50MB)
max_request_size: Maximum total request size in bytes (default 100MB)
max_fields: Maximum number of form fields (default 1000)
max_files: Maximum number of file uploads (default 100)
max_parts: Maximum number of multipart parts (default max_fields + max_files)
max_field_size: Maximum size of a text field value in bytes (default 100KB)
max_memory_file_size: Threshold before files spill to disk (default 1MB)
max_part_header_bytes: Maximum bytes allowed in part headers (default 16KB)
max_part_header_lines: Maximum header lines per part (default 100)
min_free_disk_bytes: Minimum free bytes required in temp dir (default 50MB)
Returns:
FormData object with dict-like access to fields and files.
Use form["key"] for first value, form.getlist("key") for all values.
Raises:
BadRequest: If content-type is missing, unsupported, or parsing fails
"""
content_type = self.headers.get("content-type", "")
if not content_type:
raise BadRequest(
"Missing Content-Type header; expected application/x-www-form-urlencoded "
"or multipart/form-data"
)
try:
return await parse_form_data(
receive=self.receive,
content_type=content_type,
files=files,
max_file_size=max_file_size,
max_request_size=max_request_size,
max_fields=max_fields,
max_files=max_files,
max_parts=max_parts,
max_field_size=max_field_size,
max_memory_file_size=max_memory_file_size,
max_part_header_bytes=max_part_header_bytes,
max_part_header_lines=max_part_header_lines,
min_free_disk_bytes=min_free_disk_bytes,
)
except MultipartParseError as e:
raise BadRequest(str(e))
@classmethod @classmethod
def fake(cls, path_with_query_string, method="GET", scheme="http", url_vars=None): def fake(cls, path_with_query_string, method="GET", scheme="http", url_vars=None):
"""Useful for constructing Request objects for tests""" """Useful for constructing Request objects for tests"""

View file

@ -3,7 +3,8 @@ from datasette.utils import table_column_details
async def init_internal_db(db): async def init_internal_db(db):
create_tables_sql = textwrap.dedent(""" create_tables_sql = textwrap.dedent(
"""
CREATE TABLE IF NOT EXISTS catalog_databases ( CREATE TABLE IF NOT EXISTS catalog_databases (
database_name TEXT PRIMARY KEY, database_name TEXT PRIMARY KEY,
path TEXT, path TEXT,
@ -67,13 +68,16 @@ async def init_internal_db(db):
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name), FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name) FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
); );
""").strip() """
).strip()
await db.execute_write_script(create_tables_sql) await db.execute_write_script(create_tables_sql)
await initialize_metadata_tables(db) await initialize_metadata_tables(db)
async def initialize_metadata_tables(db): async def initialize_metadata_tables(db):
await db.execute_write_script(textwrap.dedent(""" await db.execute_write_script(
textwrap.dedent(
"""
CREATE TABLE IF NOT EXISTS metadata_instance ( CREATE TABLE IF NOT EXISTS metadata_instance (
key text, key text,
value text, value text,
@ -103,38 +107,9 @@ async def initialize_metadata_tables(db):
value text, value text,
unique(database_name, resource_name, column_name, key) unique(database_name, resource_name, column_name, key)
); );
"""
CREATE TABLE IF NOT EXISTS column_types ( )
database_name TEXT NOT NULL, )
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
"""))
async def populate_schema_tables(internal_db, db): async def populate_schema_tables(internal_db, db):

View file

@ -1,757 +0,0 @@
"""
Streaming multipart/form-data parser for ASGI applications.
Supports:
- Streaming parsing without buffering entire body in memory
- Files spill to disk above configurable threshold
- Security limits on request size, file size, field count
- Both multipart/form-data and application/x-www-form-urlencoded
"""
import asyncio
import shutil
import tempfile
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)
from urllib.parse import parse_qsl
# Centralized defaults for multipart/form-data parsing
DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
DEFAULT_MAX_FIELDS = 1000
DEFAULT_MAX_FILES = 100
# If max_parts is not specified, it defaults to max_fields + max_files
DEFAULT_MAX_PARTS: Optional[int] = None
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
DEFAULT_MAX_PART_HEADER_LINES = 100
DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
class MultipartParseError(Exception):
"""Raised when multipart parsing fails."""
pass
@dataclass
class UploadedFile:
"""
Represents an uploaded file from a multipart form.
Attributes:
name: The form field name
filename: The original filename from the upload
content_type: The MIME type of the file
size: Size in bytes
"""
name: str
filename: str
content_type: Optional[str]
size: int
_file: tempfile.SpooledTemporaryFile = field(repr=False)
async def read(self, size: int = -1) -> bytes:
"""Read file contents."""
return await asyncio.to_thread(self._file.read, size)
async def seek(self, offset: int, whence: int = 0) -> int:
"""Seek to position in file."""
return await asyncio.to_thread(self._file.seek, offset, whence)
async def close(self) -> None:
"""Close the underlying file."""
await asyncio.to_thread(self._file.close)
def close_sync(self) -> None:
"""Close the underlying file synchronously."""
self._file.close()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
def __del__(self):
try:
self._file.close()
except Exception:
pass
class FormData:
"""
Container for parsed form data, supporting both fields and files.
Provides dict-like access with support for multiple values per key.
"""
def __init__(self):
self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
def append(self, key: str, value: Union[str, UploadedFile]) -> None:
"""Add a key-value pair."""
self._data.append((key, value))
def __getitem__(self, key: str) -> Union[str, UploadedFile]:
"""Get the first value for a key."""
for k, v in self._data:
if k == key:
return v
raise KeyError(key)
def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
"""Get the first value for a key, or default if not found."""
try:
return self[key]
except KeyError:
return default
def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
"""Get all values for a key."""
return [v for k, v in self._data if k == key]
def __contains__(self, key: str) -> bool:
"""Check if key exists."""
return any(k == key for k, _ in self._data)
def __len__(self) -> int:
"""Return number of items."""
return len(self._data)
def __iter__(self):
"""Iterate over unique keys."""
seen = set()
for k, _ in self._data:
if k not in seen:
seen.add(k)
yield k
def keys(self):
"""Return unique keys."""
return list(self)
def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
"""Return all key-value pairs."""
return list(self._data)
def values(self) -> List[Union[str, UploadedFile]]:
"""Return all values."""
return [v for _, v in self._data]
def _uploaded_files(self) -> List[UploadedFile]:
"""Return UploadedFile instances contained in this form."""
return [v for _, v in self._data if isinstance(v, UploadedFile)]
def close(self) -> None:
"""
Close any uploaded files.
This provides deterministic cleanup for spooled temp files.
"""
for uploaded in self._uploaded_files():
try:
uploaded.close_sync()
except Exception:
# Best-effort cleanup; ignore close errors
pass
async def aclose(self) -> None:
"""Asynchronously close any uploaded files."""
for uploaded in self._uploaded_files():
try:
await uploaded.close()
except Exception:
# Best-effort cleanup; ignore close errors
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self.aclose()
def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
"""
Parse Content-Disposition header value.
Returns dict with 'name', 'filename' keys (filename may be None).
"""
result: Dict[str, Optional[str]] = {"name": None, "filename": None}
# Split on semicolons, handling quoted strings
parts = []
current = ""
in_quotes = False
i = 0
while i < len(header):
char = header[i]
if char == '"' and (i == 0 or header[i - 1] != "\\"):
in_quotes = not in_quotes
current += char
elif char == ";" and not in_quotes:
parts.append(current.strip())
current = ""
else:
current += char
i += 1
if current.strip():
parts.append(current.strip())
for part in parts[1:]: # Skip the "form-data" part
if "=" not in part:
continue
key, _, value = part.partition("=")
key = key.strip().lower()
value = value.strip()
# Handle filename* (RFC 5987 encoding)
if key == "filename*":
# Format: utf-8''encoded_filename or charset'language'encoded_filename
if "'" in value:
parts_star = value.split("'", 2)
if len(parts_star) >= 3:
# charset = parts_star[0]
# language = parts_star[1]
encoded = parts_star[2]
# URL decode
try:
from urllib.parse import unquote
result["filename"] = unquote(encoded, encoding="utf-8")
except Exception:
pass
continue
# Remove quotes if present
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
# Unescape backslash sequences
value = value.replace('\\"', '"').replace("\\\\", "\\")
if key == "name":
result["name"] = value
elif key == "filename":
# Only set if filename* hasn't already set it
if result["filename"] is None:
# Strip path components (security)
# Handle both Unix and Windows paths
value = value.replace("\\", "/")
if "/" in value:
value = value.rsplit("/", 1)[-1]
result["filename"] = value
return result
def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
"""
Parse Content-Type header value.
Returns (media_type, parameters_dict).
"""
parts = header.split(";")
media_type = parts[0].strip().lower()
params = {}
for part in parts[1:]:
part = part.strip()
if "=" in part:
key, _, value = part.partition("=")
key = key.strip().lower()
value = value.strip()
# Remove quotes if present
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
params[key] = value
return media_type, params
class MultipartParser:
"""
Streaming multipart/form-data parser.
Processes the body chunk by chunk without loading everything into memory.
"""
# Parser states
STATE_PREAMBLE = 0
STATE_HEADER = 1
STATE_BODY = 2
STATE_DONE = 3
def __init__(
self,
boundary: bytes,
max_file_size: int = DEFAULT_MAX_FILE_SIZE,
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES,
min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES,
handle_files: bool = False,
):
self.boundary = b"--" + boundary
self.end_boundary = self.boundary + b"--"
self.max_file_size = max_file_size
self.max_request_size = max_request_size
self.max_fields = max_fields
self.max_files = max_files
# If not specified, tie max_parts to the other cardinality limits
if max_parts is None:
max_parts = max_fields + max_files
self.max_parts = max_parts
self.max_field_size = max_field_size
self.max_memory_file_size = max_memory_file_size
self.max_part_header_bytes = max_part_header_bytes
self.max_part_header_lines = max_part_header_lines
self.min_free_disk_bytes = min_free_disk_bytes
self.handle_files = handle_files
self.state = self.STATE_PREAMBLE
self.buffer = bytearray()
self.total_bytes = 0
self.field_count = 0
self.file_count = 0
self.part_count = 0
self.current_part_size = 0
self.current_header_bytes = 0
self.current_header_lines = 0
self.form_data = FormData()
self._disk_check_interval_bytes = 1024 * 1024 # 1MB between disk checks
self._bytes_since_disk_check = 0
self._tempdir = tempfile.gettempdir()
# Current part state
self.current_headers: Dict[str, str] = {}
self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
self.current_body = bytearray()
self.current_name: Optional[str] = None
self.current_filename: Optional[str] = None
self.current_content_type: Optional[str] = None
def feed(self, chunk: bytes) -> None:
"""Feed a chunk of data to the parser."""
self.total_bytes += len(chunk)
if self.total_bytes > self.max_request_size:
raise MultipartParseError("Request body too large")
self.buffer.extend(chunk)
self._process()
def _process(self) -> None:
"""Process buffered data."""
while True:
if self.state == self.STATE_PREAMBLE:
if not self._process_preamble():
break
elif self.state == self.STATE_HEADER:
if not self._process_header():
break
elif self.state == self.STATE_BODY:
if not self._process_body():
break
elif self.state == self.STATE_DONE:
break
def _process_preamble(self) -> bool:
"""Skip preamble and find first boundary."""
# Look for boundary (could be at start or after preamble)
# Try both \r\n prefixed and bare boundary at start
idx = self.buffer.find(self.boundary)
if idx == -1:
# Keep potential partial boundary at end
keep = len(self.boundary) - 1
if len(self.buffer) > keep:
self.buffer = self.buffer[-keep:]
return False
# Found boundary, skip to after it
after_boundary = idx + len(self.boundary)
# Check for end boundary
if self.buffer[idx : idx + len(self.end_boundary)] == self.end_boundary:
self.state = self.STATE_DONE
return False
# Skip CRLF or LF after boundary
if after_boundary < len(self.buffer):
if self.buffer[after_boundary : after_boundary + 2] == b"\r\n":
after_boundary += 2
elif self.buffer[after_boundary : after_boundary + 1] == b"\n":
after_boundary += 1
self.buffer = self.buffer[after_boundary:]
self.state = self.STATE_HEADER
self.current_headers = {}
self.current_header_bytes = 0
self.current_header_lines = 0
return True
def _process_header(self) -> bool:
"""Parse part headers."""
while True:
# Look for end of header line
crlf_idx = self.buffer.find(b"\r\n")
lf_idx = self.buffer.find(b"\n")
if crlf_idx == -1 and lf_idx == -1:
# Guard against unbounded header buffering if no newline is ever sent
if len(self.buffer) > self.max_part_header_bytes:
raise MultipartParseError("Part headers too large")
return False # Need more data
# Use whichever comes first
if crlf_idx != -1 and (lf_idx == -1 or crlf_idx < lf_idx):
idx = crlf_idx
line_end_len = 2
else:
idx = lf_idx
line_end_len = 1
line = self.buffer[:idx]
self.buffer = self.buffer[idx + line_end_len :]
self.current_header_lines += 1
self.current_header_bytes += idx + line_end_len
if (
self.current_header_lines > self.max_part_header_lines
or self.current_header_bytes > self.max_part_header_bytes
):
raise MultipartParseError("Part headers too large")
if not line:
# Empty line = end of headers
self._start_body()
self.state = self.STATE_BODY
return True
# Parse header
try:
line_str = line.decode("utf-8", errors="replace")
except Exception:
line_str = line.decode("latin-1")
if ":" in line_str:
name, _, value = line_str.partition(":")
self.current_headers[name.strip().lower()] = value.strip()
def _start_body(self) -> None:
"""Initialize body parsing for current part."""
self.part_count += 1
if self.part_count > self.max_parts:
raise MultipartParseError("Too many parts")
# Parse Content-Disposition
cd = self.current_headers.get("content-disposition", "")
parsed = parse_content_disposition(cd)
self.current_name = parsed.get("name")
self.current_filename = parsed.get("filename")
self.current_content_type = self.current_headers.get("content-type")
self.current_part_size = 0
if self.current_filename is not None:
# It's a file
self.file_count += 1
if self.file_count > self.max_files:
raise MultipartParseError("Too many files")
if self.handle_files:
self.current_file = tempfile.SpooledTemporaryFile(
max_size=self.max_memory_file_size
)
else:
# Will discard file content
self.current_file = None
else:
# It's a text field
self.field_count += 1
if self.field_count > self.max_fields:
raise MultipartParseError("Too many fields")
self.current_body = bytearray()
self.current_file = None
# Check disk space before allocating a spooled temp file
if self.current_filename is not None and self.handle_files:
self._ensure_disk_space()
def _process_body(self) -> bool:
"""Process body data for current part."""
# Look for boundary in buffer
# Need to handle boundary potentially split across chunks
# The boundary is preceded by \r\n (or \n for lenient parsing)
search_boundary = b"\r\n" + self.boundary
idx = self.buffer.find(search_boundary)
if idx == -1:
# Try LF-only boundary (lenient)
search_boundary_lf = b"\n" + self.boundary
idx = self.buffer.find(search_boundary_lf)
if idx != -1:
search_boundary = search_boundary_lf
if idx == -1:
# No boundary found yet
# Keep potential partial boundary at end of buffer
safe_len = len(self.buffer) - len(search_boundary) - 1
if safe_len > 0:
safe_data = self.buffer[:safe_len]
self._write_body_data(bytes(safe_data))
self.buffer = self.buffer[safe_len:]
return False
# Found boundary - write remaining body data
body_data = self.buffer[:idx]
self._write_body_data(bytes(body_data))
# Move past the boundary
after_boundary = idx + len(search_boundary)
# Check for end boundary
remaining = self.buffer[after_boundary:]
if remaining.startswith(b"--"):
# End boundary
self._finish_part()
self.state = self.STATE_DONE
return False
# Skip CRLF or LF after boundary
if remaining.startswith(b"\r\n"):
after_boundary += 2
elif remaining.startswith(b"\n"):
after_boundary += 1
self.buffer = self.buffer[after_boundary:]
self._finish_part()
self.state = self.STATE_HEADER
self.current_headers = {}
self.current_header_bytes = 0
self.current_header_lines = 0
return True
def _write_body_data(self, data: bytes) -> None:
"""Write data to current part body."""
if not data:
return
self.current_part_size += len(data)
if self.current_filename is not None:
# File data
if self.current_part_size > self.max_file_size:
raise MultipartParseError("File too large")
if self.handle_files and self.current_file:
self._bytes_since_disk_check += len(data)
if self._bytes_since_disk_check >= self._disk_check_interval_bytes:
self._ensure_disk_space()
self._bytes_since_disk_check = 0
self.current_file.write(data)
# else: discard file data
else:
# Field data
if self.current_part_size > self.max_field_size:
raise MultipartParseError("Field value too large")
self.current_body.extend(data)
def _finish_part(self) -> None:
"""Finalize current part and add to form data."""
if self.current_name is None:
return
if self.current_filename is not None:
# File
if self.handle_files and self.current_file:
self.current_file.seek(0)
uploaded = UploadedFile(
name=self.current_name,
filename=self.current_filename,
content_type=self.current_content_type,
size=self.current_part_size,
_file=self.current_file,
)
self.form_data.append(self.current_name, uploaded)
# else: file was discarded
else:
# Text field
try:
value = bytes(self.current_body).decode("utf-8")
except UnicodeDecodeError:
value = bytes(self.current_body).decode("latin-1")
self.form_data.append(self.current_name, value)
# Reset part state
self.current_file = None
self.current_body = bytearray()
self.current_name = None
self.current_filename = None
self.current_content_type = None
def finalize(self) -> FormData:
"""Finalize parsing and return form data."""
# Process any remaining data
self._process()
if self.state != self.STATE_DONE:
raise MultipartParseError(
"Truncated multipart body (missing closing boundary)"
)
return self.form_data
def _ensure_disk_space(self) -> None:
"""
Ensure there is enough free space on the temp filesystem.
This is a best-effort guard against filling the disk with uploads.
"""
if not self.handle_files:
return
if self.min_free_disk_bytes <= 0:
return
free_bytes = shutil.disk_usage(self._tempdir).free
if free_bytes < self.min_free_disk_bytes:
raise MultipartParseError("Insufficient disk space for uploads")
async def parse_form_data(
receive: Callable,
content_type: str,
files: bool = False,
max_file_size: int = DEFAULT_MAX_FILE_SIZE,
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
max_part_header_lines: int = DEFAULT_MAX_PART_HEADER_LINES,
min_free_disk_bytes: int = DEFAULT_MIN_FREE_DISK_BYTES,
) -> FormData:
"""
Parse form data from an ASGI receive callable.
Supports both application/x-www-form-urlencoded and multipart/form-data.
Args:
receive: ASGI receive callable
content_type: Content-Type header value
files: If True, store file uploads; if False, discard them
max_file_size: Maximum size per file in bytes
max_request_size: Maximum total request size in bytes
max_fields: Maximum number of form fields
max_files: Maximum number of file uploads
max_field_size: Maximum size of a text field value
max_memory_file_size: File size threshold before spilling to disk
Returns:
FormData object containing parsed fields and files
"""
media_type, params = parse_content_type(content_type)
if media_type == "application/x-www-form-urlencoded":
# Read entire body for URL-encoded forms (they're typically small)
body = bytearray()
total = 0
while True:
message = await receive()
message_type = message.get("type")
if message_type == "http.disconnect":
raise MultipartParseError("Client disconnected during request body")
if message_type is not None and message_type != "http.request":
continue
chunk = message.get("body", b"")
total += len(chunk)
if total > max_request_size:
raise MultipartParseError("Request body too large")
body.extend(chunk)
if not message.get("more_body", False):
break
form_data = FormData()
try:
pairs = parse_qsl(bytes(body).decode("utf-8"), keep_blank_values=True)
except UnicodeDecodeError:
pairs = parse_qsl(bytes(body).decode("latin-1"), keep_blank_values=True)
for key, value in pairs:
form_data.append(key, value)
return form_data
elif media_type == "multipart/form-data":
boundary = params.get("boundary")
if not boundary:
raise MultipartParseError("Missing boundary in Content-Type")
parser = MultipartParser(
boundary=boundary.encode("utf-8"),
max_file_size=max_file_size,
max_request_size=max_request_size,
max_fields=max_fields,
max_files=max_files,
max_parts=max_parts,
max_field_size=max_field_size,
max_memory_file_size=max_memory_file_size,
max_part_header_bytes=max_part_header_bytes,
max_part_header_lines=max_part_header_lines,
min_free_disk_bytes=min_free_disk_bytes,
handle_files=files,
)
# Stream body through parser
batch_target = 64 * 1024
batch = bytearray()
async def flush_batch() -> None:
if batch:
data = bytes(batch)
batch.clear()
await asyncio.to_thread(parser.feed, data)
while True:
message = await receive()
message_type = message.get("type")
if message_type == "http.disconnect":
raise MultipartParseError("Client disconnected during request body")
if message_type is not None and message_type != "http.request":
continue
chunk = message.get("body", b"")
if chunk:
batch.extend(chunk)
if len(batch) >= batch_target:
await flush_batch()
if not message.get("more_body", False):
break
await flush_batch()
return await asyncio.to_thread(parser.finalize)
else:
raise MultipartParseError(
f"Unsupported Content-Type: {media_type}. "
"Expected application/x-www-form-urlencoded or multipart/form-data"
)

View file

@ -9,6 +9,7 @@ from datasette.permissions import PermissionSQL
from datasette.plugins import pm from datasette.plugins import pm
from datasette.utils import await_me_maybe from datasette.utils import await_me_maybe
# Sentinel object to indicate permission checks should be skipped # Sentinel object to indicate permission checks should be skipped
SKIP_PERMISSION_CHECKS = object() SKIP_PERMISSION_CHECKS = object()
@ -115,11 +116,13 @@ def build_rules_union(
if p.sql is None: if p.sql is None:
continue continue
parts.append(f""" parts.append(
f"""
SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM ( SELECT parent, child, allow, reason, '{p.source}' AS source_plugin FROM (
{p.sql} {p.sql}
) )
""".strip()) """.strip()
)
if not parts: if not parts:
# Empty UNION that returns no rows # Empty UNION that returns no rows

View file

@ -1,550 +0,0 @@
from dataclasses import dataclass
from typing import Literal
from datasette.utils.sqlite import SQLiteTableType, sqlite3, sqlite_table_type
SQLOperation = Literal[
"read",
"insert",
"update",
"delete",
"select",
"function",
"create",
"alter",
"drop",
"begin",
"commit",
"rollback",
"savepoint",
"attach",
"detach",
"pragma",
"analyze",
"reindex",
"vacuum",
"unknown",
]
SQLTargetType = Literal[
"table",
"index",
"view",
"trigger",
"virtual-table",
"schema",
"statement",
"transaction",
"database",
"pragma",
"function",
"unknown",
]
SQLTableOperation = Literal["read", "insert", "update", "delete"]
SQLSchemaOperation = Literal["create", "drop"]
SQLSchemaTargetType = Literal["index", "table", "trigger", "view", "virtual-table"]
@dataclass(frozen=True)
class Operation:
operation: SQLOperation
target_type: SQLTargetType
database: str | None
table: str | None
sqlite_schema: str | None
table_kind: SQLiteTableType | None = None
target: str | None = None
columns: tuple[str, ...] = ()
source: str | None = None
internal: bool = False
@dataclass(frozen=True)
class SQLAnalysis:
operations: tuple[Operation, ...]
# Hashable dict key for grouping repeated authorizer callbacks while collecting columns.
@dataclass(frozen=True)
class OperationKey:
operation: SQLOperation
target_type: SQLTargetType
database: str | None
table: str | None
sqlite_schema: str | None
target: str | None
source: str | None
internal: bool
_ACTION_TO_OPERATION: dict[int, SQLTableOperation] = {
sqlite3.SQLITE_READ: "read",
sqlite3.SQLITE_INSERT: "insert",
sqlite3.SQLITE_UPDATE: "update",
sqlite3.SQLITE_DELETE: "delete",
}
# Values are (operation, target_type) pairs used to construct Operation objects.
_CREATE_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
sqlite3.SQLITE_CREATE_INDEX: ("create", "index"),
sqlite3.SQLITE_CREATE_TABLE: ("create", "table"),
sqlite3.SQLITE_CREATE_TRIGGER: ("create", "trigger"),
sqlite3.SQLITE_CREATE_VIEW: ("create", "view"),
}
_DROP_ACTIONS: dict[int, tuple[SQLSchemaOperation, SQLSchemaTargetType]] = {
sqlite3.SQLITE_DROP_INDEX: ("drop", "index"),
sqlite3.SQLITE_DROP_TABLE: ("drop", "table"),
sqlite3.SQLITE_DROP_TRIGGER: ("drop", "trigger"),
sqlite3.SQLITE_DROP_VIEW: ("drop", "view"),
}
def _add_schema_action(
action_name: str,
operation: SQLSchemaOperation,
target_type: SQLSchemaTargetType,
) -> None:
action_value = getattr(sqlite3, action_name, None)
if action_value is not None:
actions = _CREATE_ACTIONS if operation == "create" else _DROP_ACTIONS
actions[action_value] = (operation, target_type)
_TEMP_SCHEMA_ACTIONS: tuple[
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
] = (
("SQLITE_CREATE_TEMP_INDEX", "create", "index"),
("SQLITE_CREATE_TEMP_TABLE", "create", "table"),
("SQLITE_CREATE_TEMP_TRIGGER", "create", "trigger"),
("SQLITE_CREATE_TEMP_VIEW", "create", "view"),
("SQLITE_DROP_TEMP_INDEX", "drop", "index"),
("SQLITE_DROP_TEMP_TABLE", "drop", "table"),
("SQLITE_DROP_TEMP_TRIGGER", "drop", "trigger"),
("SQLITE_DROP_TEMP_VIEW", "drop", "view"),
)
for schema_action in _TEMP_SCHEMA_ACTIONS:
_add_schema_action(*schema_action)
_VTABLE_SCHEMA_ACTIONS: tuple[
tuple[str, SQLSchemaOperation, SQLSchemaTargetType], ...
] = (
("SQLITE_CREATE_VTABLE", "create", "virtual-table"),
("SQLITE_DROP_VTABLE", "drop", "virtual-table"),
)
for schema_action in _VTABLE_SCHEMA_ACTIONS:
_add_schema_action(*schema_action)
_SQLITE_SCHEMA_TABLES = {
"sqlite_master",
"sqlite_schema",
"sqlite_temp_master",
"sqlite_temp_schema",
}
_SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
"length",
"like",
"printf",
"sqlite_drop_column",
"sqlite_rename_column",
"sqlite_rename_quotefix",
"sqlite_rename_table",
"sqlite_rename_test",
"substr",
}
_AUTHORIZER_ACTION_NAMES = {
getattr(sqlite3, name): name
for name in (
"SQLITE_CREATE_INDEX",
"SQLITE_CREATE_TABLE",
"SQLITE_CREATE_TEMP_INDEX",
"SQLITE_CREATE_TEMP_TABLE",
"SQLITE_CREATE_TEMP_TRIGGER",
"SQLITE_CREATE_TEMP_VIEW",
"SQLITE_CREATE_TRIGGER",
"SQLITE_CREATE_VIEW",
"SQLITE_DELETE",
"SQLITE_DROP_INDEX",
"SQLITE_DROP_TABLE",
"SQLITE_DROP_TEMP_INDEX",
"SQLITE_DROP_TEMP_TABLE",
"SQLITE_DROP_TEMP_TRIGGER",
"SQLITE_DROP_TEMP_VIEW",
"SQLITE_DROP_TRIGGER",
"SQLITE_DROP_VIEW",
"SQLITE_INSERT",
"SQLITE_PRAGMA",
"SQLITE_READ",
"SQLITE_SELECT",
"SQLITE_TRANSACTION",
"SQLITE_UPDATE",
"SQLITE_ATTACH",
"SQLITE_DETACH",
"SQLITE_ALTER_TABLE",
"SQLITE_REINDEX",
"SQLITE_ANALYZE",
"SQLITE_CREATE_VTABLE",
"SQLITE_DROP_VTABLE",
"SQLITE_FUNCTION",
"SQLITE_SAVEPOINT",
"SQLITE_RECURSIVE",
)
if hasattr(sqlite3, name)
}
def _allow_authorizer_action(*args):
return sqlite3.SQLITE_OK
def analyze_sql_tables(
conn,
sql: str,
params=None,
*,
database_name: str | None = None,
schema_to_database: dict[str, str] | None = None,
) -> SQLAnalysis:
"""
Return operations performed by a SQL statement according to SQLite's authorizer.
This function is synchronous and connection-based. It temporarily installs a
SQLite authorizer, prepares ``EXPLAIN <sql>``, and returns the operation
callbacks observed while SQLite compiles the statement.
"""
operations: dict[OperationKey, set[str]] = {}
def database_for_schema(sqlite_schema):
if schema_to_database and sqlite_schema in schema_to_database:
return schema_to_database[sqlite_schema]
if sqlite_schema == "main" and database_name is not None:
return database_name
return sqlite_schema
def record(
operation: SQLOperation,
target_type: SQLTargetType,
*,
database: str | None,
table: str | None,
sqlite_schema: str | None,
target: str | None,
source: str | None,
column: str | None = None,
internal: bool = False,
):
key = OperationKey(
operation=operation,
target_type=target_type,
database=database,
table=table,
sqlite_schema=sqlite_schema,
target=target,
source=source,
internal=internal,
)
columns = operations.setdefault(key, set())
if column is not None:
columns.add(column)
def authorizer(action, arg1, arg2, sqlite_schema, source):
operation = _ACTION_TO_OPERATION.get(action)
if operation is not None and arg1 is not None:
target_type = "schema" if arg1 in _SQLITE_SCHEMA_TABLES else "table"
column = (
arg2 if operation in ("read", "update") and arg2 is not None else None
)
record(
operation,
target_type,
database=database_for_schema(sqlite_schema),
table=arg1 if target_type == "table" else None,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
column=column,
)
return sqlite3.SQLITE_OK
create_operation = _CREATE_ACTIONS.get(action)
if create_operation is not None and arg1 is not None:
operation, target_type = create_operation
related_table = arg2 if target_type in {"index", "trigger"} else arg1
record(
operation,
target_type,
database=database_for_schema(sqlite_schema),
table=related_table,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
drop_operation = _DROP_ACTIONS.get(action)
if drop_operation is not None and arg1 is not None:
operation, target_type = drop_operation
related_table = arg2 if target_type in {"index", "trigger"} else arg1
record(
operation,
target_type,
database=database_for_schema(sqlite_schema),
table=related_table,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_ALTER_TABLE and arg2 is not None:
record(
"alter",
"table",
database=database_for_schema(arg1),
table=arg2,
sqlite_schema=arg1,
target=arg2,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_TRANSACTION and arg1 is not None:
record(
arg1.lower(),
"transaction",
database=None,
table=None,
sqlite_schema=None,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_ATTACH and arg1 is not None:
record(
"attach",
"database",
database=None,
table=None,
sqlite_schema=None,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_DETACH and arg1 is not None:
record(
"detach",
"database",
database=None,
table=None,
sqlite_schema=None,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_PRAGMA and arg1 is not None:
record(
"pragma",
"pragma",
database=None,
table=None,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_ANALYZE:
record(
"analyze",
"database" if arg1 is None else "table",
database=database_for_schema(sqlite_schema),
table=arg1,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_REINDEX and arg1 is not None:
record(
"reindex",
"index",
database=database_for_schema(sqlite_schema),
table=None,
sqlite_schema=sqlite_schema,
target=arg1,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_SELECT:
record(
"select",
"statement",
database=None,
table=None,
sqlite_schema=sqlite_schema,
target=None,
source=source,
)
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_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()
)
)

View file

@ -1,6 +1,3 @@
import re
from typing import Literal
using_pysqlite3 = False using_pysqlite3 = False
try: try:
import pysqlite3 as sqlite3 import pysqlite3 as sqlite3
@ -13,19 +10,6 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
sqlite3.enable_callback_tracebacks(True) sqlite3.enable_callback_tracebacks(True)
_cached_sqlite_version = None _cached_sqlite_version = None
_cached_supports_returning = None
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
_VIRTUAL_TABLE_MODULE_RE = re.compile(
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
re.IGNORECASE | re.DOTALL,
)
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
"fts3": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
"fts4": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
"fts5": ("_data", "_idx", "_docsize", "_content", "_config"),
"rtree": ("_node", "_parent", "_rowid"),
"rtree_i32": ("_node", "_parent", "_rowid"),
}
def sqlite_version(): def sqlite_version():
@ -36,162 +20,20 @@ def sqlite_version():
def _sqlite_version(): def _sqlite_version():
conn = sqlite3.connect(":memory:") return tuple(
try: map(
return tuple( int,
map( sqlite3.connect(":memory:")
int, .execute("select sqlite_version()")
conn.execute("select sqlite_version()").fetchone()[0].split("."), .fetchone()[0]
) .split("."),
) )
finally: )
conn.close()
def supports_table_xinfo(): def supports_table_xinfo():
return sqlite_version() >= (3, 26, 0) return sqlite_version() >= (3, 26, 0)
def supports_table_list():
return sqlite_version() >= (3, 37, 0)
def supports_generated_columns(): def supports_generated_columns():
return sqlite_version() >= (3, 31, 0) return sqlite_version() >= (3, 31, 0)
def supports_returning():
global _cached_supports_returning
if _cached_supports_returning is None:
conn = sqlite3.connect(":memory:")
try:
conn.execute("create table t (id integer primary key)")
conn.execute("insert into t default values returning id").fetchone()
_cached_supports_returning = True
except sqlite3.DatabaseError:
_cached_supports_returning = False
finally:
conn.close()
return _cached_supports_returning
def sqlite_table_type(
conn,
table: str,
*,
schema: str | None = "main",
) -> SQLiteTableType | None:
if supports_table_list():
try:
query = "select type from pragma_table_list where name = ?"
params: tuple[str, ...] = (table,)
if schema is not None:
query += " and schema = ?"
params = (table, schema)
row = conn.execute(query, params).fetchone()
if row is not None and row[0] in {"table", "view", "virtual", "shadow"}:
return row[0]
except sqlite3.DatabaseError:
pass
return _sqlite_table_type_from_schema(conn, table, schema=schema)
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
schema_table = _sqlite_schema_table(schema)
try:
rows = conn.execute(
"select name, sql from {} where type = 'table'".format(schema_table)
).fetchall()
except sqlite3.DatabaseError:
return []
hidden_tables = []
content_fts_tables = []
for name, sql in rows:
if (
name in {"sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"}
or name.startswith("_")
or sqlite_table_type(conn, name, schema=schema) == "shadow"
):
hidden_tables.append(name)
elif _is_fts_content_virtual_table(sql):
content_fts_tables.append(name)
return sorted(hidden_tables) + content_fts_tables
def _sqlite_table_type_from_schema(
conn,
table: str,
*,
schema: str | None = "main",
) -> SQLiteTableType | None:
schema_table = _sqlite_schema_table(schema)
try:
row = conn.execute(
"select type, sql from {} where name = ?".format(schema_table),
(table,),
).fetchone()
except sqlite3.DatabaseError:
return None
if row is None:
return None
object_type, sql = row
if object_type == "view":
return "view"
if object_type != "table":
return None
if _virtual_table_module(sql) is not None:
return "virtual"
if _is_known_shadow_table(conn, table, schema=schema):
return "shadow"
return "table"
def _is_known_shadow_table(
conn,
table: str,
*,
schema: str | None = "main",
) -> bool:
schema_table = _sqlite_schema_table(schema)
try:
rows = conn.execute(
"select name, sql from {} where type = 'table'".format(schema_table)
).fetchall()
except sqlite3.DatabaseError:
return False
for virtual_table, sql in rows:
module = _virtual_table_module(sql)
if module is None:
continue
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
if table == virtual_table + suffix:
return True
return False
def _sqlite_schema_table(schema: str | None) -> str:
if schema is None or schema == "main":
return "sqlite_master"
if schema == "temp":
return "sqlite_temp_master"
return "{}.sqlite_master".format(_quote_identifier(schema))
def _quote_identifier(value: str) -> str:
return '"{}"'.format(value.replace('"', '""'))
def _virtual_table_module(sql: str | None) -> str | None:
if not sql:
return None
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
if match is None:
return None
return match.group(1).strip("\"'[]`").lower()
def _is_fts_content_virtual_table(sql: str | None) -> bool:
return (
_virtual_table_module(sql) in {"fts3", "fts4", "fts5"}
and "content=" in sql.lower()
)

View file

@ -95,8 +95,15 @@ class TestClient:
cookies = cookies or {} cookies = cookies or {}
post_data = post_data or {} post_data = post_data or {}
assert not (post_data and body), "Provide one or other of body= or post_data=" assert not (post_data and body), "Provide one or other of body= or post_data="
# csrftoken_from is accepted for backward compatibility but is now a no-op. # Maybe fetch a csrftoken first
# Datasette no longer uses CSRF tokens - see CrossOriginProtectionMiddleware. if csrftoken_from is not None:
assert body is None, "body= is not compatible with csrftoken_from="
if csrftoken_from is True:
csrftoken_from = path
token_response = await self._request(csrftoken_from, cookies=cookies)
csrftoken = token_response.cookies["ds_csrftoken"]
cookies["ds_csrftoken"] = csrftoken
post_data["csrftoken"] = csrftoken
if post_data: if post_data:
body = urlencode(post_data, doseq=True) body = urlencode(post_data, doseq=True)
return await self._request( return await self._request(

View file

@ -1,2 +1,2 @@
__version__ = "1.0a32" __version__ = "1.0a22"
__version_info__ = tuple(__version__.split(".")) __version_info__ = tuple(__version__.split("."))

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import csv import csv
import hashlib import hashlib
import json
import sys import sys
import textwrap import textwrap
import time import time
@ -153,13 +154,7 @@ class BaseView:
if self.has_json_alternate: if self.has_json_alternate:
alternate_url_json = self.ds.absolute_url( alternate_url_json = self.ds.absolute_url(
request, request,
self.ds.urls.path( self.ds.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
template_context["alternate_url_json"] = alternate_url_json template_context["alternate_url_json"] = alternate_url_json
headers.update( headers.update(
@ -247,7 +242,8 @@ class DataView(BaseView):
data, extra_template_data, templates = response_or_template_contexts data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex: except QueryInterrupted as ex:
raise DatasetteError( raise DatasetteError(
textwrap.dedent(""" textwrap.dedent(
"""
<p>SQL query took too long. The time limit is controlled by the <p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a> <a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p> configuration option.</p>
@ -256,7 +252,10 @@ class DataView(BaseView):
let ta = document.querySelector("textarea"); let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px"; ta.style.height = ta.scrollHeight + "px";
</script> </script>
""".format(escape(ex.sql))).strip(), """.format(
escape(ex.sql)
)
).strip(),
title="SQL Interrupted", title="SQL Interrupted",
status=400, status=400,
message_is_html=True, message_is_html=True,
@ -353,21 +352,13 @@ class DataView(BaseView):
if it_can_render: if it_can_render:
renderers[key] = self.ds.urls.path( renderers[key] = self.ds.urls.path(
path_with_format( path_with_format(
request=request, request=request, format=key, extra_qs={**url_labels_extra}
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
) )
) )
url_csv_args = {"_size": "max", **url_labels_extra} url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path( url_csv = self.ds.urls.path(
path_with_format( path_with_format(request=request, format="csv", extra_qs=url_csv_args)
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
) )
url_csv_path = url_csv.split("?")[0] url_csv_path = url_csv.split("?")[0]
context = { context = {

View file

@ -13,8 +13,6 @@ import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource 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 ( from datasette.utils import (
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
@ -37,7 +35,6 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm from datasette.plugins import pm
from .base import BaseView, DatasetteError, View, _error, stream_csv from .base import BaseView, DatasetteError, View, _error, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from . import Context from . import Context
@ -60,16 +57,12 @@ class DatabaseView(View):
sql = (request.args.get("sql") or "").strip() sql = (request.args.get("sql") or "").strip()
if sql: if sql:
redirect_url = datasette.urls.database(database) + "/-/query" redirect_url = "/" + request.url_vars.get("database") + "/-/query"
if request.url_vars.get("format"): if request.url_vars.get("format"):
redirect_url = path_with_format( redirect_url += "." + request.url_vars.get("format")
path=redirect_url, format=request.url_vars.get("format")
)
redirect_url += "?" + request.query_string redirect_url += "?" + request.query_string
response = Response.redirect(redirect_url) return Response.redirect(redirect_url)
if datasette.cors: return await QueryView()(request, datasette)
add_cors_headers(response.headers)
return response
if format_ not in ("html", "json"): if format_ not in ("html", "json"):
raise NotFound("Invalid format: {}".format(format_)) raise NotFound("Invalid format: {}".format(format_))
@ -97,20 +90,25 @@ class DatabaseView(View):
tables = await get_tables(datasette, request, db, allowed_dict) tables = await get_tables(datasette, request, db, allowed_dict)
queries_page = await datasette.list_queries( # Get allowed queries using the new permission system
database, allowed_query_page = await datasette.allowed_resources(
actor=request.actor, "view-query",
limit=5, request.actor,
include_private=True, parent=database,
) include_is_private=True,
stored_queries = queries_page.queries limit=1000,
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(): async def database_actions():
links = [] links = []
for hook in pm.hook.database_actions( for hook in pm.hook.database_actions(
@ -132,7 +130,6 @@ class DatabaseView(View):
actor=request.actor, actor=request.actor,
) )
json_data = { json_data = {
"ok": True,
"database": database, "database": database,
"private": private, "private": private,
"path": datasette.urls.database(database), "path": datasette.urls.database(database),
@ -140,9 +137,7 @@ class DatabaseView(View):
"tables": tables, "tables": tables,
"hidden_count": len([t for t in tables if t["hidden"]]), "hidden_count": len([t for t in tables if t["hidden"]]),
"views": sql_views, "views": sql_views,
"queries": [stored_query_to_dict(query) for query in stored_queries], "queries": canned_queries,
"queries_more": queries_more,
"queries_count": queries_count,
"allow_execute_sql": allow_execute_sql, "allow_execute_sql": allow_execute_sql,
"table_columns": ( "table_columns": (
await _table_columns(datasette, database) if allow_execute_sql else {} await _table_columns(datasette, database) if allow_execute_sql else {}
@ -159,13 +154,7 @@ class DatabaseView(View):
assert format_ == "html" assert format_ == "html"
alternate_url_json = datasette.absolute_url( alternate_url_json = datasette.absolute_url(
request, request,
datasette.urls.path( datasette.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
templates = (f"database-{to_css_class(database)}.html", "database.html") templates = (f"database-{to_css_class(database)}.html", "database.html")
environment = datasette.get_jinja_environment(request) environment = datasette.get_jinja_environment(request)
@ -181,9 +170,7 @@ class DatabaseView(View):
tables=tables, tables=tables,
hidden_count=len([t for t in tables if t["hidden"]]), hidden_count=len([t for t in tables if t["hidden"]]),
views=sql_views, views=sql_views,
queries=stored_queries, queries=canned_queries,
queries_more=queries_more,
queries_count=queries_count,
allow_execute_sql=allow_execute_sql, allow_execute_sql=allow_execute_sql,
table_columns=( table_columns=(
await _table_columns(datasette, database) await _table_columns(datasette, database)
@ -231,11 +218,7 @@ class DatabaseContext(Context):
tables: list = field(metadata={"help": "List of table objects in the database"}) tables: list = field(metadata={"help": "List of table objects in the database"})
hidden_count: int = field(metadata={"help": "Count of hidden tables"}) hidden_count: int = field(metadata={"help": "Count of hidden tables"})
views: list = field(metadata={"help": "List of view objects in the database"}) views: list = field(metadata={"help": "List of view objects in the database"})
queries: list = field(metadata={"help": "List of stored query objects"}) queries: list = field(metadata={"help": "List of canned 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( allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"} metadata={"help": "Boolean indicating if custom SQL can be executed"}
) )
@ -280,8 +263,8 @@ class QueryContext(Context):
query: dict = field( query: dict = field(
metadata={"help": "The SQL query object containing the `sql` string"} metadata={"help": "The SQL query object containing the `sql` string"}
) )
stored_query: str = field( canned_query: str = field(
metadata={"help": "The name of the stored query if this is a stored query"} metadata={"help": "The name of the canned query if this is a canned query"}
) )
private: bool = field( private: bool = field(
metadata={"help": "Boolean indicating if this is a private database"} metadata={"help": "Boolean indicating if this is a private database"}
@ -289,13 +272,13 @@ class QueryContext(Context):
# urls: dict = field( # urls: dict = field(
# metadata={"help": "Object containing URL helpers like `database()`"} # metadata={"help": "Object containing URL helpers like `database()`"}
# ) # )
stored_query_write: bool = field( canned_query_write: bool = field(
metadata={ metadata={
"help": "Boolean indicating if this is a stored query that allows writes" "help": "Boolean indicating if this is a canned query that allows writes"
} }
) )
metadata: dict = field( metadata: dict = field(
metadata={"help": "Metadata about the database or the stored query"} metadata={"help": "Metadata about the database or the canned query"}
) )
db_is_immutable: bool = field( db_is_immutable: bool = field(
metadata={"help": "Boolean indicating if this database is immutable"} metadata={"help": "Boolean indicating if this database is immutable"}
@ -316,15 +299,12 @@ class QueryContext(Context):
allow_execute_sql: bool = field( allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"} 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"}) tables: list = field(metadata={"help": "List of table objects in the database"})
named_parameter_values: dict = field( named_parameter_values: dict = field(
metadata={"help": "Dictionary of parameter names/values"} metadata={"help": "Dictionary of parameter names/values"}
) )
edit_sql_url: str = field( edit_sql_url: str = field(
metadata={"help": "URL to edit the SQL for a stored query"} metadata={"help": "URL to edit the SQL for a canned query"}
) )
display_rows: list = field(metadata={"help": "List of result rows to display"}) display_rows: list = field(metadata={"help": "List of result rows to display"})
columns: list = field(metadata={"help": "List of column names"}) columns: list = field(metadata={"help": "List of column names"})
@ -348,8 +328,8 @@ class QueryContext(Context):
top_query: callable = field( top_query: callable = field(
metadata={"help": "Callable to render the top_query slot"} metadata={"help": "Callable to render the top_query slot"}
) )
top_stored_query: callable = field( top_canned_query: callable = field(
metadata={"help": "Callable to render the top_stored_query slot"} metadata={"help": "Callable to render the top_canned_query slot"}
) )
query_actions: callable = field( query_actions: callable = field(
metadata={ metadata={
@ -440,47 +420,21 @@ class QueryView(View):
db = await datasette.resolve_database(request) db = await datasette.resolve_database(request)
# We must be a stored query # We must be a canned query
table_found = False table_found = False
try: try:
await datasette.resolve_table(request) await datasette.resolve_table(request)
table_found = True table_found = True
except TableNotFound as table_not_found: except TableNotFound as table_not_found:
stored_query = await datasette.get_query( canned_query = await datasette.get_canned_query(
table_not_found.database_name, table_not_found.table table_not_found.database_name, table_not_found.table, request.actor
) )
if stored_query is None: if canned_query is None:
raise raise
if table_found: if table_found:
# That should not have happened # That should not have happened
raise DatasetteError("Unexpected table found on POST", status=404) 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 database is immutable, return an error
if not db.is_mutable: if not db.is_mutable:
raise Forbidden("Database is immutable") raise Forbidden("Database is immutable")
@ -505,18 +459,18 @@ class QueryView(View):
or request.args.get("_json") or request.args.get("_json")
or params.get("_json") or params.get("_json")
) )
params_for_query = MagicParameters(stored_query.sql, params, request, datasette) params_for_query = MagicParameters(
canned_query["sql"], params, request, datasette
)
await params_for_query.execute_params() await params_for_query.execute_params()
ok = None ok = None
redirect_url = None redirect_url = None
try: try:
cursor = await db.execute_write( cursor = await db.execute_write(canned_query["sql"], params_for_query)
stored_query.sql, params_for_query, request=request
)
# success message can come from on_success_message or on_success_message_sql # success message can come from on_success_message or on_success_message_sql
message = None message = None
message_type = datasette.INFO message_type = datasette.INFO
on_success_message_sql = stored_query.on_success_message_sql on_success_message_sql = canned_query.get("on_success_message_sql")
if on_success_message_sql: if on_success_message_sql:
try: try:
message_result = ( message_result = (
@ -528,21 +482,18 @@ class QueryView(View):
message = "Error running on_success_message_sql: {}".format(ex) message = "Error running on_success_message_sql: {}".format(ex)
message_type = datasette.ERROR message_type = datasette.ERROR
if not message: if not message:
if stored_query.on_success_message: message = canned_query.get(
message = stored_query.on_success_message "on_success_message"
elif cursor.rowcount == -1: ) or "Query executed, {} row{} affected".format(
message = "Query executed" cursor.rowcount, "" if cursor.rowcount == 1 else "s"
else: )
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
redirect_url = stored_query.on_success_redirect redirect_url = canned_query.get("on_success_redirect")
ok = True ok = True
except Exception as ex: except Exception as ex:
message = stored_query.on_error_message or str(ex) message = canned_query.get("on_error_message") or str(ex)
message_type = datasette.ERROR message_type = datasette.ERROR
redirect_url = stored_query.on_error_redirect redirect_url = canned_query.get("on_error_redirect")
ok = False ok = False
if should_return_json: if should_return_json:
return Response.json( return Response.json(
@ -575,35 +526,31 @@ class QueryView(View):
# Create lookup dict for quick access # Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables_page.resources} allowed_dict = {r.child: r for r in allowed_tables_page.resources}
# Are we a stored query? # Are we a canned query?
stored_query = None canned_query = None
stored_query_write = False canned_query_write = False
if "table" in request.url_vars: if "table" in request.url_vars:
try: try:
await datasette.resolve_table(request) await datasette.resolve_table(request)
except TableNotFound as table_not_found: except TableNotFound as table_not_found:
# Was this actually a stored query? # Was this actually a canned query?
stored_query = await datasette.get_query( canned_query = await datasette.get_canned_query(
table_not_found.database_name, table_not_found.table table_not_found.database_name, table_not_found.table, request.actor
) )
if stored_query is None: if canned_query is None:
raise raise
stored_query_write = stored_query.is_write canned_query_write = bool(canned_query.get("write"))
private = False private = False
if stored_query: if canned_query:
# Respect stored query permissions # Respect canned query permissions
visible, private = await datasette.check_visibility( visible, private = await datasette.check_visibility(
request.actor, request.actor,
action="view-query", action="view-query",
resource=QueryResource(database=database, query=stored_query.name), resource=QueryResource(database=database, query=canned_query["name"]),
) )
if not visible: if not visible:
raise Forbidden("You do not have permission to view this query") 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: else:
await datasette.ensure_permission( await datasette.ensure_permission(
@ -616,16 +563,16 @@ class QueryView(View):
params = {key: request.args.get(key) for key in request.args} params = {key: request.args.get(key) for key in request.args}
sql = None sql = None
if stored_query: if canned_query:
sql = stored_query.sql sql = canned_query["sql"]
elif "sql" in params: elif "sql" in params:
sql = params.pop("sql") sql = params.pop("sql")
# Extract any :named parameters # Extract any :named parameters
named_parameters = [] named_parameters = []
if stored_query and stored_query.parameters: if canned_query and canned_query.get("params"):
named_parameters = stored_query.parameters named_parameters = canned_query["params"]
if not named_parameters and sql: if not named_parameters:
named_parameters = derive_named_parameters(sql) named_parameters = derive_named_parameters(sql)
named_parameter_values = { named_parameter_values = {
named_parameter: params.get(named_parameter) or "" named_parameter: params.get(named_parameter) or ""
@ -650,13 +597,13 @@ class QueryView(View):
params_for_query = params params_for_query = params
if sql and not stored_query_write: if not canned_query_write:
try: try:
if not stored_query: if not canned_query:
# For regular queries we only allow SELECT, plus other rules # For regular queries we only allow SELECT, plus other rules
validate_sql_select(sql) validate_sql_select(sql)
else: else:
# Stored queries can run magic parameters # Canned queries can run magic parameters
params_for_query = MagicParameters(sql, params, request, datasette) params_for_query = MagicParameters(sql, params, request, datasette)
await params_for_query.execute_params() await params_for_query.execute_params()
results = await datasette.execute( results = await datasette.execute(
@ -666,7 +613,8 @@ class QueryView(View):
rows = results.rows rows = results.rows
except QueryInterrupted as ex: except QueryInterrupted as ex:
raise DatasetteError( raise DatasetteError(
textwrap.dedent(""" textwrap.dedent(
"""
<p>SQL query took too long. The time limit is controlled by the <p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a> <a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p> configuration option.</p>
@ -675,7 +623,10 @@ class QueryView(View):
let ta = document.querySelector("textarea"); let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px"; ta.style.height = ta.scrollHeight + "px";
</script> </script>
""".format(markupsafe.escape(ex.sql))).strip(), """.format(
markupsafe.escape(ex.sql)
)
).strip(),
title="SQL Interrupted", title="SQL Interrupted",
status=400, status=400,
message_is_html=True, message_is_html=True,
@ -694,8 +645,6 @@ class QueryView(View):
# Handle formats from plugins # Handle formats from plugins
if format_ == "csv": if format_ == "csv":
if not sql:
raise DatasetteError("?sql= is required", status=400)
async def fetch_data_for_csv(request, _next=None): async def fetch_data_for_csv(request, _next=None):
results = await db.execute(sql, params, truncate=True) results = await db.execute(sql, params, truncate=True)
@ -712,7 +661,7 @@ class QueryView(View):
columns=columns, columns=columns,
rows=rows, rows=rows,
sql=sql, sql=sql,
query_name=stored_query.name if stored_query else None, query_name=canned_query["name"] if canned_query else None,
database=database, database=database,
table=None, table=None,
request=request, request=request,
@ -744,23 +693,17 @@ class QueryView(View):
elif format_ == "html": elif format_ == "html":
headers = {} headers = {}
templates = [f"query-{to_css_class(database)}.html", "query.html"] templates = [f"query-{to_css_class(database)}.html", "query.html"]
if stored_query: if canned_query:
templates.insert( templates.insert(
0, 0,
f"query-{to_css_class(database)}-{to_css_class(stored_query.name)}.html", f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html",
) )
environment = datasette.get_jinja_environment(request) environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates) template = environment.select_template(templates)
alternate_url_json = datasette.absolute_url( alternate_url_json = datasette.absolute_url(
request, request,
datasette.urls.path( datasette.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
data = {} data = {}
headers.update( headers.update(
@ -771,9 +714,6 @@ class QueryView(View):
} }
) )
metadata = await datasette.get_database_metadata(database) metadata = await datasette.get_database_metadata(database)
if stored_query:
metadata = stored_query_to_dict(stored_query)
metadata.pop("source", None)
renderers = {} renderers = {}
for key, (_, can_render) in datasette.renderers.items(): for key, (_, can_render) in datasette.renderers.items():
@ -792,11 +732,7 @@ class QueryView(View):
it_can_render = await await_me_maybe(it_can_render) it_can_render = await await_me_maybe(it_can_render)
if it_can_render: if it_can_render:
renderers[key] = datasette.urls.path( renderers[key] = datasette.urls.path(
path_with_format( path_with_format(request=request, format=key)
request=request,
path=request.scope.get("route_path"),
format=key,
)
) )
allow_execute_sql = await datasette.allowed( allow_execute_sql = await datasette.allowed(
@ -804,14 +740,9 @@ class QueryView(View):
resource=DatabaseResource(database=database), resource=DatabaseResource(database=database),
actor=request.actor, actor=request.actor,
) )
allow_store_query = await datasette.allowed(
action="store-query",
resource=DatabaseResource(database=database),
actor=request.actor,
)
show_hide_hidden = "" show_hide_hidden = ""
if stored_query and stored_query.hide_sql: if canned_query and canned_query.get("hide_sql"):
if bool(params.get("_show_sql")): if bool(params.get("_show_sql")):
show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_link = path_with_removed_args(request, {"_show_sql"})
show_hide_text = "hide" show_hide_text = "hide"
@ -839,38 +770,24 @@ class QueryView(View):
# - No magic parameters, so no :_ in the SQL string # - No magic parameters, so no :_ in the SQL string
edit_sql_url = None edit_sql_url = None
is_validated_sql = False is_validated_sql = False
if sql: try:
try: validate_sql_select(sql)
validate_sql_select(sql) is_validated_sql = True
is_validated_sql = True except InvalidSql:
except InvalidSql: pass
pass if allow_execute_sql and is_validated_sql and ":_" not in sql:
if allow_execute_sql and is_validated_sql and ":_" not in sql: edit_sql_url = (
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) datasette.urls.database(database)
+ "/-/queries/store?" + "/-/query"
+ urlencode({"sql": sql}) + "?"
+ urlencode(
{
**{
"sql": sql,
},
**named_parameter_values,
}
)
) )
async def query_actions(): async def query_actions():
@ -879,7 +796,7 @@ class QueryView(View):
datasette=datasette, datasette=datasette,
actor=request.actor, actor=request.actor,
database=database, database=database,
query_name=stored_query.name if stored_query else None, query_name=canned_query["name"] if canned_query else None,
request=request, request=request,
sql=sql, sql=sql,
params=params, params=params,
@ -899,17 +816,16 @@ class QueryView(View):
"sql": sql, "sql": sql,
"params": params, "params": params,
}, },
stored_query=stored_query.name if stored_query else None, canned_query=canned_query["name"] if canned_query else None,
private=private, private=private,
stored_query_write=stored_query_write, canned_query_write=canned_query_write,
db_is_immutable=not db.is_mutable, db_is_immutable=not db.is_mutable,
error=query_error, error=query_error,
hide_sql=hide_sql, hide_sql=hide_sql,
show_hide_link=datasette.urls.path(show_hide_link), show_hide_link=datasette.urls.path(show_hide_link),
show_hide_text=show_hide_text, show_hide_text=show_hide_text,
editable=not stored_query, editable=not canned_query,
allow_execute_sql=allow_execute_sql, allow_execute_sql=allow_execute_sql,
save_query_url=save_query_url,
tables=await get_tables(datasette, request, db, allowed_dict), tables=await get_tables(datasette, request, db, allowed_dict),
named_parameter_values=named_parameter_values, named_parameter_values=named_parameter_values,
edit_sql_url=edit_sql_url, edit_sql_url=edit_sql_url,
@ -925,14 +841,11 @@ class QueryView(View):
renderers=renderers, renderers=renderers,
url_csv=datasette.urls.path( url_csv=datasette.urls.path(
path_with_format( path_with_format(
request=request, request=request, format="csv", extra_qs={"_size": "max"}
path=request.scope.get("route_path"),
format="csv",
extra_qs={"_size": "max"},
) )
), ),
show_hide_hidden=markupsafe.Markup(show_hide_hidden), show_hide_hidden=markupsafe.Markup(show_hide_hidden),
metadata=metadata, metadata=canned_query or metadata,
alternate_url_json=alternate_url_json, alternate_url_json=alternate_url_json,
select_templates=[ select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}" f"{'*' if template_name == template.name else ''}{template_name}"
@ -941,12 +854,12 @@ class QueryView(View):
top_query=make_slot_function( top_query=make_slot_function(
"top_query", datasette, request, database=database, sql=sql "top_query", datasette, request, database=database, sql=sql
), ),
top_stored_query=make_slot_function( top_canned_query=make_slot_function(
"top_stored_query", "top_canned_query",
datasette, datasette,
request, request,
database=database, database=database,
query_name=stored_query.name if stored_query else None, query_name=canned_query["name"] if canned_query else None,
), ),
query_actions=query_actions, query_actions=query_actions,
), ),
@ -1206,7 +1119,7 @@ class TableCreateView(BaseView):
return table.schema return table.schema
try: try:
schema = await db.execute_write_fn(create_table, request=request) schema = await db.execute_write_fn(create_table)
except Exception as e: except Exception as e:
return _error([str(e)]) return _error([str(e)])
@ -1259,6 +1172,22 @@ class TableCreateView(BaseView):
return Response.json(details, status=201) 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): async def display_rows(datasette, database, request, rows, columns):
display_rows = [] display_rows = []
truncate_cells = datasette.setting("truncate_cells_html") truncate_cells = datasette.setting("truncate_cells_html")
@ -1274,11 +1203,9 @@ async def display_rows(datasette, database, request, rows, columns):
value=value, value=value,
column=column, column=column,
table=None, table=None,
pks=[],
database=database, database=database,
datasette=datasette, datasette=datasette,
request=request, request=request,
column_type=None,
): ):
candidate = await await_me_maybe(candidate) candidate = await await_me_maybe(candidate)
if candidate is not None: if candidate is not None:

View file

@ -1,472 +0,0 @@
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,
_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,
"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)
)
)

View file

@ -12,6 +12,7 @@ from datasette.version import __version__
from .base import BaseView from .base import BaseView
# Truncate table list on homepage at: # Truncate table list on homepage at:
TRUNCATE_AT = 5 TRUNCATE_AT = 5

View file

@ -1,605 +0,0 @@
import json
import re
from datasette.resources import DatabaseResource
from datasette.stored_queries import (
StoredQuery,
)
from datasette.write_sql import (
IgnoreWriteSqlOperation,
QueryWriteRejected,
RequireWriteSqlPermissions,
decision_for_write_sql_operation,
operation_is_write,
)
from datasette.utils import (
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",
}
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 = {
key: value
for key, value in data.items()
if key not in {"sql", "csrftoken", "_json"}
}
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 _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

View file

@ -5,16 +5,14 @@ from datasette.resources import TableResource
from .base import DataView, BaseView, _error from .base import DataView, BaseView, _error
from datasette.utils import ( from datasette.utils import (
await_me_maybe, await_me_maybe,
CustomRow,
make_slot_function, make_slot_function,
to_css_class, to_css_class,
escape_sqlite, escape_sqlite,
) )
from datasette.plugins import pm from datasette.plugins import pm
import json import json
import markupsafe
import sqlite_utils import sqlite_utils
from .table import display_columns_and_rows, _get_extras from .table import display_columns_and_rows
class RowView(DataView): class RowView(DataView):
@ -44,62 +42,13 @@ class RowView(DataView):
if not rows: if not rows:
raise NotFound(f"Record not found: {pk_values}") raise NotFound(f"Record not found: {pk_values}")
pks = resolved.pks
async def template_data(): async def template_data():
# 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]
non_pk_cols = [d for d in results.description if d[0] not in pk_set]
reordered_description = pk_cols + non_pk_cols
reordered_columns = [d[0] for d in reordered_description]
# Reorder row data to match
reordered_rows = []
for row in rows:
new_row = CustomRow(reordered_columns)
for col in reordered_columns:
new_row[col] = row[col]
reordered_rows.append(new_row)
# Expand foreign key columns into dicts so display_columns_and_rows
# renders them as hyperlinks, matching the table view behavior
expanded_rows = reordered_rows
for fk in await db.foreign_keys_for_table(table):
column = fk["column"]
if column not in reordered_columns:
continue
column_index = reordered_columns.index(column)
values = [row[column_index] for row in expanded_rows]
expanded_labels = await self.ds.expand_foreign_keys(
request.actor, database, table, column, values
)
if expanded_labels:
new_rows = []
for row in expanded_rows:
new_row = CustomRow(reordered_columns)
for col in reordered_columns:
value = row[col]
if (
col == column
and (col, value) in expanded_labels
and value is not None
):
new_row[col] = {
"value": value,
"label": expanded_labels[(col, value)],
}
else:
new_row[col] = value
new_rows.append(new_row)
expanded_rows = new_rows
display_columns, display_rows = await display_columns_and_rows( display_columns, display_rows = await display_columns_and_rows(
self.ds, self.ds,
database, database,
table, table,
reordered_description, results.description,
expanded_rows, rows,
link_column=False, link_column=False,
truncate_cells=0, truncate_cells=0,
request=request, request=request,
@ -107,14 +56,6 @@ class RowView(DataView):
for column in display_columns: for column in display_columns:
column["sortable"] = False column["sortable"] = False
# Bold primary key cell values
for row in display_rows:
for cell in row:
if cell["column"] in pk_set:
cell["value"] = markupsafe.Markup(
"<strong>{}</strong>".format(cell["value"])
)
row_actions = [] row_actions = []
for hook in pm.hook.row_actions( for hook in pm.hook.row_actions(
datasette=self.ds, datasette=self.ds,
@ -130,7 +71,6 @@ class RowView(DataView):
return { return {
"private": private, "private": private,
"columns": reordered_columns,
"foreign_key_tables": await self.foreign_key_tables( "foreign_key_tables": await self.foreign_key_tables(
database, table, pk_values database, table, pk_values
), ),
@ -155,7 +95,6 @@ class RowView(DataView):
} }
data = { data = {
"ok": True,
"database": database, "database": database,
"table": table, "table": table,
"rows": rows, "rows": rows,
@ -164,61 +103,11 @@ class RowView(DataView):
"primary_key_values": pk_values, "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(","): if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
extras.add("foreign_key_tables")
# Process extras
if "foreign_key_tables" in extras:
data["foreign_key_tables"] = await self.foreign_key_tables( data["foreign_key_tables"] = await self.foreign_key_tables(
database, table, pk_values 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
return ( return (
data, data,
template_data, template_data,
@ -321,7 +210,7 @@ class RowDeleteView(BaseView):
sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values) sqlite_utils.Database(conn)[resolved.table].delete(resolved.pk_values)
try: try:
await resolved.db.execute_write_fn(delete_row, request=request) await resolved.db.execute_write_fn(delete_row)
except Exception as e: except Exception as e:
return _error([str(e)], 500) return _error([str(e)], 500)
@ -367,15 +256,6 @@ class RowUpdateView(BaseView):
update = data["update"] update = data["update"]
# Validate column types
from datasette.views.table import _validate_column_types
ct_errors = await _validate_column_types(
self.ds, resolved.db.name, resolved.table, [update]
)
if ct_errors:
return _error(ct_errors, 400)
alter = data.get("alter") alter = data.get("alter")
if alter and not await self.ds.allowed( if alter and not await self.ds.allowed(
action="alter-table", action="alter-table",
@ -390,7 +270,7 @@ class RowUpdateView(BaseView):
) )
try: try:
await resolved.db.execute_write_fn(update_row, request=request) await resolved.db.execute_write_fn(update_row)
except Exception as e: except Exception as e:
return _error([str(e)], 400) return _error([str(e)], 400)

View file

@ -1,14 +1,11 @@
import json import json
import logging import logging
from datasette.jump import JumpSQL, namespace_sql_params
from datasette.plugins import pm
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden from datasette.utils.asgi import Response, Forbidden
from datasette.utils import ( from datasette.utils import (
actor_matches_allow, actor_matches_allow,
add_cors_headers, add_cors_headers,
await_me_maybe,
tilde_encode, tilde_encode,
tilde_decode, tilde_decode,
) )
@ -16,6 +13,7 @@ from .base import BaseView, View
import secrets import secrets
import urllib import urllib
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,7 +65,7 @@ class JsonDataView(BaseView):
context = { context = {
"filename": self.filename, "filename": self.filename,
"data": data, "data": data,
"data_json": json.dumps(data, indent=2, default=repr), "data_json": json.dumps(data, indent=4, default=repr),
} }
# Add has_debug_permission if this view requires permissions-debug # Add has_debug_permission if this view requires permissions-debug
if self.permission == "permissions-debug": if self.permission == "permissions-debug":
@ -179,11 +177,11 @@ class PermissionsDebugView(BaseView):
async def post(self, request): async def post(self, request):
await self.ds.ensure_permission(action="view-instance", actor=request.actor) await self.ds.ensure_permission(action="view-instance", actor=request.actor)
await self.ds.ensure_permission(action="permissions-debug", actor=request.actor) await self.ds.ensure_permission(action="permissions-debug", actor=request.actor)
form = await request.form() vars = await request.post_vars()
actor = json.loads(form["actor"]) actor = json.loads(vars["actor"])
permission = form["permission"] permission = vars["permission"]
parent = form.get("resource_1") or None parent = vars.get("resource_1") or None
child = form.get("resource_2") or None child = vars.get("resource_2") or None
response, status = await _check_permission_for_actor( response, status = await _check_permission_for_actor(
self.ds, permission, parent, child, actor self.ds, permission, parent, child, actor
@ -604,9 +602,9 @@ class MessagesDebugView(BaseView):
async def post(self, request): async def post(self, request):
await self.ds.ensure_permission(action="view-instance", actor=request.actor) await self.ds.ensure_permission(action="view-instance", actor=request.actor)
form = await request.form() post = await request.post_vars()
message = form.get("message", "") message = post.get("message", "")
message_type = form.get("message_type") or "INFO" message_type = post.get("message_type") or "INFO"
assert message_type in ("INFO", "WARNING", "ERROR", "all") assert message_type in ("INFO", "WARNING", "ERROR", "all")
datasette = self.ds datasette = self.ds
if message_type == "all": if message_type == "all":
@ -690,11 +688,11 @@ class CreateTokenView(BaseView):
async def post(self, request): async def post(self, request):
self.check_permission(request) self.check_permission(request)
form = await request.form() post = await request.post_vars()
errors = [] errors = []
expires_after = None expires_after = None
if form.get("expire_type"): if post.get("expire_type"):
duration_string = form.get("expire_duration") duration_string = post.get("expire_duration")
if ( if (
not duration_string not duration_string
or not duration_string.isdigit() or not duration_string.isdigit()
@ -702,7 +700,7 @@ class CreateTokenView(BaseView):
): ):
errors.append("Invalid expire duration") errors.append("Invalid expire duration")
else: else:
unit = form["expire_type"] unit = post["expire_type"]
if unit == "minutes": if unit == "minutes":
expires_after = int(duration_string) * 60 expires_after = int(duration_string) * 60
elif unit == "hours": elif unit == "hours":
@ -713,36 +711,42 @@ class CreateTokenView(BaseView):
errors.append("Invalid expire duration unit") errors.append("Invalid expire duration unit")
# Are there any restrictions? # Are there any restrictions?
from datasette.tokens import TokenRestrictions restrict_all = []
restrict_database = {}
restrict_resource = {}
restrictions = TokenRestrictions() for key in post:
for key in form:
if key.startswith("all:") and key.count(":") == 1: if key.startswith("all:") and key.count(":") == 1:
restrictions.allow_all(key.split(":")[1]) restrict_all.append(key.split(":")[1])
elif key.startswith("database:") and key.count(":") == 2: elif key.startswith("database:") and key.count(":") == 2:
bits = key.split(":") bits = key.split(":")
restrictions.allow_database(tilde_decode(bits[1]), bits[2]) database = tilde_decode(bits[1])
action = bits[2]
restrict_database.setdefault(database, []).append(action)
elif key.startswith("resource:") and key.count(":") == 3: elif key.startswith("resource:") and key.count(":") == 3:
bits = key.split(":") bits = key.split(":")
restrictions.allow_resource( database = tilde_decode(bits[1])
tilde_decode(bits[1]), tilde_decode(bits[2]), bits[3] resource = tilde_decode(bits[2])
) action = bits[3]
restrict_resource.setdefault(database, {}).setdefault(
resource, []
).append(action)
token = await self.ds.create_token( token = self.ds.create_token(
request.actor["id"], request.actor["id"],
expires_after=expires_after, expires_after=expires_after,
restrictions=restrictions, restrict_all=restrict_all,
handler="signed", restrict_database=restrict_database,
restrict_resource=restrict_resource,
) )
token_bits = self.ds.unsign(token[len("dstok_") :], namespace="token") token_bits = self.ds.unsign(token[len("dstok_") :], namespace="token")
await self.ds.track_event( await self.ds.track_event(
CreateTokenEvent( CreateTokenEvent(
actor=request.actor, actor=request.actor,
expires_after=expires_after, expires_after=expires_after,
restrict_all=restrictions.all, restrict_all=restrict_all,
restrict_database=restrictions.database, restrict_database=restrict_database,
restrict_resource=restrictions.resource, restrict_resource=restrict_resource,
) )
) )
context = await self.shared(request) context = await self.shared(request)
@ -816,18 +820,9 @@ class ApiExplorerView(BaseView):
"json": { "json": {
"rows": [ "rows": [
{ {
column: "<{}{}>".format( column: None
column, for column in await db.table_columns(table)
( if column not in pks
" (primary key)"
if column in (pks or ["rowid"])
else ""
),
)
for column in (
(["rowid"] if not pks else [])
+ await db.table_columns(table)
)
} }
] ]
}, },
@ -892,15 +887,14 @@ class ApiExplorerView(BaseView):
raise Forbidden("You do not have permission to view this instance") raise Forbidden("You do not have permission to view this instance")
def api_path(link): def api_path(link):
return "{}#{}".format( return "/-/api#{}".format(
self.ds.urls.path("/-/api"),
urllib.parse.urlencode( urllib.parse.urlencode(
{ {
key: json.dumps(value, indent=2) if key == "json" else value key: json.dumps(value, indent=2) if key == "json" else value
for key, value in link.items() for key, value in link.items()
if key in ("path", "method", "json") if key in ("path", "method", "json")
} }
), )
) )
return await self.render( return await self.render(
@ -914,183 +908,75 @@ class ApiExplorerView(BaseView):
) )
class JumpView(BaseView): class TablesView(BaseView):
""" """
Endpoint for the jump menu. Returns JSON navigation items the actor can use. 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.
""" """
name = "jump" name = "tables"
has_json_alternate = False has_json_alternate = False
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
def _resolve_url(self, url):
if not url or url.startswith("/"):
return url
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
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:
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}",
)
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)
async def get(self, request): async def get(self, request):
# Get search query parameter
q = request.args.get("q", "").strip() q = request.args.get("q", "").strip()
terms = q.split()
pattern = "%" + "%".join(terms) + "%" if terms else "%"
fragments = await self._fragments(request)
fragments_by_database = {} # Get SQL for allowed resources using the permission system
for index, fragment in enumerate(fragments): permission_sql, params = await self.ds.allowed_resources_sql(
fragments_by_database.setdefault(fragment.database, []).append( action="view-table", actor=request.actor
(index, fragment) )
# 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) + "%"
# Build query with CTE to filter by search pattern
sql = f"""
WITH allowed_tables AS (
{permission_sql}
) )
SELECT parent, child
rows = [] FROM allowed_tables
truncated = False WHERE child LIKE :pattern COLLATE NOCASE
for database_name, indexed_fragments in fragments_by_database.items(): ORDER BY length(child), child
database_rows = await self._rows_for_database( """
database_name, indexed_fragments, q, pattern all_params = {**params, "pattern": pattern}
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}
) )
if len(database_rows) > 100: SELECT parent, child
truncated = True FROM allowed_tables
database_rows = database_rows[:100] ORDER BY parent, child
rows.extend(database_rows) LIMIT 101
rows.sort(key=lambda row: self._sort_key(row, q)) """
all_params = params
if len(rows) > 100: # Execute against internal database
truncated = True result = await self.ds.get_internal_database().execute(sql, all_params)
# Build response with truncation
rows = list(result.rows)
truncated = len(rows) > 100
if truncated:
rows = rows[:100] rows = rows[:100]
matches = [] matches = [
for row in rows: {
match = { "name": f"{row['parent']}: {row['child']}",
"name": row["label"], "url": self.ds.urls.table(row["parent"], row["child"]),
"url": self._resolve_url(row["url"]),
"type": row["type"],
"description": row["description"],
} }
if row["display_name"]: for row in rows
match["display_name"] = row["display_name"] ]
matches.append(match)
return Response.json({"matches": matches, "truncated": truncated}) return Response.json({"matches": matches, "truncated": truncated})

View file

@ -1,483 +0,0 @@
from urllib.parse import parse_qsl, urlencode
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
from datasette.utils import 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_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 QueryDeleteView(BaseView):
name = "query-delete"
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="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need delete-query"], 403)
await self.ds.remove_query(db.name, query_name)
return Response.json({"ok": True})

View file

@ -134,22 +134,6 @@ async def _redirect_if_needed(datasette, request, resolved):
) )
async def _validate_column_types(datasette, database_name, table_name, rows):
"""Validate row values against assigned column types. Returns list of error strings."""
ct_map = await datasette.get_column_types(database_name, table_name)
if not ct_map:
return []
errors = []
for row in rows:
for col_name, ct in ct_map.items():
if col_name not in row:
continue
error = await ct.validate(row[col_name], datasette)
if error:
errors.append(f"{col_name}: {error}")
return errors
async def display_columns_and_rows( async def display_columns_and_rows(
datasette, datasette,
database_name, database_name,
@ -179,9 +163,6 @@ async def display_columns_and_rows(
) )
) )
# Look up column types for this table
column_types_map = await datasette.get_column_types(database_name, table_name)
column_details = { column_details = {
col.name: col for col in await db.table_column_details(table_name) col.name: col for col in await db.table_column_details(table_name)
} }
@ -198,21 +179,16 @@ async def display_columns_and_rows(
else: else:
type_ = column_details[r[0]].type type_ = column_details[r[0]].type
notnull = column_details[r[0]].notnull notnull = column_details[r[0]].notnull
col_dict = { columns.append(
"name": r[0], {
"sortable": r[0] in sortable_columns, "name": r[0],
"is_pk": r[0] in pks_for_display, "sortable": r[0] in sortable_columns,
"type": type_, "is_pk": r[0] in pks_for_display,
"notnull": notnull, "type": type_,
"description": column_descriptions.get(r[0]), "notnull": notnull,
"column_type": None, "description": column_descriptions.get(r[0]),
"column_type_config": None, }
} )
ct = column_types_map.get(r[0])
if ct:
col_dict["column_type"] = ct.name
col_dict["column_type_config"] = ct.config
columns.append(col_dict)
column_to_foreign_key_table = { column_to_foreign_key_table = {
fk["column"]: fk["other_table"] fk["column"]: fk["other_table"]
@ -251,37 +227,22 @@ async def display_columns_and_rows(
# already shown in the link column. # already shown in the link column.
continue continue
# First try column type render_cell, then plugins # First let the plugins have a go
# pylint: disable=no-member # pylint: disable=no-member
plugin_display_value = None plugin_display_value = None
ct = column_types_map.get(column) for candidate in pm.hook.render_cell(
if ct: row=row,
candidate = await ct.render_cell( value=value,
value=value, column=column,
column=column, table=table_name,
table=table_name, database=database_name,
database=database_name, datasette=datasette,
datasette=datasette, request=request,
request=request, ):
) candidate = await await_me_maybe(candidate)
if candidate is not None: if candidate is not None:
plugin_display_value = candidate plugin_display_value = candidate
if plugin_display_value is None: break
for candidate in pm.hook.render_cell(
row=row,
value=value,
column=column,
table=table_name,
pks=pks_for_display,
database=database_name,
datasette=datasette,
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: if plugin_display_value:
display_value = plugin_display_value display_value = plugin_display_value
elif isinstance(value, bytes): elif isinstance(value, bytes):
@ -369,7 +330,6 @@ async def display_columns_and_rows(
"is_pk": False, "is_pk": False,
"type": "", "type": "",
"notnull": 0, "notnull": 0,
"is_special_link_column": True,
} }
columns = [first_column] + columns columns = [first_column] + columns
return columns, cell_rows return columns, cell_rows
@ -461,13 +421,6 @@ class TableInsertView(BaseView):
i, '", "'.join(missing_pks) i, '", "'.join(missing_pks)
) )
) )
null_pks = [pk for pk in pks_list if pk in row and row[pk] is None]
if null_pks:
errors.append(
'Row {} has null primary key column(s): "{}"'.format(
i, '", "'.join(null_pks)
)
)
invalid_columns = set(row.keys()) - columns invalid_columns = set(row.keys()) - columns
if invalid_columns and not extras.get("alter"): if invalid_columns and not extras.get("alter"):
errors.append( errors.append(
@ -530,13 +483,6 @@ class TableInsertView(BaseView):
if errors: if errors:
return _error(errors, 400) return _error(errors, 400)
# Validate column types
ct_errors = await _validate_column_types(
self.ds, database_name, table_name, rows
)
if ct_errors:
return _error(ct_errors, 400)
num_rows = len(rows) num_rows = len(rows)
# No that we've passed pks to _validate_data it's safe to # No that we've passed pks to _validate_data it's safe to
@ -604,7 +550,7 @@ class TableInsertView(BaseView):
method_all(rows, **kwargs) method_all(rows, **kwargs)
try: try:
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request) rows = await db.execute_write_fn(insert_or_upsert_rows)
except Exception as e: except Exception as e:
return _error([str(e)]) return _error([str(e)])
result = {"ok": True} result = {"ok": True}
@ -673,122 +619,6 @@ class TableUpsertView(TableInsertView):
return await super().post(request, upsert=True) return await super().post(request, upsert=True)
class TableSetColumnTypeView(BaseView):
name = "table-set-column-type"
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
database_name = resolved.db.name
table_name = resolved.table
if not await self.ds.allowed(
action="set-column-type",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
content_type = request.headers.get("content-type") or ""
if not content_type.startswith("application/json"):
return _error(["Invalid content-type, must be application/json"], 400)
try:
data = json.loads(await request.post_body())
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
invalid_keys = set(data.keys()) - {"column", "column_type"}
if invalid_keys:
return _error(
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
400,
)
if "column" not in data:
return _error(['"column" is required'], 400)
column = data["column"]
if not isinstance(column, str):
return _error(['"column" must be a string'], 400)
if "column_type" not in data:
return _error(['"column_type" is required'], 400)
column_details = await self.ds._get_resource_column_details(
database_name, table_name
)
if column not in column_details:
return _error(["Column not found: {}".format(column)], 400)
column_type_data = data["column_type"]
if column_type_data is None:
await self.ds.remove_column_type(database_name, table_name, column)
return Response.json(
{
"ok": True,
"database": database_name,
"table": table_name,
"column": column,
"column_type": None,
},
status=200,
)
if not isinstance(column_type_data, dict):
return _error(['"column_type" must be an object or null'], 400)
invalid_column_type_keys = set(column_type_data.keys()) - {"type", "config"}
if invalid_column_type_keys:
return _error(
[
'Invalid column_type parameter: "{}"'.format(
'", "'.join(sorted(invalid_column_type_keys))
)
],
400,
)
if "type" not in column_type_data:
return _error(['"column_type.type" is required'], 400)
column_type = column_type_data["type"]
if not isinstance(column_type, str):
return _error(['"column_type.type" must be a string'], 400)
config = column_type_data.get("config")
if config is not None and not isinstance(config, dict):
return _error(['"column_type.config" must be a dictionary'], 400)
if column_type not in self.ds._column_types:
return _error(["Unknown column type: {}".format(column_type)], 400)
try:
await self.ds.set_column_type(
database_name, table_name, column, column_type, config
)
except ValueError as e:
return _error([str(e)], 400)
return Response.json(
{
"ok": True,
"database": database_name,
"table": table_name,
"column": column,
"column_type": {"type": column_type, "config": config},
},
status=200,
)
class TableDropView(BaseView): class TableDropView(BaseView):
name = "table-drop" name = "table-drop"
@ -840,7 +670,7 @@ class TableDropView(BaseView):
def drop_table(conn): def drop_table(conn):
sqlite_utils.Database(conn)[table_name].drop() sqlite_utils.Database(conn)[table_name].drop()
await db.execute_write_fn(drop_table, request=request) await db.execute_write_fn(drop_table)
await self.ds.track_event( await self.ds.track_event(
DropTableEvent( DropTableEvent(
actor=request.actor, database=database_name, table=table_name actor=request.actor, database=database_name, table=table_name
@ -963,12 +793,12 @@ async def table_view_traced(datasette, request):
try: try:
resolved = await datasette.resolve_table(request) resolved = await datasette.resolve_table(request)
except TableNotFound as not_found: except TableNotFound as not_found:
# Was this actually a stored query? # Was this actually a canned query?
stored_query = await datasette.get_query( canned_query = await datasette.get_canned_query(
not_found.database_name, not_found.table not_found.database_name, not_found.table, request.actor
) )
# If this is a stored query, not a table, then dispatch to QueryView instead # If this is a canned query, not a table, then dispatch to QueryView instead
if stored_query: if canned_query:
return await QueryView()(request, datasette) return await QueryView()(request, datasette)
else: else:
raise raise
@ -1072,13 +902,7 @@ async def table_view_traced(datasette, request):
template = environment.select_template(templates) template = environment.select_template(templates)
alternate_url_json = datasette.absolute_url( alternate_url_json = datasette.absolute_url(
request, request,
datasette.urls.path( datasette.urls.path(path_with_format(request=request, format="json")),
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
) )
headers.update( headers.update(
{ {
@ -1596,10 +1420,6 @@ async def table_view_data(
"Column names returned by this query" "Column names returned by this query"
return columns return columns
async def extra_all_columns():
"All columns in the table, regardless of _col/_nocol filtering"
return list(table_columns)
async def extra_primary_keys(): async def extra_primary_keys():
"Primary keys for this table" "Primary keys for this table"
return pks return pks
@ -1672,50 +1492,6 @@ async def table_view_data(
async def extra_display_rows(run_display_columns_and_rows): async def extra_display_rows(run_display_columns_and_rows):
return run_display_columns_and_rows["rows"] return run_display_columns_and_rows["rows"]
async def extra_render_cell():
"Rendered HTML for each cell using the render_cell plugin hook"
pks_for_display = pks if pks else (["rowid"] if not is_view else [])
col_names = [col[0] for col in results.description]
ct_map = await datasette.get_column_types(database_name, table_name)
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, col_names):
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_name,
database=database_name,
datasette=datasette,
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_name,
pks=pks_for_display,
database=database_name,
datasette=datasette,
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)
return rendered_rows
async def extra_query(): async def extra_query():
"Details of the underlying SQL query" "Details of the underlying SQL query"
return { return {
@ -1723,58 +1499,6 @@ async def table_view_data(
"params": params, "params": params,
} }
async def extra_column_types():
"Column type assignments for this table"
ct_map = await datasette.get_column_types(database_name, table_name)
return {
col_name: {
"type": ct.name,
"config": ct.config,
}
for col_name, ct in ct_map.items()
}
async def extra_set_column_type_ui():
"Column type UI metadata for this table"
if is_view:
return None
if not await datasette.allowed(
action="set-column-type",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return None
column_details = await datasette._get_resource_column_details(
database_name, table_name
)
ct_map = await datasette.get_column_types(database_name, table_name)
columns = {}
for column_name, column_detail in column_details.items():
current = ct_map.get(column_name)
columns[column_name] = {
"current": (
{"type": current.name, "config": current.config}
if current is not None
else None
),
"options": [
{
"name": name,
"description": ct_cls.description,
}
for name, ct_cls in sorted(datasette._column_types.items())
if datasette._column_type_is_applicable(ct_cls, column_detail)
],
}
return {
"path": "{}/-/set-column-type".format(
datasette.urls.table(database_name, table_name)
),
"columns": columns,
}
async def extra_metadata(): async def extra_metadata():
"Metadata about the table and database" "Metadata about the table and database"
tablemetadata = await datasette.get_resource_metadata(database_name, table_name) tablemetadata = await datasette.get_resource_metadata(database_name, table_name)
@ -1826,35 +1550,11 @@ async def table_view_data(
] ]
async def extra_sorted_facet_results(extra_facet_results): async def extra_sorted_facet_results(extra_facet_results):
facet_configs = table_metadata.get("facets", []) return sorted(
if facet_configs: extra_facet_results["results"].values(),
# Build ordered list of facet names from metadata config key=lambda f: (len(f["results"]), f["name"]),
metadata_facet_names = [] reverse=True,
for fc in facet_configs: )
if isinstance(fc, str):
metadata_facet_names.append(fc)
elif isinstance(fc, dict):
metadata_facet_names.append(list(fc.values())[0])
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
metadata_facets = []
request_facets = []
for f in extra_facet_results["results"].values():
if f["name"] in metadata_order:
metadata_facets.append(f)
else:
request_facets.append(f)
metadata_facets.sort(key=lambda f: metadata_order[f["name"]])
request_facets.sort(
key=lambda f: (len(f["results"]), f["name"]),
reverse=True,
)
return metadata_facets + request_facets
else:
return sorted(
extra_facet_results["results"].values(),
key=lambda f: (len(f["results"]), f["name"]),
reverse=True,
)
async def extra_table_definition(): async def extra_table_definition():
return await db.get_table_definition(table_name) return await db.get_table_definition(table_name)
@ -1884,10 +1584,7 @@ async def table_view_data(
if it_can_render: if it_can_render:
renderers[key] = datasette.urls.path( renderers[key] = datasette.urls.path(
path_with_format( path_with_format(
request=request, request=request, format=key, extra_qs={**url_labels_extra}
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
) )
) )
return renderers return renderers
@ -1957,10 +1654,8 @@ async def table_view_data(
"is_view", "is_view",
"private", "private",
"primary_keys", "primary_keys",
"all_columns",
"expandable_columns", "expandable_columns",
"form_hidden_args", "form_hidden_args",
"set_column_type_ui",
] ]
} }
@ -1979,17 +1674,13 @@ async def table_view_data(
extra_human_description_en, extra_human_description_en,
extra_next_url, extra_next_url,
extra_columns, extra_columns,
extra_all_columns,
extra_primary_keys, extra_primary_keys,
run_display_columns_and_rows, run_display_columns_and_rows,
extra_display_columns, extra_display_columns,
extra_display_rows, extra_display_rows,
extra_render_cell,
extra_debug, extra_debug,
extra_request, extra_request,
extra_query, extra_query,
extra_column_types,
extra_set_column_type_ui,
extra_metadata, extra_metadata,
extra_extras, extra_extras,
extra_database, extra_database,
@ -2023,18 +1714,7 @@ async def table_view_data(
} }
) )
raw_sqlite_rows = rows[:page_size] raw_sqlite_rows = rows[:page_size]
# Apply transform_value for columns with assigned types data["rows"] = [dict(r) for r in raw_sqlite_rows]
ct_map = await datasette.get_column_types(database_name, table_name)
transformed_rows = []
for r in raw_sqlite_rows:
row_dict = dict(r)
for col_name, ct in ct_map.items():
if col_name in row_dict:
row_dict[col_name] = await ct.transform_value(
row_dict[col_name], datasette
)
transformed_rows.append(row_dict)
data["rows"] = transformed_rows
if context_for_html_hack: if context_for_html_hack:
data.update(extra_context_from_filters) data.update(extra_context_from_filters)
@ -2051,12 +1731,7 @@ async def table_view_data(
url_labels_extra = {"_labels": "on"} url_labels_extra = {"_labels": "on"}
url_csv_args = {"_size": "max", **url_labels_extra} url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = datasette.urls.path( url_csv = datasette.urls.path(
path_with_format( path_with_format(request=request, format="csv", extra_qs=url_csv_args)
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
) )
url_csv_path = url_csv.split("?")[0] url_csv_path = url_csv.split("?")[0]
data.update( data.update(

View file

@ -1,253 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .permissions import Resource
from .resources import DatabaseResource, TableResource
from .utils import named_parameters, sqlite3
from .utils.asgi import Forbidden
from .utils.sql_analysis import Operation, SQLAnalysis
if TYPE_CHECKING:
from .app import Datasette
class QueryWriteRejected(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
@dataclass(frozen=True)
class PermissionRequirement:
action: str
resource: Resource
PermissionRequirements = tuple[PermissionRequirement, ...]
class WriteSqlOperationDecision:
"""What Datasette should do with one operation in user-supplied write SQL."""
@dataclass(frozen=True)
class IgnoreWriteSqlOperation(WriteSqlOperationDecision):
reason: str
@dataclass(frozen=True)
class RequireWriteSqlPermissions(WriteSqlOperationDecision):
permissions: PermissionRequirements
@dataclass(frozen=True)
class RejectWriteSqlOperation(WriteSqlOperationDecision):
message: str
@dataclass(frozen=True)
class UnsupportedWriteSqlOperation(WriteSqlOperationDecision):
message: str
def row_mutation_requirements(database: str, table: str) -> PermissionRequirements:
resource = TableResource(database=database, table=table)
return tuple(
PermissionRequirement(action=action, resource=resource)
for action in ("insert-row", "update-row", "delete-row")
)
def decision_for_write_sql_operation(
operation: Operation,
) -> WriteSqlOperationDecision:
unsupported_message = (
f"Unsupported SQL operation: {operation.operation} {operation.target_type}"
)
if operation.internal:
return IgnoreWriteSqlOperation("internal SQLite operation")
if operation.operation == "select":
return IgnoreWriteSqlOperation("select statement")
if operation.operation == "vacuum":
return RejectWriteSqlOperation("VACUUM is not allowed in user-supplied SQL")
if operation.operation in {"insert", "update", "delete"}:
if operation.table_kind == "virtual":
return RejectWriteSqlOperation(
"Writes to virtual tables are not allowed in user-supplied SQL"
)
if operation.table_kind == "shadow":
return RejectWriteSqlOperation(
"Writes to shadow tables are not allowed in user-supplied SQL"
)
if operation.operation == "function":
return IgnoreWriteSqlOperation("SQL function")
if (
operation.operation == "read"
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="view-table",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
if (
operation.operation in {"insert", "update"}
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
row_mutation_requirements(
database=operation.database,
table=operation.table,
)
)
if (
operation.operation == "delete"
and operation.target_type == "table"
and operation.database is not None
and operation.table is not None
):
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="delete-row",
resource=TableResource(
database=operation.database, table=operation.table
),
),
)
)
if operation.operation == "create" and operation.target_type == "table":
if operation.database is None:
return UnsupportedWriteSqlOperation(unsupported_message)
return RequireWriteSqlPermissions(
(
PermissionRequirement(
action="create-table",
resource=DatabaseResource(database=operation.database),
),
)
)
if (
operation.operation == "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

View file

@ -33,7 +33,7 @@ The one exception is the "root" account, which you can sign into while using Dat
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including: The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.) * All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``) * All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``drop-table``)
* Debug permissions (``permissions-debug``, ``debug-menu``) * Debug permissions (``permissions-debug``, ``debug-menu``)
* Any custom permissions defined by plugins * Any custom permissions defined by plugins
@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
How permissions are resolved 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. ``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 the entire Datasette instance
* Access to specific databases * Access to specific databases
* Access to specific tables and views * Access to specific tables and views
* Access to specific :ref:`queries <queries>` * Access to specific :ref:`canned_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. 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: .. _authentication_permissions_query:
Access to specific queries Access to specific canned queries
-------------------------- ---------------------------------
: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. :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.
To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`: To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
.. [[[cog .. [[[cog
config_example(cog, """ config_example(cog, """
@ -886,8 +886,6 @@ To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs``
} }
.. [[[end]]] .. [[[end]]]
Other table-scoped write permissions, including ``set-column-type``, can be configured in the same place.
And for ``insert-row`` against the ``reports`` table in that ``docs`` database: And for ``insert-row`` against the ``reports`` table in that ``docs`` database:
.. [[[cog .. [[[cog
@ -1020,7 +1018,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. 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 <queries>` - within a specific database:: Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <canned_queries>` - within a specific database::
datasette create-token root --resource mydatabase mytable insert-row datasette create-token root --resource mydatabase mytable insert-row
@ -1074,7 +1072,6 @@ cannot grant new access. If the underlying actor is denied by ``allow`` rules in
``datasette.yaml`` or by a plugin, a token that lists that resource in its ``datasette.yaml`` or by a plugin, a token that lists that resource in its
``"_r"`` section will still be denied. ``"_r"`` section will still be denied.
To create tokens with restrictions in Python code, use the :ref:`TokenRestrictions <TokenRestrictions>` builder and pass it to :ref:`datasette.create_token() <datasette_create_token>`.
.. _permissions_plugins: .. _permissions_plugins:
@ -1285,46 +1282,12 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
view-query view-query
---------- ----------
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. 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`.
``resource`` - ``datasette.resources.QueryResource(database, query)`` ``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string) ``database`` is the name of the database (string)
``query`` is the name of the query (string) ``query`` is the name of the canned 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: .. _actions_insert_row:
@ -1379,18 +1342,6 @@ alter-table
Actor is allowed to alter a database table. Actor is allowed to alter a database table.
``resource`` - ``datasette.resources.TableResource(database, table)``
``database`` is the name of the database (string)
``table`` is the name of the table (string)
.. _actions_set_column_type:
set-column-type
---------------
Actor is allowed to set assigned :ref:`column types <table_configuration_column_types>` for columns in a table.
``resource`` - ``datasette.resources.TableResource(database, table)`` ``resource`` - ``datasette.resources.TableResource(database, table)``
``database`` is the name of the database (string) ``database`` is the name of the database (string)
@ -1413,23 +1364,13 @@ Actor is allowed to drop a database table.
execute-sql execute-sql
----------- -----------
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 Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
``resource`` - ``datasette.resources.DatabaseResource(database)`` ``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string) ``database`` is the name of the database (string)
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`. 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: .. _actions_permissions_debug:
permissions-debug permissions-debug
@ -1442,4 +1383,4 @@ Actor is allowed to view the ``/-/permissions`` debug tools.
debug-menu debug-menu
---------- ----------
Controls if the various debug pages are displayed in the jump menu. Controls if the various debug pages are displayed in the navigation menu.

Some files were not shown because too many files have changed in this diff Show more