Compare commits

..

4 commits

Author SHA1 Message Date
Simon Willison
a668168835 Fix bug with compound pks and row panel 2026-03-09 18:18:50 -07:00
Simon Willison
73091472cb Merge main into row-panel
Resolved conflicts:
- pyproject.toml: kept main's dependency-groups structure
- datasette/static/table.js: kept both initRowDetailPanel (row-panel)
  and openColumnChooser (main)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:12:45 -07:00
Simon Willison
472caf4edf Install Playwright in CI 2025-11-08 16:54:59 -08:00
Simon Willison
5e0cfa8b30 Initial prototype of row side panel, refs #2589 2025-11-08 16:43:47 -08:00
176 changed files with 4192 additions and 30921 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
steps:
- name: Check out datasette
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -57,7 +57,7 @@ jobs:
db.route = "alternative-route"
' > plugins/alternative_route.py
cp fixtures.db fixtures2.db
- name: And the counters writable stored query demo
- name: And the counters writable canned query demo
run: |
cat > plugins/counters.py <<EOF
from datasette import hookimpl
@ -69,24 +69,23 @@ jobs:
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 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
@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
# - name: Make some modifications to metadata.json
# run: |
@ -117,7 +116,7 @@ jobs:
--plugins-dir=plugins \
--branch=$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' \
--service "datasette-latest$SUFFIX" \
--secret $LATEST_DATASETTE_SECRET

View file

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

View file

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

View file

@ -14,7 +14,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -35,7 +35,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -56,7 +56,7 @@ jobs:
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -92,7 +92,7 @@ jobs:
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Build and push to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

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

View file

@ -9,7 +9,7 @@ jobs:
spellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

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

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

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

View file

@ -25,7 +25,7 @@ jobs:
#"3.23.1" # 2018-04-10, before UPSERT
]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:

View file

@ -12,7 +12,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -27,6 +27,14 @@ jobs:
run: |
pip install . --group dev
pip freeze
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers
- name: Install Playwright dependencies
run: |
playwright install
- name: Run tests
run: |
pytest -n auto -m "not serial"

View file

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

View file

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

4
.gitignore vendored
View file

@ -1,8 +1,6 @@
build-metadata.json
datasets.json
.playwright-mcp
scratchpad
.vscode
@ -133,4 +131,4 @@ tests/*.dylib
tests/*.so
tests/*.dll
.idea
.idea

View file

@ -1,123 +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
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
"datasette_active_instances", default=None
)
_original_init = None
def _install_tracking():
# datasette.app is imported lazily here rather than at module level:
# as a pytest11 entry point this module is imported during pytest
# startup, before pytest-cov starts measuring, so a module-level
# import would drag in all of datasette and make every import-time
# line in the package invisible to coverage
global _original_init
if _original_init is not None:
return
from datasette.app import Datasette
_original_init = Datasette.__init__
def _tracking_init(self, *args, **kwargs):
_original_init(self, *args, **kwargs)
instances = _active_instances.get()
if instances is not None:
instances.append(weakref.ref(self))
Datasette.__init__ = _tracking_init
def pytest_configure(config):
if _enabled(config):
_install_tracking()
def pytest_addoption(parser):
parser.addini(
"datasette_autoclose",
help=(
"Automatically close Datasette instances created inside test "
"bodies and function-scoped fixtures (default: true)."
),
default="true",
)
def _enabled(config) -> bool:
value = config.getini("datasette_autoclose")
if isinstance(value, bool):
return value
return str(value).strip().lower() not in ("false", "0", "no", "off")
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(item, nextitem):
"""Track Datasette instances across setup, call and teardown; close at end."""
if not _enabled(item.config):
yield
return
refs: list[weakref.ref] = []
token = _active_instances.set(refs)
try:
yield
finally:
_active_instances.reset(token)
for ref in reversed(refs):
ds = ref()
if ds is None:
continue
try:
ds.close()
except Exception as e:
item.warn(
pytest.PytestUnraisableExceptionWarning(
f"Error closing Datasette instance: {e!r}"
)
)
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef, request):
"""Exempt instances created by non-function-scoped fixtures.
Session-, module-, class- and package-scoped fixtures produce Datasette
instances that must survive beyond the current test other tests in
the session will still use them. When such a fixture creates one or
more Datasette instances during its setup, we snapshot the tracking
list before the fixture runs and subtract off any instances that were
added during its setup, so they don't get closed at test teardown.
"""
refs = _active_instances.get()
if refs is None:
yield
return
before_ids = {id(ref) for ref in refs}
yield
if fixturedef.scope != "function":
new_refs = [ref for ref in refs if id(ref) not in before_ids]
for new_ref in new_refs:
try:
refs.remove(new_ref)
except ValueError:
pass

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,6 @@ from .app import (
SQLITE_LIMIT_ATTACHED,
pm,
)
from .inspect import inspect_tables
from .utils import (
LoadExtension,
StartupError,
@ -155,14 +154,14 @@ async def inspect_(files, sqlite_extensions):
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
data = {}
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] = {
"hash": database.hash,
"size": database.size,
"file": database.path,
"tables": {
table_name: {"count": table["count"]}
for table_name, table in tables.items()
table_name: {"count": table_count}
for table_name, table_count in counts.items()
},
}
return data
@ -616,9 +615,7 @@ def serve(
for file in file_paths:
if not pathlib.Path(file).exists():
if create:
conn = sqlite3.connect(file)
conn.execute("vacuum")
conn.close()
sqlite3.connect(file).execute("vacuum")
else:
raise click.ClickException(
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
@ -664,16 +661,15 @@ def serve(
# Private utility mechanism for writing unit tests
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
try:
run_sync(ds.invoke_startup)
except StartupError as e:
raise click.ClickException(e.args[0])
# Run async soundness checks - but only if we're not under pytest
run_sync(lambda: check_databases(ds))
if headers and not get:
raise click.ClickException("--headers can only be used with --get")

View file

@ -1,81 +0,0 @@
from enum import Enum
class SQLiteType(Enum):
TEXT = "TEXT"
INTEGER = "INTEGER"
REAL = "REAL"
BLOB = "BLOB"
NUMERIC = "NUMERIC"
@classmethod
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType":
if declared_type is None:
return cls.BLOB
normalized = declared_type.strip().upper()
if not normalized:
return cls.BLOB
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 cls.NUMERIC
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 atexit
from collections import namedtuple
import inspect
import os
from pathlib import Path
import janus
import queue
import sqlite_utils
import sys
import tempfile
import threading
import uuid
from .tracer import trace
from .utils import (
call_with_supported_arguments,
detect_fts,
detect_primary_keys,
detect_spatialite,
@ -25,24 +21,14 @@ from .utils import (
table_columns,
table_column_details,
)
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
from .utils.sqlite import sqlite_hidden_table_names
from .utils.sqlite import sqlite_version
from .inspect import inspect_hash
connections = threading.local()
EXECUTE_WRITE_RETURNING_LIMIT = 10
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
class DatasetteClosedError(RuntimeError):
"""Raised when using a Datasette or Database instance after close()."""
_SHUTDOWN = object()
class Database:
# For table counts stop at this many rows:
count_limit = 10000
@ -56,7 +42,6 @@ class Database:
is_memory=False,
memory_name=None,
mode=None,
is_temp_disk=False,
):
self.name = None
self._thread_local_id = f"x{self._thread_local_id_counter}"
@ -67,44 +52,19 @@ class Database:
self.is_mutable = is_mutable
self.is_memory = is_memory
self.memory_name = memory_name
self.is_temp_disk = is_temp_disk
if memory_name is not None:
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_size = None
self._cached_table_counts = None
self._write_thread = None
self._write_queue = None
self._closed = False
self._pending_execute_futures = set()
self._pending_execute_futures_lock = threading.Lock()
# These are used when in non-threaded mode:
self._read_connection = None
self._write_connection = None
# This is used to track all file connections so they can be closed
self._all_file_connections = []
if not is_temp_disk:
self.mode = mode
def _check_not_closed(self):
if self._closed:
raise DatasetteClosedError(
"Database {!r} has been closed".format(self.name)
)
def _remove_pending_execute_future(self, future):
with self._pending_execute_futures_lock:
self._pending_execute_futures.discard(future)
self.mode = mode
@property
def cached_table_counts(self):
@ -125,8 +85,6 @@ class Database:
return md5_not_usedforsecurity(self.name)[:6]
def suggest_name(self):
if self.is_temp_disk:
return "_temp_disk"
if self.path:
return Path(self.path).stem
elif self.memory_name:
@ -165,105 +123,22 @@ class Database:
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
)
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
def close(self):
"""Release all resources held by this database.
Idempotent. After close() further calls to execute()/execute_fn()/
execute_write()/execute_write_fn() raise DatasetteClosedError.
"""
if self._closed:
return
with self._pending_execute_futures_lock:
if self._closed:
return
self._closed = True
pending_execute_futures = tuple(self._pending_execute_futures)
# Shut down the write thread, if any, via a sentinel. The thread
# drains any writes already queued before the sentinel and then
# closes its own write connection and returns.
write_thread = self._write_thread
if write_thread is not None and self._write_queue is not None:
self._write_queue.put(_SHUTDOWN)
write_thread.join(timeout=10)
if write_thread.is_alive():
sys.stderr.write(
"Datasette: write thread for {!r} did not exit within 10s\n".format(
self.name
)
)
sys.stderr.flush()
for future in pending_execute_futures:
try:
future.result()
except Exception:
pass
# Close anything still tracked in _all_file_connections
# Close all connections - useful to avoid running out of file handles in tests
for connection in self._all_file_connections:
try:
connection.close()
except Exception:
pass
self._all_file_connections = []
# Drop per-thread cached read connections we can reach
try:
delattr(connections, self._thread_local_id)
except AttributeError:
pass
# Close non-threaded-mode cached connections if still open
if self._read_connection is not None:
try:
self._read_connection.close()
except Exception:
pass
self._read_connection = None
if self._write_connection is not None:
try:
self._write_connection.close()
except Exception:
pass
self._write_connection = None
if self.is_temp_disk:
self._cleanup_temp_file()
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")
connection.close()
async def execute_write(self, sql, params=None, block=True, request=None):
def _inner(conn):
cursor = conn.execute(sql, params or [])
return ExecuteWriteResult.from_cursor(
cursor, return_all=return_all, returning_limit=returning_limit
)
return conn.execute(sql, params or [])
with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn(_inner, block=block, request=request)
return results
async def execute_write_script(self, sql, block=True, request=None):
self._check_not_closed()
def _inner(conn):
return conn.executescript(sql)
@ -274,8 +149,6 @@ class Database:
return results
async def execute_write_many(self, sql, params_seq, block=True, request=None):
self._check_not_closed()
def _inner(conn):
count = 0
@ -297,15 +170,13 @@ class Database:
return results
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
write = self.is_mutable
def _run():
isolated_connection = self.connect(write=write)
if self.ds.executor is None:
# non-threaded mode
isolated_connection = self.connect(write=True)
try:
return fn(isolated_connection)
result = fn(isolated_connection)
finally:
isolated_connection.close()
try:
@ -313,34 +184,13 @@ class Database:
except ValueError:
# Was probably a memory connection
pass
if self.ds.executor is None:
# non-threaded mode
return _run()
if not write:
# Immutable database - no writes can ever occur, so there is no
# write queue to block; run against a fresh read-only connection
return await asyncio.get_running_loop().run_in_executor(
self.ds.executor, _run
)
# Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True)
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
self._check_not_closed()
return await self.execute_isolated_fn(
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
)
return result
else:
# Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True)
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)
fn = self._wrap_fn_with_hooks(fn, request, transaction)
if self.ds.executor is None:
# non-threaded mode
if self._write_connection is None:
@ -348,53 +198,17 @@ class Database:
self.ds._prepare_connection(self._write_connection, self.name)
if transaction:
with self._write_connection:
result = fn(self._write_connection)
return fn(self._write_connection)
else:
result = fn(self._write_connection)
return fn(self._write_connection)
else:
result = await self._send_to_write_thread(
return await self._send_to_write_thread(
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):
def _wrap_fn_with_hooks(self, fn, request, transaction):
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,
@ -406,9 +220,10 @@ class Database:
return fn
# Build the wrapped fn by nesting context manager generators.
# The first wrapper returned by pluggy is outermost.
original_fn = fn
for wrapper_factory in reversed(wrappers):
fn = _apply_write_wrapper(fn, wrapper_factory, track_event)
return fn
original_fn = _apply_write_wrapper(original_fn, wrapper_factory)
return original_fn
async def _send_to_write_thread(
self, fn, block=True, isolated_connection=False, transaction=True
@ -424,15 +239,18 @@ class Database:
)
self._write_thread.start()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
loop = asyncio.get_running_loop()
reply_future = loop.create_future()
reply_queue = janus.Queue()
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:
return await reply_future
result = await reply_queue.async_q.get()
if isinstance(result, Exception):
raise result
else:
return result
else:
return task_id, reply_future
return task_id
def _execute_writes(self):
# Infinite looping thread that protects the single write connection
@ -446,22 +264,17 @@ class Database:
conn_exception = e
while True:
task = self._write_queue.get()
if task is _SHUTDOWN:
if conn is not None:
try:
conn.close()
except Exception:
pass
return
exception = None
result = None
if conn_exception is not None:
exception = conn_exception
elif task.isolated_connection:
try:
result = conn_exception
else:
if 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()
result = e
finally:
isolated_connection.close()
try:
@ -469,25 +282,20 @@ class Database:
except ValueError:
# Was probably a memory connection
pass
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
else:
try:
if task.transaction:
with conn:
else:
try:
if task.transaction:
with conn:
result = task.fn(conn)
else:
result = task.fn(conn)
else:
result = task.fn(conn)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
_deliver_write_result(task, result, exception)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
result = e
task.reply_queue.sync_q.put(result)
async def execute_fn(self, fn):
self._check_not_closed()
if self.ds.executor is None:
# non-threaded mode
if self._read_connection is None:
@ -504,12 +312,9 @@ class Database:
setattr(connections, self._thread_local_id, conn)
return fn(conn)
with self._pending_execute_futures_lock:
self._check_not_closed()
future = self.ds.executor.submit(in_thread)
self._pending_execute_futures.add(future)
future.add_done_callback(self._remove_pending_execute_future)
return await asyncio.wrap_future(future)
return await asyncio.get_event_loop().run_in_executor(
self.ds.executor, in_thread
)
async def execute(
self,
@ -521,7 +326,6 @@ class Database:
log_sql_errors=True,
):
"""Executes sql against db_name in a thread"""
self._check_not_closed()
page_size = page_size or self.ds.page_size
def sql_operation_in_thread(conn):
@ -569,7 +373,7 @@ class Database:
def hash(self):
if self.cached_hash is not None:
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
elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):
self.cached_hash = self.ds.inspect_data[self.name]["hash"]
@ -727,7 +531,83 @@ class Database:
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)
if has_spatialite:
@ -792,8 +672,6 @@ class Database:
tags.append("mutable")
if self.is_memory:
tags.append("memory")
if self.is_temp_disk:
tags.append("temp_disk")
if self.hash:
tags.append(f"hash={self.hash}")
if self.size is not None:
@ -804,21 +682,18 @@ class Database:
return f"<Database: {self.name}{tags_str}>"
def _apply_write_wrapper(fn, wrapper_factory, track_event):
def _apply_write_wrapper(fn, wrapper_factory):
"""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()``.
``wrapper_factory`` is a callable that takes ``(conn)`` 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
)
gen = wrapper_factory(conn)
# Advance to the yield point (run "before" code)
try:
next(gen)
@ -829,10 +704,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
# Execute the actual write
try:
result = fn(conn)
except Exception as e:
except Exception:
# Throw exception into generator so it can handle it
try:
gen.throw(e)
gen.throw(*sys.exc_info())
except StopIteration:
pass
# Re-raise the original exception
@ -849,45 +724,16 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
class WriteTask:
__slots__ = (
"fn",
"task_id",
"loop",
"reply_future",
"isolated_connection",
"transaction",
)
__slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction")
def __init__(
self, fn, task_id, loop, reply_future, isolated_connection, transaction
):
def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):
self.fn = fn
self.task_id = task_id
self.loop = loop
self.reply_future = reply_future
self.reply_queue = reply_queue
self.isolated_connection = isolated_connection
self.transaction = transaction
def _deliver_write_result(task, result, exception):
# Called from the write thread. Delivers the result back to the
# awaiting coroutine on its event loop via call_soon_threadsafe.
def _set():
if task.reply_future.done():
# Awaiter was cancelled; nothing to do.
return
if exception is not None:
task.reply_future.set_exception(exception)
else:
task.reply_future.set_result(result)
try:
task.loop.call_soon_threadsafe(_set)
except RuntimeError:
# Event loop has been closed; the awaiter is gone.
pass
class QueryInterrupted(Exception):
def __init__(self, e, sql, params):
self.e = e
@ -902,44 +748,6 @@ class MultipleValues(Exception):
pass
class ExecuteWriteResult:
def __init__(self, rowcount, lastrowid, description, rows, truncated):
self.rowcount = rowcount
self.lastrowid = lastrowid
self.description = description
self.truncated = truncated
self._rows = rows
@classmethod
def from_cursor(
cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT
):
rows = []
truncated = False
description = cursor.description
lastrowid = cursor.lastrowid
try:
if description is not None:
if return_all:
rows = cursor.fetchall()
else:
rows = cursor.fetchmany(returning_limit + 1)
if len(rows) > returning_limit:
rows = rows[:returning_limit]
truncated = True
rowcount = cursor.rowcount
finally:
cursor.close()
if description is not None and not return_all and truncated:
rowcount = -1
return cls(rowcount, lastrowid, description, rows, truncated)
def fetchall(self):
rows = self._rows
self._rows = []
return rows
class Results:
def __init__(self, rows, truncated, description):
self.rows = rows

View file

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

View file

@ -1,87 +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
class TextareaColumnType(ColumnType):
name = "textarea"
description = "Multiline text"
sqlite_types = (SQLiteType.TEXT,)
@hookimpl
def register_column_types(datasette):
return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType]

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,80 +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.",
),
(
"/-/debug/autocomplete",
"Debug autocomplete",
"Try out table autocomplete against a detected label column.",
),
(
"/-/threads",
"Debug threads",
"Inspect worker threads and database tasks.",
),
(
"/-/actor",
"Debug actor",
"View the actor object for the current signed-in user.",
),
(
"/-/patterns",
"Pattern portfolio",
"Browse Datasette UI patterns.",
),
)
@hookimpl
def jump_items_sql(datasette, actor, request):
async def inner():
if not await datasette.allowed(action="debug-menu", actor=actor):
return []
return [
JumpSQL.menu_item(
label=label,
url=datasette.urls.path(path),
description=description,
search_text=f"debug {label} {description}",
item_type="debug",
)
for path, label, description in DEBUG_MENU_ITEMS
]
return inner

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

@ -17,6 +17,13 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette import hookimpl
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql,
@ -26,9 +33,26 @@ from .restrictions import (
from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql,
default_query_permissions_sql as default_query_permissions_sql,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
@hookimpl
def skip_csrf(scope) -> Optional[bool]:
"""Skip CSRF check for JSON content-type requests."""
if scope["type"] == "http":
headers = scope.get("headers") or {}
if dict(headers).get(b"content-type") == b"application/json":
return True
return None
@hookimpl
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
"""Return canned queries defined in datasette.yaml configuration."""
queries = (
((datasette.config or {}).get("databases") or {}).get(database) or {}
).get("queries") or {}
return queries

View file

@ -67,48 +67,3 @@ async def default_action_permissions_sql(
return PermissionSQL.allow(reason=reason)
return None
@hookimpl(specname="permission_resources_sql")
async def default_query_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
action: str,
) -> Optional[PermissionSQL]:
actor_id = actor.get("id") if isinstance(actor, dict) else None
if action not in {"view-query", "update-query", "delete-query"}:
return None
params = {"query_owner_id": actor_id}
rule_sqls = []
if actor_id is not None:
if action in {"update-query", "delete-query"}:
# Query owner can update/delete query
rule_sqls.append("""
SELECT database_name AS parent, name AS child, 1 AS allow,
'query owner' AS reason
FROM queries
WHERE source = 'user'
AND owner_id = :query_owner_id
""")
else:
# Query owner can view-query
rule_sqls.append("""
SELECT database_name AS parent, name AS child, 1 AS allow,
'query owner' AS reason
FROM queries
WHERE owner_id = :query_owner_id
""")
# restriction_sql enforces private queries ONLY visible/mutable by owner
return PermissionSQL(
sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None,
restriction_sql="""
SELECT database_name AS parent, name AS child
FROM queries
WHERE is_private = 0
OR owner_id = :query_owner_id
""",
params=params,
)

View file

@ -1,48 +0,0 @@
from datasette import hookimpl
from datasette.resources import QueryResource
@hookimpl
def query_actions(datasette, actor, database, query_name, request):
# Only stored queries (with a name) can be edited or deleted
if not query_name:
return None
async def inner():
query = await datasette.get_query(database, query_name)
if query is None:
return []
# Config-defined and trusted queries are managed outside the UI
if query.source == "config" or query.is_trusted:
return []
links = []
if await datasette.allowed(
action="update-query",
resource=QueryResource(database, query_name),
actor=actor,
):
links.append(
{
"href": datasette.urls.table(database, query_name) + "/-/edit",
"label": "Edit this query",
"description": (
"Change the title, description, SQL or visibility."
),
}
)
if await datasette.allowed(
action="delete-query",
resource=QueryResource(database, query_name),
actor=actor,
):
links.append(
{
"href": datasette.urls.table(database, query_name) + "/-/delete",
"label": "Delete this query",
"description": "Permanently remove this saved query.",
}
)
return links
return inner

View file

@ -199,27 +199,6 @@ class UpdateRowEvent(Event):
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
class DeleteRowEvent(Event):
"""
@ -240,42 +219,6 @@ class DeleteRowEvent(Event):
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
def register_events():
return [
@ -284,7 +227,6 @@ def register_events():
CreateTableEvent,
CreateTokenEvent,
AlterTableEvent,
RenameTableEvent,
DropTableEvent,
InsertRowsEvent,
UpsertRowsEvent,

View file

@ -1,118 +0,0 @@
import re
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar
from asyncinject import Registry
def extra_names_from_request(request):
extra_bits = request.args.getlist("_extra")
extras = set()
for bit in extra_bits:
extras.update(part for part in bit.split(",") if part)
return extras
class ExtraScope(Enum):
TABLE = "table"
ROW = "row"
QUERY = "query"
@dataclass(frozen=True)
class ExtraExample:
path: str | None = None
key: str | None = None
value: object | None = None
note: str | None = None
class Provider:
name: ClassVar[str | None] = None
scopes: ClassVar[set[ExtraScope]] = set()
public: ClassVar[bool] = False
@classmethod
def key(cls):
return cls.name or _camel_to_snake(cls.__name__)
@classmethod
def available_for(cls, scope):
return scope in cls.scopes
async def resolve(self, context):
raise NotImplementedError
class Extra(Provider):
description: ClassVar[str | None] = None
example: ClassVar[ExtraExample | None] = None
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {}
public: ClassVar[bool] = True
expensive: ClassVar[bool] = False
docs_note: ClassVar[str | None] = None
@classmethod
def example_for_scope(cls, scope):
return cls.examples.get(scope, cls.example)
class ExtraRegistry:
def __init__(self, classes):
self.classes = list(classes)
self.classes_by_name = {cls.key(): cls for cls in self.classes}
# Lazily-built shared state, keyed by scope. Safe to share across
# requests because Extra instances are stateless and asyncinject's
# Registry keeps per-call state local to each resolve_multi() call.
# If extras classes ever become registerable at runtime (e.g. via a
# plugin hook) these caches will need invalidating.
self._scope_registries = {}
self._allowed_names = {}
def classes_for_scope(self, scope, include_internal=True):
classes = [
cls
for cls in self.classes
if cls.available_for(scope) and (include_internal or cls.public)
]
return classes
def public_classes_for_scope(self, scope):
return self.classes_for_scope(scope, include_internal=False)
def _registry_for_scope(self, scope):
registry = self._scope_registries.get(scope)
if registry is None:
registry = Registry()
for cls in self.classes_for_scope(scope):
registry.register(cls().resolve, name=cls.key())
self._scope_registries[scope] = registry
return registry
def _allowed_names_for_scope(self, scope, include_internal):
key = (scope, include_internal)
names = self._allowed_names.get(key)
if names is None:
names = {
cls.key()
for cls in self.classes_for_scope(
scope, include_internal=include_internal
)
}
self._allowed_names[key] = names
return names
async def resolve(self, requested, context, scope, include_internal=False):
allowed_names = self._allowed_names_for_scope(scope, include_internal)
requested_names = [name for name in requested if name in allowed_names]
resolved = await self._registry_for_scope(scope).resolve_multi(
requested_names, results={"context": context}
)
return {name: resolved[name] for name in requested_names}
def _camel_to_snake(name):
name = re.sub(r"(Extra|Provider)$", "", name)
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()

View file

@ -83,7 +83,7 @@ class Facet:
self.ds = ds
self.request = request
self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries:
# For foreign key expansion. Can be None for e.g. canned SQL queries:
self.table = table
self.sql = sql or f"select * from [{table}]"
self.params = params or []

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
def render_cell(
row,
value,
column,
table,
pks,
database,
datasette,
request,
column_type,
):
def render_cell(row, value, column, table, pks, database, datasette, request):
"""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"""
@hookspec
def register_column_types(datasette):
"""Return a list of ColumnType subclasses"""
@hookspec
def register_routes(datasette):
"""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
def register_magic_parameters(datasette):
"""Return a list of (name, function) magic parameter functions"""
@ -152,39 +142,39 @@ def menu_links(datasette, actor, request):
"""Links for the navigation menu"""
@hookspec
def jump_items_sql(datasette, actor, request):
"""SQL fragments for extra items in the jump menu"""
@hookspec
def row_actions(datasette, actor, request, database, table, row):
"""Items for the row actions menu"""
"""Links for the row actions menu"""
@hookspec
def table_actions(datasette, actor, database, table, request):
"""Items for the table actions menu"""
"""Links for the table actions menu"""
@hookspec
def view_actions(datasette, actor, database, view, request):
"""Items for the view actions menu"""
"""Links for the view actions menu"""
@hookspec
def query_actions(datasette, actor, database, query_name, request, sql, params):
"""Items for the query and stored query actions menu"""
"""Links for the query and canned query actions menu"""
@hookspec
def database_actions(datasette, actor, database, request):
"""Items for the database actions menu"""
"""Links for the database actions menu"""
@hookspec
def homepage_actions(datasette, actor, request):
"""Items 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
@ -228,8 +218,8 @@ def top_query(datasette, request, database, sql):
@hookspec
def top_stored_query(datasette, request, database, query_name):
"""HTML to include at the top of the stored query page"""
def top_canned_query(datasette, request, database, query_name):
"""HTML to include at the top of the canned query page"""
@hookspec
@ -241,18 +231,12 @@ def register_token_handler(datasette):
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.
Return a generator function that accepts a ``conn`` 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 the write raises an exception, it is thrown into the generator
so you can handle it with a try/except around the ``yield``.

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

@ -8,14 +8,6 @@ _skip_permission_checks = contextvars.ContextVar(
"skip_permission_checks", default=False
)
# Request-scoped cache of permission check results. The ASGI router sets
# this to a fresh dict at the start of each request, so cached verdicts
# never outlive a request or leak between actors. Keys are
# (actor_json, action, parent, child) tuples, values are booleans.
_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
"permission_check_cache", default=None
)
class SkipPermissions:
"""Context manager to temporarily skip permission checks.
@ -66,16 +58,6 @@ class Resource(ABC):
self.child = child
self._private = None # Sentinel to track if private was set
def __str__(self) -> str:
return "/".join(
str(part) for part in (self.parent, self.child) if part is not None
)
def __repr__(self) -> str:
return "{}(parent={!r}, child={!r})".format(
self.__class__.__name__, self.parent, self.child
)
@property
def private(self) -> bool:
"""
@ -123,7 +105,7 @@ class Resource(ABC):
@classmethod
@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.

View file

@ -25,13 +25,9 @@ DEFAULT_PLUGINS = (
"datasette.default_permissions",
"datasette.default_permissions.tokens",
"datasette.default_actions",
"datasette.default_column_types",
"datasette.default_magic_parameters",
"datasette.blob_renderer",
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.default_query_actions",
"datasette.default_menu_links",
"datasette.handle_exception",
"datasette.forbidden",
"datasette.events",

View file

@ -1,5 +1,4 @@
import json
from datasette.extras import extra_names_from_request
from datasette.utils import (
value_as_boolean,
remove_infinites,
@ -109,7 +108,7 @@ def json_renderer(request, args, data, error, truncated=None):
# Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136
if isinstance(data, dict) and "columns" not in extra_names_from_request(request):
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
data.pop("columns", None)
# Handle _nl option for _shape=array

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,344 +0,0 @@
(function () {
function autocompleteValueFromRow(row) {
var pks = (row && row.pks) || {};
var keys = Object.keys(pks);
if (!keys.length) {
return "";
}
if (keys.length === 1) {
return String(pks[keys[0]]);
}
return keys
.map(function (key) {
return key + "=" + pks[key];
})
.join(", ");
}
function autocompleteLabelFromRow(row) {
var value = autocompleteValueFromRow(row);
if (row.label && String(row.label) !== value) {
return row.label + " (" + value + ")";
}
return value;
}
if (!window.customElements || customElements.get("datasette-autocomplete")) {
return;
}
class DatasetteAutocomplete extends HTMLElement {
constructor() {
super();
this.input = null;
this.listbox = null;
this.status = null;
this.results = [];
this.activeIndex = -1;
this.fetchId = 0;
this.searchTimer = null;
this.boundInput = this.handleInput.bind(this);
this.boundKeydown = this.handleKeydown.bind(this);
this.boundBlur = this.handleBlur.bind(this);
this.boundFocus = this.handleFocus.bind(this);
this.boundPositionListbox = this.positionListbox.bind(this);
}
connectedCallback() {
if (this.input) {
return;
}
this.input = this.querySelector("input");
if (!this.input) {
return;
}
var inputId =
this.input.id ||
"datasette-autocomplete-" + Math.random().toString(36).slice(2);
this.input.id = inputId;
var listboxId = inputId + "-listbox";
var statusId = inputId + "-status";
this.classList.add("datasette-autocomplete");
this.input.setAttribute("role", "combobox");
this.input.setAttribute("aria-autocomplete", "list");
this.input.setAttribute("aria-expanded", "false");
this.input.setAttribute("aria-controls", listboxId);
this.input.setAttribute("autocomplete", "off");
this.listbox = document.createElement("div");
this.listbox.className = "datasette-autocomplete-list";
this.listbox.id = listboxId;
this.listbox.setAttribute("role", "listbox");
this.listbox.hidden = true;
this.status = document.createElement("span");
this.status.className = "datasette-autocomplete-status";
this.status.id = statusId;
this.status.setAttribute("role", "status");
this.status.setAttribute("aria-live", "polite");
this.input.setAttribute(
"aria-describedby",
[this.input.getAttribute("aria-describedby"), statusId]
.filter(Boolean)
.join(" "),
);
this.appendChild(this.listbox);
this.appendChild(this.status);
this.input.addEventListener("input", this.boundInput);
this.input.addEventListener("keydown", this.boundKeydown);
this.input.addEventListener("blur", this.boundBlur);
this.input.addEventListener("focus", this.boundFocus);
}
disconnectedCallback() {
if (!this.input) {
return;
}
this.input.removeEventListener("input", this.boundInput);
this.input.removeEventListener("keydown", this.boundKeydown);
this.input.removeEventListener("blur", this.boundBlur);
this.input.removeEventListener("focus", this.boundFocus);
}
handleInput() {
this.scheduleSearch();
}
handleFocus() {
if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) {
this.scheduleSearch();
}
}
handleBlur() {
window.setTimeout(() => this.close(), 150);
}
handleKeydown(ev) {
if (ev.key === "Escape") {
if (!this.listbox.hidden) {
ev.preventDefault();
this.close();
}
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
if (this.listbox.hidden) {
this.scheduleSearch();
} else {
this.setActiveIndex(this.activeIndex + 1);
}
return;
}
if (ev.key === "ArrowUp") {
ev.preventDefault();
if (!this.listbox.hidden) {
this.setActiveIndex(this.activeIndex - 1);
}
return;
}
if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) {
ev.preventDefault();
this.chooseIndex(this.activeIndex);
}
}
scheduleSearch() {
window.clearTimeout(this.searchTimer);
this.searchTimer = window.setTimeout(() => this.search(), 150);
}
async search() {
var query = this.input.value.trim();
var initial = !query && this.hasAttribute("suggest-on-focus");
if (!query && !initial) {
this.close();
this.status.textContent = "";
return;
}
var src = this.getAttribute("src");
if (!src) {
return;
}
var url = new URL(src, location.href);
url.searchParams.set("q", query);
if (initial) {
url.searchParams.set("_initial", "1");
} else {
url.searchParams.delete("_initial");
}
var fetchId = this.fetchId + 1;
this.fetchId = fetchId;
this.status.textContent = "Searching...";
try {
var response = await fetch(url.toString(), {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
var data = await response.json();
if (fetchId !== this.fetchId) {
return;
}
this.results = (data && data.rows) || [];
this.render();
} catch (_error) {
if (fetchId !== this.fetchId) {
return;
}
this.results = [];
this.close();
this.status.textContent = "Could not load suggestions";
}
}
render() {
this.listbox.textContent = "";
this.activeIndex = -1;
if (!this.results.length) {
this.close();
this.status.textContent = "No matches";
return;
}
this.results.forEach((row, index) => {
var option = document.createElement("div");
option.className = "datasette-autocomplete-option";
option.id = this.input.id + "-option-" + index;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", "false");
option.dataset.index = String(index);
option.dataset.value = autocompleteValueFromRow(row);
option.textContent = autocompleteLabelFromRow(row);
option.addEventListener("mousedown", (ev) => {
ev.preventDefault();
this.chooseIndex(index);
});
this.listbox.appendChild(option);
});
this.listbox.hidden = false;
this.input.setAttribute("aria-expanded", "true");
this.status.textContent =
this.results.length + (this.results.length === 1 ? " match" : " matches");
this.positionListbox();
this.setActiveIndex(0);
}
positionListbox() {
if (!this.input || !this.listbox || this.listbox.hidden) {
return;
}
var gap = 3;
var margin = 8;
var inputRect = this.input.getBoundingClientRect();
this.listbox.style.maxHeight = "";
var defaultMaxHeight = parseFloat(
window.getComputedStyle(this.listbox).maxHeight,
);
if (!Number.isFinite(defaultMaxHeight)) {
defaultMaxHeight = 256;
}
var scrollHeight = Math.ceil(this.listbox.scrollHeight);
var desiredHeight = Math.min(scrollHeight, defaultMaxHeight);
var availableBelow = Math.max(
0,
(window.innerHeight || document.documentElement.clientHeight) -
inputRect.bottom -
gap -
margin,
);
this.listbox.style.left = inputRect.left + "px";
this.listbox.style.top = inputRect.bottom + gap + "px";
this.listbox.style.width = inputRect.width + "px";
if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) {
this.listbox.style.maxHeight = "none";
} else {
this.listbox.style.maxHeight =
Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) +
"px";
}
window.addEventListener("resize", this.boundPositionListbox);
document.addEventListener("scroll", this.boundPositionListbox, true);
}
setActiveIndex(index) {
var options = this.listbox.querySelectorAll("[role='option']");
if (!options.length) {
this.activeIndex = -1;
this.input.removeAttribute("aria-activedescendant");
return;
}
if (index < 0) {
index = options.length - 1;
}
if (index >= options.length) {
index = 0;
}
options.forEach((option, optionIndex) => {
option.setAttribute(
"aria-selected",
optionIndex === index ? "true" : "false",
);
});
this.activeIndex = index;
this.input.setAttribute("aria-activedescendant", options[index].id);
}
chooseIndex(index) {
var row = this.results[index];
if (!row) {
return;
}
var value = autocompleteValueFromRow(row);
var label = autocompleteLabelFromRow(row);
this.input.value = value;
this.input.dispatchEvent(new Event("change", { bubbles: true }));
this.close();
this.status.textContent = "Selected " + label;
this.dispatchEvent(
new CustomEvent("datasette-autocomplete-select", {
bubbles: true,
detail: {
row: row,
value: value,
label: label,
},
}),
);
}
close() {
if (this.listbox) {
this.listbox.hidden = true;
this.listbox.textContent = "";
this.listbox.style.left = "";
this.listbox.style.maxHeight = "";
this.listbox.style.top = "";
this.listbox.style.width = "";
}
if (this.input) {
this.input.setAttribute("aria-expanded", "false");
this.input.removeAttribute("aria-activedescendant");
}
window.removeEventListener("resize", this.boundPositionListbox);
document.removeEventListener("scroll", this.boundPositionListbox, true);
this.activeIndex = -1;
}
}
customElements.define("datasette-autocomplete", DatasetteAutocomplete);
})();

View file

@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {
<style>
:host {
--ink: #0f0f0f;
--paper: #eef6ff;
--paper: #f5f3ef;
--muted: #6b6b6b;
--rule: #d8e6f5;
--rule: #e2dfd8;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;

View file

@ -82,48 +82,6 @@ const datasetteManager = {
return columnActions;
},
/**
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
* for specific Datasette column types.
*
* The first plugin to return a control object wins. Returning null or
* undefined means "I do not handle this field".
*/
makeColumnField: (context) => {
for (const [pluginName, plugin] of datasetteManager.plugins) {
if (!plugin.makeColumnField) {
continue;
}
let control = null;
try {
control = plugin.makeColumnField(context);
} catch (error) {
console.error(
`Error in makeColumnField() for plugin ${pluginName}`,
error,
);
continue;
}
if (control) {
return Object.assign({ pluginName }, control);
}
}
return null;
},
makeJumpSections: (context) => {
let jumpSections = [];
datasetteManager.plugins.forEach((plugin) => {
if (plugin.makeJumpSections) {
const sections = plugin.makeJumpSections(context) || [];
jumpSections.push(...sections);
}
});
return jumpSections;
},
/**
* In MVP, each plugin can only have 1 instance.
* In future, panels could be repeated. We omit that for now since so many plugins depend on
@ -234,6 +192,7 @@ const initializeDatasette = () => {
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
window.__DATASETTE__ = datasetteManager;
console.debug("Datasette Manager Created!");
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
detail: datasetteManager,

File diff suppressed because it is too large Load diff

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 {
constructor() {
super();
this.instanceId = ++navigationSearchInstanceCounter;
this.inputId = `navigation-search-input-${this.instanceId}`;
this.instructionsId = `navigation-search-instructions-${this.instanceId}`;
this.listboxId = `navigation-search-results-${this.instanceId}`;
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
this.statusId = `navigation-search-status-${this.instanceId}`;
this.titleId = `navigation-search-title-${this.instanceId}`;
this.attachShadow({ mode: "open" });
this.selectedIndex = -1;
this.matches = [];
this.renderedMatches = [];
this.debounceTimer = null;
this.restoreFocusTarget = null;
this.shouldRestoreFocus = true;
this.render();
this.setupEventListeners();
@ -66,20 +54,16 @@ class NavigationSearch extends HTMLElement {
.search-container {
display: flex;
flex-direction: column;
height: 100%;
}
.search-input-wrapper {
padding: 1.25rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 0.5rem;
align-items: center;
}
.search-input {
width: 100%;
flex: 1;
min-width: 0;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e5e7eb;
@ -93,36 +77,12 @@ class NavigationSearch extends HTMLElement {
border-color: #2563eb;
}
.close-search {
background: transparent;
border: 1px solid transparent;
border-radius: 0.375rem;
color: #4b5563;
cursor: pointer;
flex: 0 0 auto;
font: inherit;
font-size: 1.5rem;
height: 2.75rem;
line-height: 1;
width: 2.75rem;
}
.close-search:hover,
.close-search:focus {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.results-container {
overflow-y: auto;
height: calc(80vh - 180px);
padding: 0.5rem;
}
.results-list:empty {
display: none;
}
.result-item {
padding: 0.875rem 1rem;
cursor: pointer;
@ -141,81 +101,16 @@ class NavigationSearch extends HTMLElement {
background-color: #dbeafe;
}
.result-item > div {
flex: 1;
min-width: 0;
}
.jump-start-content {
border-bottom: 1px solid #e5e7eb;
margin-bottom: 0.5rem;
padding: 0.5rem 0.5rem 1rem;
}
.jump-start-content:empty {
display: none;
}
.result-name {
font-weight: 500;
color: #111827;
}
.result-label {
font-size: 0.875rem;
color: #4b5563;
}
.result-type {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.result-url {
font-size: 0.875rem;
color: #6b7280;
}
.result-description {
color: #374151;
display: -webkit-box;
font-size: 0.8125rem;
line-height: 1.35;
margin-top: 0.35rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.results-heading {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0;
padding: 0.5rem 1rem 0.25rem;
text-transform: uppercase;
}
.recent-actions {
padding: 0.25rem 1rem 0.75rem;
}
.clear-recent {
background: transparent;
border: 0;
color: #2563eb;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
padding: 0;
}
.clear-recent:hover {
text-decoration: underline;
}
.no-results {
padding: 2rem;
text-align: center;
@ -241,18 +136,6 @@ class NavigationSearch extends HTMLElement {
font-family: monospace;
}
.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
/* Mobile optimizations */
@media (max-width: 640px) {
dialog {
@ -280,29 +163,19 @@ class NavigationSearch extends HTMLElement {
}
</style>
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
<dialog>
<div class="search-container">
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
<div id="${this.statusId}" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
<div class="search-input-wrapper">
<input
id="${this.inputId}"
type="text"
class="search-input"
placeholder="Jump to..."
aria-label="Jump to"
aria-describedby="${this.instructionsId}"
role="combobox"
aria-autocomplete="list"
aria-controls="${this.listboxId}"
aria-expanded="false"
placeholder="Search..."
aria-label="Search navigation"
autocomplete="off"
spellcheck="false"
>
<button type="button" class="close-search" aria-label="Close jump menu">&times;</button>
</div>
<div class="results-container"></div>
<div class="results-container" role="listbox"></div>
<div class="hint-text">
<span><kbd></kbd> <kbd></kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
@ -316,7 +189,6 @@ class NavigationSearch extends HTMLElement {
setupEventListeners() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
const closeButton = this.shadowRoot.querySelector(".close-search");
const resultsContainer =
this.shadowRoot.querySelector(".results-container");
@ -328,17 +200,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.addEventListener("input", (e) => {
this.handleSearch(e.target.value);
@ -360,19 +221,8 @@ class NavigationSearch extends HTMLElement {
}
});
closeButton.addEventListener("click", () => {
this.closeMenu();
});
// Click on result item
resultsContainer.addEventListener("click", (e) => {
const clearRecent = e.target.closest("[data-clear-recent-items]");
if (clearRecent) {
e.preventDefault();
this.clearRecentItems();
return;
}
const item = e.target.closest(".result-item");
if (item) {
const index = parseInt(item.dataset.index);
@ -387,15 +237,6 @@ class NavigationSearch extends HTMLElement {
}
});
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this.closeMenu();
});
dialog.addEventListener("close", () => {
this.onMenuClosed();
});
// Initial load
this.loadInitialData();
}
@ -410,106 +251,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() {
const itemsAttr = this.getAttribute("items");
if (itemsAttr) {
@ -526,11 +267,6 @@ class NavigationSearch extends HTMLElement {
handleSearch(query) {
clearTimeout(this.debounceTimer);
if (query.trim()) {
this.setStatus("Searching...");
} else {
this.setStatus("");
}
this.debounceTimer = setTimeout(() => {
const url = this.getAttribute("url");
@ -553,262 +289,65 @@ class NavigationSearch extends HTMLElement {
this.matches = data.matches || [];
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults();
if (query.trim()) {
this.setStatus(this.resultsStatus(this.matches.length, data.truncated));
} else {
this.setStatus("");
}
} catch (e) {
console.error("Failed to fetch search results:", e);
this.matches = [];
this.renderResults();
this.setStatus("Search failed.");
}
}
filterLocalItems(query) {
if (!query.trim()) {
this.matches = this.allItems || [];
this.matches = [];
} else {
const lowerQuery = query.toLowerCase();
this.matches = (this.allItems || []).filter(
(item) =>
item.name.toLowerCase().includes(lowerQuery) ||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
item.url.toLowerCase().includes(lowerQuery),
);
}
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults();
if (query.trim()) {
this.setStatus(this.resultsStatus(this.matches.length, false));
} else {
this.setStatus("");
}
}
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() {
const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input");
const showStartContent = !input.value.trim();
const jumpSections = showStartContent ? this.jumpSections() : [];
const startBlock = showStartContent
? this.jumpSectionsHtml(jumpSections)
: "";
const recentItems = showStartContent ? this.loadRecentItems() : [];
const defaultMatches = showStartContent ? [] : this.matches;
const renderedMatches = [...recentItems, ...defaultMatches];
this.renderedMatches = renderedMatches;
const emptyListbox = `<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results"></div>`;
if (renderedMatches.length) {
if (
this.selectedIndex < 0 ||
this.selectedIndex >= renderedMatches.length
) {
this.selectedIndex = 0;
}
} else {
this.selectedIndex = -1;
}
if (renderedMatches.length === 0) {
if (startBlock) {
container.innerHTML = startBlock + emptyListbox;
this.renderJumpSections(container, jumpSections);
} else if (showStartContent) {
container.innerHTML = emptyListbox;
} else {
const message = input.value.trim()
? "No results found"
: "Start typing to search...";
container.innerHTML = `${emptyListbox}<div class="no-results">${message}</div>`;
}
this.updateComboboxState();
if (this.matches.length === 0) {
const message = input.value.trim()
? "No results found"
: "Start typing to search...";
container.innerHTML = `<div class="no-results">${message}</div>`;
return;
}
const recentHeading = recentItems.length
? `<div class="results-heading" id="${this.recentHeadingId}">Recent</div>`
: "";
const recentGroup = recentItems.length
? `<div role="group" aria-labelledby="${this.recentHeadingId}">${recentItems
.map((match, index) => this.resultItemHtml(match, index))
.join("")}</div>`
: "";
const recentActions = recentItems.length
? `<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
: "";
const defaultHtml = defaultMatches
.map((match, index) =>
this.resultItemHtml(match, recentItems.length + index),
container.innerHTML = this.matches
.map(
(match, index) => `
<div
class="result-item ${
index === this.selectedIndex ? "selected" : ""
}"
data-index="${index}"
role="option"
aria-selected="${index === this.selectedIndex}"
>
<div>
<div class="result-name">${this.escapeHtml(
match.name,
)}</div>
<div class="result-url">${this.escapeHtml(match.url)}</div>
</div>
</div>
`,
)
.join("");
container.innerHTML =
startBlock +
recentHeading +
`<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results">${recentGroup}${defaultHtml}</div>` +
recentActions;
this.renderJumpSections(container, jumpSections);
this.updateComboboxState();
// Scroll selected item into view
if (this.selectedIndex >= 0) {
const selectedItem = container.querySelector(
`.result-item[data-index="${this.selectedIndex}"]`,
);
const selectedItem = container.children[this.selectedIndex];
if (selectedItem) {
selectedItem.scrollIntoView({ block: "nearest" });
}
@ -816,27 +355,22 @@ class NavigationSearch extends HTMLElement {
}
moveSelection(direction) {
const matches = this.renderedMatches || this.matches;
const newIndex = this.selectedIndex + direction;
if (newIndex >= 0 && newIndex < matches.length) {
if (newIndex >= 0 && newIndex < this.matches.length) {
this.selectedIndex = newIndex;
this.renderResults();
}
}
selectCurrentItem() {
const matches = this.renderedMatches || this.matches;
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
this.selectItem(this.selectedIndex);
}
}
selectItem(index) {
const matches = this.renderedMatches || this.matches;
const match = matches[index];
const match = this.matches[index];
if (match) {
this.saveRecentItem(match);
// Dispatch custom event
this.dispatchEvent(
new CustomEvent("select", {
@ -849,59 +383,32 @@ class NavigationSearch extends HTMLElement {
// Navigate to URL
window.location.href = match.url;
this.closeMenu({ restoreFocus: false });
this.closeMenu();
}
}
openMenu(trigger) {
openMenu() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
this.shouldRestoreFocus = true;
if (!dialog.open) {
dialog.showModal();
}
this.setNavigationTriggersExpanded(true);
dialog.showModal();
input.value = "";
input.focus();
// Reset state, then populate the default jump list.
// Reset state - start with no items shown
this.matches = [];
this.selectedIndex = -1;
this.renderResults();
this.setStatus("");
}
closeMenu(options = {}) {
closeMenu() {
const dialog = this.shadowRoot.querySelector("dialog");
this.shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
} else {
this.onMenuClosed();
}
}
onMenuClosed() {
const input = this.shadowRoot.querySelector(".search-input");
this.setElementAttribute(input, "aria-expanded", "false");
this.removeElementAttribute(input, "aria-activedescendant");
this.setNavigationTriggersExpanded(false);
this.setStatus("");
if (
this.shouldRestoreFocus &&
this.restoreFocusTarget &&
typeof this.restoreFocusTarget.focus === "function"
) {
this.restoreFocusTarget.focus();
}
this.restoreFocusTarget = null;
dialog.close();
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : text;
div.textContent = text;
return div.innerHTML;
}
}

File diff suppressed because it is too large Load diff

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 %}
<div class="page-action-menu">
<details class="actions-menu-links details-menu">
<summary aria-haspopup="menu" aria-expanded="false">
<summary>
<div class="icon-text">
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">{{ action_title }}</title>
@ -13,24 +13,16 @@
</summary>
<div class="dropdown-menu">
<div class="hook"></div>
<ul role="menu">
<ul>
{% for link in action_links %}
<li role="none">
{% if link.get("type") == "button" %}
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
{% if link.description %}
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</button>
{% else %}
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
{% if link.description %}
<span class="dropdown-description">{{ link.description }}</span>
{% endif %}</a>
{% endif %}
<li><a href="{{ link.href }}">{{ link.label }}
{% if link.description %}
<p class="dropdown-description">{{ link.description }}</p>
{% endif %}</a>
</li>
{% endfor %}
</ul>
</div>
</details>
</div>
{% endif %}
{% endif %}

View file

@ -13,50 +13,4 @@ document.body.addEventListener('click', (ev) => {
(details) => details.open && details != detailsClickedWithin
).forEach(details => details.open = false);
});
/* Sync aria-expanded and add keyboard navigation for details-menu elements */
document.querySelectorAll('details.details-menu').forEach(function(details) {
var summary = details.querySelector('summary');
details.addEventListener('toggle', function() {
if (summary) {
summary.setAttribute('aria-expanded', details.open ? 'true' : 'false');
}
if (details.open) {
/* Focus first menu item when menu opens */
var firstItem = details.querySelector('[role="menuitem"]');
if (firstItem) { firstItem.focus(); }
}
});
});
document.body.addEventListener('keydown', function(ev) {
/* Keyboard navigation for open details-menu elements */
var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]'));
if (!openDetails.length) { return; }
if (ev.key === 'Escape') {
openDetails.forEach(function(details) {
details.open = false;
var summary = details.querySelector('summary');
if (summary) { summary.focus(); }
});
return;
}
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
var focused = document.activeElement;
openDetails.forEach(function(details) {
var items = Array.from(details.querySelectorAll('[role="menuitem"]'));
if (!items.length) { return; }
var idx = items.indexOf(focused);
if (idx === -1) { return; }
ev.preventDefault();
if (ev.key === 'ArrowDown') {
items[(idx + 1) % items.length].focus();
} else {
items[(idx - 1 + items.length) % items.length].focus();
}
});
}
});
</script>

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

@ -12,9 +12,9 @@
<ul class="tight-bullets">
{% for facet_value in facet_info.results %}
{% if not facet_value.selected %}
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span></li>
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
{% else %}
<li>{{ facet_value.label or "-" }} &middot; <span class="facet-count">{{ "{:,}".format(facet_value.count) }}</span> <a href="{{ facet_value.toggle_url }}" class="cross">&#x2716;</a></li>
<li>{{ facet_value.label or "-" }} &middot; {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">&#x2716;</a></li>
{% endif %}
{% endfor %}
{% if facet_info.truncated %}

View file

@ -1,138 +0,0 @@
<style>
.query-create-page {
max-width: 64rem;
}
.query-create-form {
--query-create-label-width: clamp(7rem, 18vw, 10rem);
--query-create-column-gap: 0.8rem;
--query-create-control-width: minmax(16rem, 1fr);
}
.query-create-fields {
margin: 0 0 0.85rem;
max-width: 52rem;
}
.query-create-field {
align-items: start;
column-gap: var(--query-create-column-gap);
display: grid;
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
margin: 0 0 0.65rem;
}
.query-create-field label {
padding-top: 0.55rem;
width: auto;
}
.query-create-field input[type=text],
.query-create-field textarea {
box-sizing: border-box;
width: 100%;
}
form.sql .query-create-field textarea {
width: 100%;
}
.query-create-url-control {
align-items: center;
box-sizing: border-box;
display: grid;
gap: 0.35rem;
grid-template-columns: max-content minmax(12rem, 1fr);
width: 100%;
}
.query-create-url-prefix {
color: #4f5b6d;
font-family: var(--font-monospace, monospace);
white-space: nowrap;
}
.query-create-url-control input[type=text] {
border: 1px solid #ccc;
border-radius: 3px;
}
.query-create-url-static {
color: #39445a;
font-family: var(--font-monospace, monospace);
word-break: break-all;
}
.query-create-field textarea {
border: 1px solid #ccc;
border-radius: 3px;
display: block;
font-family: Helvetica, sans-serif;
font-size: 1em;
min-height: 5rem;
padding: 9px 4px;
resize: vertical;
}
form.sql .query-create-sql {
column-gap: var(--query-create-column-gap);
display: grid;
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
margin: 0.9rem 0 0.75rem;
max-width: 52rem;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
grid-column: 2;
width: 100%;
}
.query-create-options {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.8rem 1.4rem;
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
}
.query-create-options label {
align-items: center;
display: inline-flex;
gap: 0.35rem;
width: auto;
}
.query-create-options input[type=checkbox] {
margin: 0;
}
.query-create-option-note,
.query-create-analysis-note {
color: #4f5b6d;
flex-basis: 100%;
font-size: 0.82rem;
}
.query-create-option-note {
margin: -0.45rem 0 0;
}
.query-create-analysis-note {
margin: 0;
}
.query-create-analysis {
margin-top: 0.8rem;
}
.query-create-submit {
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
margin-bottom: 0.9rem;
margin-top: 1rem;
}
@media (max-width: 560px) {
.query-create-form {
--query-create-label-width: 1fr;
--query-create-column-gap: 0;
}
.query-create-field {
grid-template-columns: 1fr;
row-gap: 0.25rem;
}
.query-create-field label {
padding-top: 0;
}
form.sql .query-create-sql {
grid-template-columns: 1fr;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
grid-column: 1;
}
.query-create-options,
.query-create-submit {
margin-left: 0;
}
}
</style>

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,307 +0,0 @@
<script>
window.datasetteSqlParameters = (() => {
if (
window.datasetteSqlParameters &&
window.datasetteSqlParameters.setupSqlParameterRefresh
) {
return window.datasetteSqlParameters;
}
function currentSql(form) {
if (window.editor) {
return window.editor.state.doc.toString();
}
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
return sqlInput ? sqlInput.value : "";
}
function controlState(control) {
return {
value: control.value,
expanded: control.tagName.toLowerCase() === "textarea",
};
}
function syncParameterState(manager) {
manager.parameterState = new Map();
manager.section
.querySelectorAll("[data-parameter-control]")
.forEach((control) => {
manager.parameterState.set(
control.dataset.parameterName,
controlState(control)
);
});
}
function createControl(parameter, id, state, namePrefix) {
const control = document.createElement(state.expanded ? "textarea" : "input");
control.id = id;
control.name = `${namePrefix || ""}${parameter}`;
control.value = state.value;
control.setAttribute("data-parameter-control", "");
control.dataset.parameterName = parameter;
if (state.expanded) {
control.rows = 5;
} else {
control.type = "text";
}
return control;
}
function replaceParameterControl(
manager,
control,
button,
expand,
value,
selectionStart
) {
const parameter = control.dataset.parameterName;
const replacement = createControl(
parameter,
control.id,
{
value: value === undefined ? control.value : value,
expanded: expand,
},
manager.namePrefix
);
button.textContent = expand ? "Collapse" : "Expand";
button.setAttribute("aria-expanded", expand ? "true" : "false");
control.replaceWith(replacement);
replacement.focus();
if (selectionStart !== undefined && replacement.setSelectionRange) {
replacement.setSelectionRange(selectionStart, selectionStart);
}
manager.parameterState.set(parameter, controlState(replacement));
}
function renderParameters(manager, parameters) {
syncParameterState(manager);
const previousState = manager.parameterState;
const nextState = new Map();
manager.section.replaceChildren();
if (!parameters.length) {
manager.parameterState = nextState;
return;
}
const heading = document.createElement("h2");
heading.textContent = "Parameters";
manager.section.appendChild(heading);
parameters.forEach((parameter, index) => {
const id = `qp${index + 1}`;
const state = previousState.get(parameter) || {
value: "",
expanded: false,
};
if (!manager.allowExpand) {
state.expanded = false;
}
nextState.set(parameter, state);
const row = document.createElement("p");
row.className = "sql-parameter-row";
const label = document.createElement("label");
label.htmlFor = id;
label.textContent = parameter;
const control = createControl(parameter, id, state, manager.namePrefix);
row.append(label, control);
if (manager.allowExpand) {
const button = document.createElement("button");
button.type = "button";
button.className = "sql-parameter-toggle";
button.setAttribute("data-parameter-toggle", "");
button.setAttribute("aria-controls", id);
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
button.textContent = state.expanded ? "Collapse" : "Expand";
row.append(" ", button);
}
manager.section.appendChild(row);
});
manager.parameterState = nextState;
}
function bindParameterControls(manager) {
manager.form.addEventListener("input", (event) => {
const control = event.target;
if (!control.matches || !control.matches("[data-parameter-control]")) {
return;
}
manager.parameterState.set(
control.dataset.parameterName,
controlState(control)
);
});
if (!manager.allowExpand) {
return;
}
manager.form.addEventListener("click", (event) => {
const button = event.target.closest
? event.target.closest("[data-parameter-toggle]")
: null;
if (!button || !manager.form.contains(button)) {
return;
}
const control = document.getElementById(button.getAttribute("aria-controls"));
if (!control) {
return;
}
const expanded = control.tagName.toLowerCase() === "textarea";
replaceParameterControl(manager, control, button, !expanded);
});
manager.form.addEventListener("paste", (event) => {
const control = event.target;
if (
!(control instanceof HTMLInputElement) ||
!control.matches("[data-parameter-control]")
) {
return;
}
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
if (!/[\r\n]/.test(pasted)) {
return;
}
const button = document.querySelector(
`[data-parameter-toggle][aria-controls="${control.id}"]`
);
if (!button) {
return;
}
event.preventDefault();
const selectionStart = control.selectionStart ?? control.value.length;
const selectionEnd = control.selectionEnd ?? selectionStart;
const value =
control.value.slice(0, selectionStart) +
pasted +
control.value.slice(selectionEnd);
replaceParameterControl(
manager,
control,
button,
true,
value,
selectionStart + pasted.length
);
});
}
function bindEditorChanges(form, callback) {
const editorElement = form.querySelector(".cm-content");
if (editorElement) {
editorElement.addEventListener("input", callback);
}
if (!window.editor) {
const sqlInput = form.querySelector("textarea#sql-editor");
if (sqlInput) {
sqlInput.addEventListener("input", callback);
}
return;
}
if (!window.editor.datasetteSqlParameterCallbacks) {
const editor = window.editor;
const originalDispatch = editor.dispatch.bind(editor);
editor.datasetteSqlParameterCallbacks = [];
editor.dispatch = (...transactions) => {
const before = editor.state.doc.toString();
originalDispatch(...transactions);
if (editor.state.doc.toString() !== before) {
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
}
};
}
window.editor.datasetteSqlParameterCallbacks.push(callback);
}
function setupSqlParameterRefresh(options) {
const form =
options.form || document.querySelector("form.sql.core[data-parameters-url]");
if (!form) {
return null;
}
const shouldRenderParameters = options.renderParameters !== false;
const section =
options.section || form.querySelector("[data-sql-parameters-section]");
if (shouldRenderParameters && !section) {
return null;
}
const manager = {
form,
section,
allowExpand:
options.allowExpand === undefined
? section
? section.dataset.allowExpand === "1"
: false
: options.allowExpand,
namePrefix: section ? section.dataset.parameterNamePrefix || "" : "",
parameterState: new Map(),
};
if (section) {
bindParameterControls(manager);
syncParameterState(manager);
}
const url = options.url || form.dataset.parametersUrl;
let refreshTimer = null;
let refreshSequence = 0;
async function refreshParameters() {
if (!url) {
return;
}
const sequence = ++refreshSequence;
try {
const requestUrl = new URL(url, window.location.href);
requestUrl.searchParams.set("sql", currentSql(form));
const response = await fetch(requestUrl, {
headers: { accept: "application/json" },
});
const data = await response.json();
if (sequence !== refreshSequence) {
return;
}
if (!response.ok) {
throw new Error((data.errors || [response.statusText]).join("; "));
}
if (shouldRenderParameters) {
renderParameters(manager, data.parameters || []);
}
if (options.onData) {
options.onData(data, manager);
}
} catch (error) {
if (sequence !== refreshSequence) {
return;
}
if (options.onError) {
options.onError(error, manager);
}
}
}
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
}
bindEditorChanges(form, scheduleRefresh);
return {
currentSql: () => currentSql(form),
refreshParameters,
renderParameters: (parameters) => renderParameters(manager, parameters),
};
}
return { setupSqlParameterRefresh };
})();
</script>

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,10 +0,0 @@
{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %}
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameter_name_prefix %} data-parameter-name-prefix="{{ sql_parameter_name_prefix }}"{% endif %}{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
{% if parameter_names %}
<h2>Parameters</h2>
{% for parameter in parameter_names %}
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ sql_parameter_name_prefix }}{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control data-parameter-name="{{ parameter }}">{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
{% endfor %}
{% endif %}
</div>

View file

@ -1,12 +1,12 @@
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
<div class="above-table-panel"> </div>
{% if display_columns %}
{% if display_rows %}
<div class="table-wrapper">
<table class="rows-and-columns">
<thead>
<tr>
{% 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 %}
{{ column.name }}
{% else %}
@ -22,7 +22,7 @@
</thead>
<tbody>
{% for row in display_rows %}
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}>
<tr class="table-row-clickable" data-row-index="{{ loop.index0 }}">
{% for cell in row %}
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
{% endfor %}
@ -31,7 +31,194 @@
</tbody>
</table>
</div>
{% endif %}
{% if not display_rows %}
{% else %}
<p class="zero-results">0 records</p>
{% endif %}
<!-- Row detail side panel dialog -->
<dialog id="rowDetailPanel" class="row-detail-panel">
<div class="dialog-content">
<div class="dialog-header">
<h2>Row details</h2>
<button class="close-button" id="closeRowDetail" aria-label="Close">×</button>
</div>
<div class="dialog-navigation">
<button class="nav-button" id="prevRowButton" aria-label="Previous row">← Previous</button>
<span class="row-position" id="rowPosition"></span>
<button class="nav-button" id="nextRowButton" aria-label="Next row">Next →</button>
</div>
<div id="rowDetailContent" class="row-detail-content">
<p class="loading">Loading...</p>
</div>
</div>
</dialog>
<style>
/* Row detail side panel styles */
.table-row-clickable {
cursor: pointer;
}
.table-row-clickable:hover {
background-color: #f5f5f5;
}
.row-detail-panel {
position: fixed;
margin: 0;
padding: 0;
border: none;
width: 90%;
max-width: 600px;
height: 100vh;
top: 0;
right: 0;
left: auto;
background-color: white;
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
transform: translateX(100%);
transition: transform 0.1s cubic-bezier(0.2, 0, 0.38, 0.9);
max-height: none;
}
@media only screen and (min-width: 768px) {
.row-detail-panel {
width: 50%;
max-width: 800px;
}
}
.row-detail-panel::backdrop {
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.1s cubic-bezier(0.2, 0, 0.38, 0.9);
}
.row-detail-panel[open]::backdrop {
opacity: 1;
}
.dialog-content {
padding: 24px;
height: 100%;
overflow-y: auto;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h2 {
margin: 0;
font-size: 1.5em;
}
.close-button {
background: none;
border: none;
font-size: 32px;
cursor: pointer;
padding: 8px;
margin: -8px;
color: #555;
line-height: 1;
}
.close-button:hover {
color: #000;
}
.dialog-navigation {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e0e0e0;
gap: 12px;
}
.nav-button {
background-color: #4a6cf7;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.nav-button:hover:not(:disabled) {
background-color: #3a5ce6;
}
.nav-button:disabled {
background-color: #ccc;
cursor: not-allowed;
opacity: 0.6;
}
.row-position {
font-size: 14px;
color: #666;
text-align: center;
flex: 1;
}
.row-detail-content {
font-size: 14px;
}
.row-detail-content .loading {
text-align: center;
color: #666;
padding: 40px;
}
.row-detail-content dl {
margin: 0;
}
.row-detail-content dt {
font-weight: bold;
margin-top: 16px;
margin-bottom: 4px;
color: #333;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.row-detail-content dt:first-child {
margin-top: 0;
}
.row-detail-content dd {
margin: 0;
margin-bottom: 12px;
padding: 12px;
background-color: #f9f9f9;
border-radius: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.row-detail-content dd.null-value {
color: #999;
font-style: italic;
}
.row-detail-content .error {
color: #c00;
padding: 20px;
background-color: #fee;
border-radius: 4px;
}
</style>

View file

@ -19,7 +19,7 @@
</p>
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
<summary style="cursor: pointer;">GET</summary>
<form class="core" method="get" 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>
<label for="path">API path:</label>
<input type="text" id="path" name="path" style="width: 60%">
@ -29,7 +29,7 @@
</details>
<details style="border: 2px solid #ccc; padding: 0.5em">
<summary style="cursor: pointer">POST</summary>
<form class="core" method="post" 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>
<label for="path">API path:</label>
<input type="text" id="path" name="path" style="width: 60%">

View file

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

View file

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

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}CSRF check failed{% endblock %}
{% block title %}CSRF check failed){% endblock %}
{% block content %}
<h1>Form origin check failed</h1>
@ -7,7 +7,7 @@
<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>Reason: {{ reason }}</p>
<p>Error code is {{ message_name }}.</p>
</details>
{% endblock %}

View file

@ -5,7 +5,6 @@
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% 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 %}
{% 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>
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
{% set parameter_names = [] %}
{% set parameter_values = {} %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
<p>
<button id="sql-format" type="button" hidden>Format SQL</button>
<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>
{% endfor %}
</ul>
{% if queries_more %}
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
{% endif %}
{% endif %}
{% if tables %}
@ -95,11 +87,5 @@
{% endif %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %}

View file

@ -1,78 +0,0 @@
{% extends "base.html" %}
{% block title %}Debug autocomplete{% endblock %}
{% block extra_head %}
{{ super() }}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endblock %}
{% block content %}
<h1>Debug autocomplete</h1>
<form class="core debug-autocomplete-form" action="{{ urls.path('-/debug/autocomplete') }}" method="get">
<p>
<label for="debug-autocomplete-database">Database</label>
<input id="debug-autocomplete-database" type="text" name="database" value="{{ database_name or "" }}">
</p>
<p>
<label for="debug-autocomplete-table">Table</label>
<input id="debug-autocomplete-table" type="text" name="table" value="{{ table_name or "" }}">
</p>
<p><input type="submit" value="Open autocomplete"></p>
</form>
{% if error %}
<p class="message-error">{{ error }}</p>
{% elif autocomplete_url %}
<h2>{{ database_name }} / {{ table_name }}</h2>
{% if label_column %}
<p>Label column: <code>{{ label_column }}</code></p>
{% else %}
<p>No label column detected. Results will use primary key values.</p>
{% endif %}
<div class="debug-autocomplete-demo">
<label for="debug-autocomplete-input">Search rows</label>
<datasette-autocomplete src="{{ autocomplete_url }}">
<input id="debug-autocomplete-input" type="text">
</datasette-autocomplete>
</div>
<h3>Selected row</h3>
<pre class="debug-autocomplete-selected" aria-live="polite">No row selected.</pre>
<script>
document.addEventListener("datasette-autocomplete-select", function (event) {
var output = document.querySelector(".debug-autocomplete-selected");
if (output) {
output.textContent = JSON.stringify(event.detail.row, null, 2);
}
});
</script>
{% else %}
<h2>Suggested tables</h2>
{% if suggestions %}
<p>Showing up to five tables with a detected label column.</p>
<table class="rows-and-columns">
<thead>
<tr>
<th>Database</th>
<th>Table</th>
<th>Label column</th>
</tr>
</thead>
<tbody>
{% for suggestion in suggestions %}
<tr>
<td>{{ suggestion.database }}</td>
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
<td><code>{{ suggestion.label_column }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No tables with detected label columns found.</p>
{% endif %}
<p>Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.</p>
{% endif %}
{% endblock %}

View file

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

View file

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

View file

@ -11,7 +11,7 @@
<header class="hd"><nav>
<p class="crumbs">
<a href="{{ base_url }}">home</a>
<a href="/">home</a>
</p>
<details class="nav-menu details-menu">
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
@ -22,11 +22,11 @@
</svg></summary>
<div class="nav-menu-inner">
<ul>
<li><a href="{{ base_url }}-/databases">Databases</a></li>
<li><a href="{{ base_url }}-/plugins">Installed plugins</a></li>
<li><a href="{{ base_url }}-/versions">Version info</a></li>
<li><a href="/-/databases">Databases</a></li>
<li><a href="/-/plugins">Installed plugins</a></li>
<li><a href="/-/versions">Version info</a></li>
</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>
</form>
</div>
@ -48,9 +48,9 @@
<header class="hd">
<nav>
<p class="crumbs">
<a href="{{ base_url }}">home</a> /
<a href="{{ base_url }}fixtures">fixtures</a> /
<a href="{{ base_url }}fixtures/attraction_characteristic">attraction_characteristic</a>
<a href="/">home</a> /
<a href="/fixtures">fixtures</a> /
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
</p>
<div class="actor">
<strong>testuser</strong>
@ -80,16 +80,16 @@
<a href="https://github.com/simonw/datasette">
About Datasette</a>
</p>
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="{{ base_url }}fixtures">fixtures</a></h2>
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="/fixtures">fixtures</a></h2>
<p>
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
</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>
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="{{ base_url }}data">data</a></h2>
<p><a href="/fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="/fixtures/sortable" title="201 rows">sortable</a>, <a href="/fixtures/facetable" title="15 rows">facetable</a>, <a href="/fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="/fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="/fixtures">...</a></p>
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="/data">data</a></h2>
<p>
6 rows in 2 tables
</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>
<h2 class="pattern-heading">.bd for /database</h2>
@ -134,7 +134,7 @@
<a href="https://github.com/simonw/datasette">
About Datasette</a>
</p>
<form class="sql" action="{{ base_url }}fixtures" method="get">
<form class="sql" action="/fixtures" method="get">
<h3>Custom SQL query</h3>
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
<p>
@ -143,17 +143,17 @@
</p>
</form>
<div class="db-table">
<h2><a href="{{ 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>0 rows</p>
</div>
<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>0 rows</p>
</div>
<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>2 rows</p>
</div>
@ -202,7 +202,7 @@
<h3>3 rows
where characteristic_id = 2
</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="filter-row">
<div class="select-wrapper">
@ -290,16 +290,16 @@
<h3>2 extra where clauses</h3>
<ul>
<li><code>planet_int=1</code> [<a href="{{ 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>
</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">
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">
<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>
<ul>
@ -336,7 +336,7 @@
<p class="facet-info-name">
<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>
<ul>
@ -361,7 +361,7 @@
<p class="facet-info-name">
<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>
<ul>
@ -387,45 +387,45 @@
Link
</th>
<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 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 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>
</tr>
</thead>
<tbody>
<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-attraction_id"><a href="{{ base_url }}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-attraction_id"><a href="/fixtures/roadside_attractions/1">The Mystery Spot</a>&nbsp;<em>1</em></td>
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</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-attraction_id"><a href="{{ base_url }}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-attraction_id"><a href="/fixtures/roadside_attractions/2">Winchester Mystery House</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>
<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-attraction_id"><a href="{{ base_url }}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-attraction_id"><a href="/fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a>&nbsp;<em>4</em></td>
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a>&nbsp;<em>2</em></td>
</tr>
</tbody>
</table>
<div id="export" class="advanced-export">
<h3>Advanced export</h3>
<p>JSON shape:
<a href="{{ base_url }}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="{{ 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">default</a>,
<a href="/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&amp;_nl=on">newline-delimited</a>
</p>
<form action="{{ base_url }}fixtures/roadside_attraction_characteristics.csv" method="get">
<form action="/fixtures/roadside_attraction_characteristics.csv" method="get">
<p>
CSV options:
<label><input type="checkbox" name="_dl"> download file</label>
@ -445,7 +445,7 @@
<h2 class="pattern-heading">.bd for /database/table/row</h2>
<section class="content">
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
<p>This data as <a href="{{ 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">
<thead>
<tr>
@ -479,7 +479,7 @@
<h2>Links from other tables</h2>
<ul>
<li>
<a href="{{ base_url }}fixtures/roadside_attraction_characteristics?attraction_id=2">
<a href="/fixtures/roadside_attraction_characteristics?attraction_id=2">
1 row</a>
from attraction_id in roadside_attraction_characteristics
</li>

View file

@ -14,10 +14,9 @@
</style>
{% endif %}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% 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 %}
{{ crumbs.nav(request=request, database=database) }}
@ -25,19 +24,19 @@
{% 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>
{% 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" %}
{% 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 %}
<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 %}
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
{% endif %}</h3>
@ -46,43 +45,57 @@
{% endif %}
{% if not hide_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 %}
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
<p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
>{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
{% else %}
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
{% endif %}
{% else %}
{% if not stored_query %}
{% if not canned_query %}
<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 %}
{% set parameter_names = named_parameter_values.keys()|list %}
{% set parameter_values = named_parameter_values %}
{% set sql_parameters_allow_expand = false %}
{% include "_sql_parameters.html" %}
{% if named_parameter_values %}
<h3>Query parameters</h3>
{% for name, value in named_parameter_values.items() %}
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
{% endfor %}
{% endif %}
<p>
{% 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 }}
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %}
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
</p>
</form>
{% if display_rows %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
<div class="table-wrapper"><table class="rows-and-columns">
<thead>
<tr>
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in display_rows %}
<tr>
{% for column, td in zip(columns, row) %}
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
{% if not canned_query_write and not error %}
<p class="zero-results">0 results</p>
{% endif %}
{% endif %}
{% set show_zero_results = not stored_query_write and not error %}
{% include "_query_results.html" %}
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
window.datasetteSqlParameters.setupSqlParameterRefresh({});
});
</script>
{% endblock %}

View file

@ -1,163 +0,0 @@
{% extends "base.html" %}
{% block title %}Create query{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
{% include "_query_form_styles.html" %}
{% endblock %}
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-create-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/store" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
<div class="query-create-fields">
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
</div>
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="query-create-options">
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
<input type="hidden" name="is_private" value="0">
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
</p>
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
{% if has_sql %}
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
{% endif %}
</div>
</form>
</div>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const titleInput = document.querySelector("#query-title");
const urlInput = document.querySelector("#query-url-slug");
let urlEdited = Boolean(urlInput && urlInput.value);
function slugify(value) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
if (titleInput && urlInput) {
titleInput.addEventListener("input", () => {
if (!urlEdited) {
urlInput.value = slugify(titleInput.value);
}
});
urlInput.addEventListener("input", () => {
urlEdited = true;
});
}
});
</script>
<script>
window.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#query-create-analysis-section");
const submitButton = form
? form.querySelector("[data-query-create-submit]")
: null;
const analysisNote = form
? form.querySelector("[data-query-create-analysis-note]")
: null;
function updateAnalysisNote(data) {
if (!analysisNote) {
return;
}
if (data.analysis_error) {
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
} else if (data.has_sql === false) {
analysisNote.textContent = "Enter SQL to analyze this query.";
} else if (data.analysis_is_write) {
analysisNote.textContent = "This query updates data in the database.";
} else {
analysisNote.textContent = "This is a read-only query.";
}
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
renderParameters: false,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
if (submitButton) {
submitButton.disabled = data.save_disabled;
}
updateAnalysisNote(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
if (submitButton) {
submitButton.disabled = true;
}
updateAnalysisNote({ analysis_error: error.message });
},
});
});
</script>
{% endblock %}

View file

@ -1,82 +0,0 @@
{% extends "base.html" %}
{% block title %}Delete query: {{ query.name }}{% endblock %}
{% block extra_head %}
{{- super() -}}
<style>
.query-delete-page {
max-width: 48rem;
}
.query-delete-summary {
background-color: #f7f7f9;
border: 1px solid #d7dde5;
border-radius: 4px;
margin: 0.75rem 0 1.25rem;
padding: 0.75rem 1rem;
}
.query-delete-summary dt {
color: #4f5b6d;
font-size: 0.82rem;
font-weight: 700;
}
.query-delete-summary dd {
margin: 0 0 0.6rem;
}
.query-delete-summary dd pre {
margin: 0.2rem 0 0;
white-space: pre-wrap;
}
.query-delete-actions {
align-items: center;
display: flex;
gap: 1rem;
}
.query-delete-form input[type=submit] {
background: linear-gradient(180deg, #d73a31 0%, #b42318 100%);
border-color: #b42318;
font-weight: 700;
}
.query-delete-form input[type=submit]:hover,
.query-delete-form input[type=submit]:focus {
background: linear-gradient(180deg, #c3342b 0%, #971c14 100%);
border-color: #971c14;
}
</style>
{% endblock %}
{% block body_class %}query-delete db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-delete-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Delete query: {{ query.title or query.name }}</h1>
<p>Are you sure you want to delete this saved query? This cannot be undone.</p>
<dl class="query-delete-summary">
<dt>URL</dt>
<dd><a href="{{ query_url }}">{{ query_url }}</a></dd>
{% if query.description %}
<dt>Description</dt>
<dd>{{ query.description }}</dd>
{% endif %}
<dt>SQL</dt>
<dd><pre>{{ query.sql }}</pre></dd>
</dl>
<form class="core query-delete-form" action="{{ query_url }}/-/delete" method="post">
<p class="query-delete-actions">
<input type="submit" value="Delete query">
<a href="{{ query_url }}">Cancel</a>
</p>
</form>
</div>
{% endblock %}

View file

@ -1,133 +0,0 @@
{% extends "base.html" %}
{% block title %}Edit query: {{ name }}{% endblock %}
{% block extra_head %}
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_execute_write_analysis_styles.html" %}
{% include "_query_form_styles.html" %}
{% endblock %}
{% block body_class %}query-edit db-{{ database|to_css_class }}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
{% endblock %}
{% block content %}
<div class="query-create-page">
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Edit query: {{ title or name }}</h1>
<form class="sql core query-create-form" action="{{ query_url }}/-/edit" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
<div class="query-create-fields">
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
<p class="query-create-field"><label>URL</label> <span class="query-create-url-static">{{ query_url }}</span></p>
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
</div>
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="query-create-options">
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
<input type="hidden" name="is_private" value="0">
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
<span class="query-create-option-note">Queries marked private can only be seen and edited by you, their owner.</span>
</p>
<p class="query-create-submit"><input type="submit" value="Save changes" data-query-create-submit{% if save_disabled %} disabled{% endif %}> <a href="{{ query_url }}">Cancel</a></p>
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
{% if has_sql %}
<h2>Query operations</h2>
{% if analysis_error %}
<p class="message-error">{{ analysis_error }}</p>
{% elif analysis_rows %}
<div class="table-wrapper"><table class="execute-write-analysis">
<thead>
<tr>
<th scope="col">Operation</th>
<th scope="col">Database</th>
<th scope="col">Table</th>
<th scope="col">Required permission</th>
<th scope="col">Allowed</th>
</tr>
</thead>
<tbody>
{% for row in analysis_rows %}
<tr>
<td><code>{{ row.operation }}</code></td>
<td><code>{{ row.database }}</code></td>
<td><code>{{ row.table }}</code></td>
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>Analysis will show each affected table and required permission.</p>
{% endif %}
{% endif %}
</div>
</form>
</div>
{% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %}
<script>
window.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#query-create-analysis-section");
const submitButton = form
? form.querySelector("[data-query-create-submit]")
: null;
const analysisNote = form
? form.querySelector("[data-query-create-analysis-note]")
: null;
function updateAnalysisNote(data) {
if (!analysisNote) {
return;
}
if (data.analysis_error) {
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
} else if (data.has_sql === false) {
analysisNote.textContent = "Enter SQL to analyze this query.";
} else if (data.analysis_is_write) {
analysisNote.textContent = "This query updates data in the database.";
} else {
analysisNote.textContent = "This is a read-only query.";
}
}
window.datasetteSqlParameters.setupSqlParameterRefresh({
form,
url: form.dataset.analyzeUrl,
renderParameters: false,
onData(data) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
if (submitButton) {
submitButton.disabled = data.save_disabled;
}
updateAnalysisNote(data);
},
onError(error) {
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
analysis_error: error.message,
analysis_rows: [],
});
if (submitButton) {
submitButton.disabled = true;
}
updateAnalysisNote({ analysis_error: error.message });
},
});
});
</script>
{% endblock %}

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>
{% if queries %}
<form class="query-list-filters core" action="{{ query_list_path }}" method="get">
<p class="query-list-search">
<label for="query-search">Search</label>
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
{% if filters.is_write %}<input type="hidden" name="is_write" value="{{ filters.is_write }}">{% endif %}
{% if filters.is_private %}<input type="hidden" name="is_private" value="{{ filters.is_private }}">{% endif %}
{% if filters.source %}<input type="hidden" name="source" value="{{ filters.source }}">{% endif %}
{% if filters.owner_id %}<input type="hidden" name="owner_id" value="{{ filters.owner_id }}">{% endif %}
<button type="submit">Search</button>
</p>
</form>
<nav class="query-list-facets" aria-label="Query filters">
{% for facet in facets %}
<section class="query-list-facet">
<h2>{{ facet.title }}</h2>
<ul>
{% for item in facet["items"] %}
<li>{% if item.href %}<a class="query-list-facet-link{% if item.active %} query-list-facet-link-active{% endif %}" href="{{ item.href }}"{% if item.active %} aria-current="true"{% endif %}>{% else %}<span class="query-list-facet-link query-list-facet-disabled">{% endif %}<span>{{ item.label }}</span><span class="query-list-facet-count">{{ item.count }}</span>{% if item.href %}</a>{% else %}</span>{% endif %}</li>
{% endfor %}
</ul>
</section>
{% endfor %}
</nav>
<div class="table-wrapper"><table class="query-list-results">
<thead>
<tr>
{% if show_database %}<th scope="col">Database</th>{% endif %}
<th scope="col">Query</th>
<th scope="col">Owner</th>
<th scope="col">Flags</th>
</tr>
</thead>
<tbody>
{% for query in queries %}
<tr>
{% if show_database %}
<td><a class="query-list-database" href="{{ urls.database(query.database) }}">{{ query.database }}</a></td>
{% endif %}
<td>
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
</td>
<td class="query-list-owner">{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}<span class="query-list-empty">-</span>{% endif %}</td>
<td>
<span class="query-list-flags">
{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}
{% if query.is_private %}<span class="query-list-pill query-list-pill-private">Private</span>{% endif %}
{% if query.is_trusted %}<span class="query-list-pill query-list-pill-trusted">Trusted</span>{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% if show_private_note or show_trusted_note %}
<div class="query-list-footnotes">
{% if show_private_note %}<p><span class="query-list-pill query-list-pill-private">Private</span>Only the owning actor can view this query.</p>{% endif %}
{% if show_trusted_note %}<p><span class="query-list-pill query-list-pill-trusted">Trusted</span>Execution skips the usual SQL and write permission checks after view-query allows access.</p>{% endif %}
</div>
{% endif %}
{% else %}
<p>No queries found.</p>
{% endif %}
{% if next_url %}
<nav class="query-list-pagination query-list-pagination-bottom" aria-label="Query pagination"><a href="{{ next_url }}">Next page</a></nav>
{% endif %}
</div>
{% endblock %}

View file

@ -4,13 +4,6 @@
{% block extra_head %}
{{- super() -}}
{% if row_mutation_ui %}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
{% endif %}
<style>
@media only screen and (max-width: 576px) {
{% for column in columns %}

View file

@ -4,14 +4,8 @@
{% block extra_head %}
{{- super() -}}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script src="{{ urls.static('table.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style>
@media only screen and (max-width: 576px) {
@ -146,35 +140,10 @@
{% 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 %}
{% if table_insert_ui %}
<div class="table-row-toolbar">
<button type="button" class="core table-insert-row" data-table-action="insert-row">
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M8 12h8"></path>
<path d="M12 8v8"></path>
</svg>
<span>Insert row</span>
</button>
</div>
{% endif %}
{% include custom_table_templates %}

View file

@ -52,38 +52,6 @@ class TokenRestrictions:
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
return self
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
"""
Return the abbreviated ``_r`` dictionary shape for this set of
restrictions, using action abbreviations registered with ``datasette``.
Returns ``None`` if no restrictions are set.
"""
if not (self.all or self.database or self.resource):
return None
def abbreviate_action(action):
action_obj = datasette.actions.get(action)
if not action_obj:
return action
return action_obj.abbr or action
result: dict = {}
if self.all:
result["a"] = [abbreviate_action(a) for a in self.all]
if self.database:
result["d"] = {
database: [abbreviate_action(a) for a in actions]
for database, actions in self.database.items()
}
if self.resource:
result["r"] = {}
for database, resources in self.resource.items():
for resource, actions in resources.items():
result["r"].setdefault(database, {})[resource] = [
abbreviate_action(a) for a in actions
]
return result
class TokenHandler:
"""
@ -136,12 +104,31 @@ class SignedTokenHandler(TokenHandler):
token = {"a": actor_id, "t": int(time.time())}
def abbreviate_action(action):
action_obj = datasette.actions.get(action)
if not action_obj:
return action
return action_obj.abbr or action
if expires_after:
token["d"] = expires_after
if restrictions is not None:
abbreviated = restrictions.abbreviated(datasette)
if abbreviated is not None:
token["_r"] = abbreviated
if restrictions and (
restrictions.all or restrictions.database or restrictions.resource
):
token["_r"] = {}
if restrictions.all:
token["_r"]["a"] = [abbreviate_action(a) for a in restrictions.all]
if restrictions.database:
token["_r"]["d"] = {}
for database, actions in restrictions.database.items():
token["_r"]["d"][database] = [abbreviate_action(a) for a in actions]
if restrictions.resource:
token["_r"]["r"] = {}
for database, resources in restrictions.resource.items():
for resource, actions in resources.items():
token["_r"]["r"].setdefault(database, {})[resource] = [
abbreviate_action(a) for a in actions
]
return "dstok_{}".format(datasette.sign(token, namespace="token"))
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:

View file

@ -27,10 +27,8 @@ def get_task_id():
@contextmanager
def trace_child_tasks():
token = trace_task_id.set(get_task_id())
try:
yield
finally:
trace_task_id.reset(token)
yield
trace_task_id.reset(token)
@contextmanager

View file

@ -155,15 +155,9 @@ Column = namedtuple(
functions_marked_as_documented = []
def documented(fn=None, *, label=None):
def decorate(fn):
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
functions_marked_as_documented.append(fn)
return fn
if fn is None:
return decorate
return decorate(fn)
def documented(fn):
functions_marked_as_documented.append(fn)
return fn
@documented
@ -410,10 +404,6 @@ def escape_css_string(s):
def escape_sqlite(s):
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
return s
elif "]" in s:
# SQLite does not support escaping ] inside [bracket] quoting, so fall
# back to double-quote quoting (doubling any embedded ") - #2677
return '"{}"'.format(s.replace('"', '""'))
else:
return f"[{s}]"
@ -691,18 +681,13 @@ def detect_fts_sql(table):
def detect_json1(conn=None):
close_conn = False
if conn is None:
conn = sqlite3.connect(":memory:")
close_conn = True
try:
conn.execute("SELECT json('{}')")
return True
except Exception:
return False
finally:
if close_conn:
conn.close()
def table_columns(conn, table):
@ -841,8 +826,7 @@ def path_with_format(
*, request=None, path=None, format=None, extra_qs=None, replace_format=None
):
qs = extra_qs or {}
if path is None and request:
path = request.path
path = request.path if request else path
if replace_format and path.endswith(f".{replace_format}"):
path = path[: -(1 + len(replace_format))]
if "." in path:
@ -1102,35 +1086,12 @@ def _gather_arguments(fn, kwargs):
return call_with
@documented
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)
return fn(*call_with)
@documented
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)
return await fn(*call_with)

View file

@ -21,8 +21,6 @@ The core pattern is:
- Across levels, child beats parent beats global
"""
import asyncio
import re
from typing import TYPE_CHECKING
from datasette.utils.permissions import gather_permission_sql_from_hooks
@ -149,9 +147,7 @@ async def _build_single_action_sql(
raise ValueError(f"Unknown action: {action}")
# Get base resources SQL from the resource class
base_resources_sql = await action_obj.resource_class.resources_sql(
datasette, actor=actor
)
base_resources_sql = await action_obj.resource_class.resources_sql(datasette)
permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette,
@ -243,14 +239,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
query_parts.extend(
@ -497,153 +485,6 @@ async def build_permission_rules_sql(
return rules_union, all_params, restriction_sqls
async def check_permissions_for_actions(
*,
datasette: "Datasette",
actor: dict | None,
actions: list[str],
parent: str | None,
child: str | None,
) -> dict[str, bool]:
"""
Check several actions for one actor and resource in a single query.
Args:
datasette: The Datasette instance
actor: The actor dict (or None)
actions: List of action names to check
parent: The parent resource identifier (e.g., database name, or None)
child: The child resource identifier (e.g., table name, or None)
Returns:
Dict mapping each action name to True (allowed) or False (denied)
Each action contributes its own tagged block of permission rules
(gathered from the permission_resources_sql hook, with parameters
namespaced per action to avoid collisions) plus an optional
restriction allowlist CTE. One internal database query resolves
the winning rule per action using the same specificity-then-deny
ordering as the rest of the permission system.
Note: this resolves each action independently - also_requires
dependencies are handled by the caller (Datasette.allowed_many).
"""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
for action in actions:
if not datasette.actions.get(action):
raise ValueError(f"Unknown action: {action}")
# Dedupe while preserving order
unique_actions = list(dict.fromkeys(actions))
if not unique_actions:
return {}
# Gather hook results for each action concurrently - hooks within a
# single action still run sequentially, preserving existing semantics
gathered = await asyncio.gather(
*(
gather_permission_sql_from_hooks(
datasette=datasette, actor=actor, action=action
)
for action in unique_actions
)
)
if any(result is SKIP_PERMISSION_CHECKS for result in gathered):
return {action: True for action in unique_actions}
params = {"_check_parent": parent, "_check_child": child}
ctes = []
result_rows = []
verdicts = {}
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
prefix = f"a{i}_"
rule_parts = []
restriction_parts = []
for permission_sql in permission_sqls:
sql = permission_sql.sql
restriction_sql = permission_sql.restriction_sql
# Namespace this block's params so identical names used for
# different actions cannot collide
for key in permission_sql.params or {}:
new_key = prefix + key
params[new_key] = permission_sql.params[key]
pattern = re.compile(":" + re.escape(key) + r"(?![A-Za-z0-9_])")
if sql:
sql = pattern.sub(":" + new_key, sql)
if restriction_sql:
restriction_sql = pattern.sub(":" + new_key, restriction_sql)
if restriction_sql:
restriction_parts.append(restriction_sql)
# Skip plugins that only provide restriction_sql (no permission rules)
if sql is None:
continue
rule_parts.append(
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
)
if not rule_parts:
# No rules from any plugin - default deny. Restrictions can
# only restrict, never grant, so no SQL is needed at all
verdicts[action] = False
continue
ctes.append(f"a{i}_rules AS (\n" + "\nUNION ALL\n".join(rule_parts) + "\n)")
# Winning rule for this action: most specific depth first, then
# deny-beats-allow, then source_plugin as a stable tie-break
verdict_sql = f"""COALESCE((
SELECT allow FROM (
SELECT allow, source_plugin,
CASE
WHEN child IS NOT NULL THEN 2
WHEN parent IS NOT NULL THEN 1
ELSE 0
END AS depth
FROM a{i}_rules
WHERE (parent IS NULL OR parent = :_check_parent)
AND (child IS NULL OR child = :_check_child)
ORDER BY
depth DESC,
CASE WHEN allow = 0 THEN 0 ELSE 1 END,
source_plugin
LIMIT 1
)
), 0)"""
if restriction_parts:
# Database-level restrictions (parent, NULL) match all children
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_parts
)
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
verdict_sql = f"""({verdict_sql}) AND EXISTS (
SELECT 1 FROM a{i}_restriction r
WHERE (r.parent = :_check_parent OR r.parent IS NULL)
AND (r.child = :_check_child OR r.child IS NULL)
)"""
result_rows.append(f"({i}, ({verdict_sql}))")
if result_rows:
ctes.append(
"results(action_idx, is_allowed) AS (VALUES\n"
+ ",\n".join(result_rows)
+ "\n)"
)
query = (
"WITH\n" + ",\n".join(ctes) + "\nSELECT action_idx, is_allowed FROM results"
)
result = await datasette.get_internal_database().execute(query, params)
for row in result.rows:
verdicts[unique_actions[row[0]]] = bool(row[1])
return verdicts
async def check_permission_for_resource(
*,
datasette: "Datasette",
@ -664,12 +505,77 @@ async def check_permission_for_resource(
Returns:
True if the actor is allowed, False otherwise
This builds the cascading permission query and checks if the specific
resource is in the allowed set.
"""
results = await check_permissions_for_actions(
datasette=datasette,
actor=actor,
actions=[action],
parent=parent,
child=child,
rules_union, all_params, restriction_sqls = await build_permission_rules_sql(
datasette, actor, action
)
return results[action]
# If no rules (empty SQL), default deny
if not rules_union:
return False
# Add parameters for the resource we're checking
all_params["_check_parent"] = parent
all_params["_check_child"] = child
# If there are restriction filters, check if the resource passes them first
if restriction_sqls:
# Check if resource is in restriction allowlist
# Database-level restrictions (parent, NULL) should match all children (parent, *)
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
restriction_check = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_sqls
)
restriction_query = f"""
WITH restriction_list AS (
{restriction_check}
)
SELECT EXISTS (
SELECT 1 FROM restriction_list
WHERE (parent = :_check_parent OR parent IS NULL)
AND (child = :_check_child OR child IS NULL)
) AS in_allowlist
"""
result = await datasette.get_internal_database().execute(
restriction_query, all_params
)
if result.rows and not result.rows[0][0]:
# Resource not in restriction allowlist - deny
return False
query = f"""
WITH
all_rules AS (
{rules_union}
),
matched_rules AS (
SELECT ar.*,
CASE
WHEN ar.child IS NOT NULL THEN 2 -- child-level (most specific)
WHEN ar.parent IS NOT NULL THEN 1 -- parent-level
ELSE 0 -- root/global
END AS depth
FROM all_rules ar
WHERE (ar.parent IS NULL OR ar.parent = :_check_parent)
AND (ar.child IS NULL OR ar.child = :_check_child)
),
winner AS (
SELECT *
FROM matched_rules
ORDER BY
depth DESC, -- specificity first (higher depth wins)
CASE WHEN allow=0 THEN 0 ELSE 1 END, -- then deny over allow
source_plugin -- stable tie-break
LIMIT 1
)
SELECT COALESCE((SELECT allow FROM winner), 0) AS is_allowed
"""
# Execute the query against the internal database
result = await datasette.get_internal_database().execute(query, all_params)
if result.rows:
return bool(result.rows[0][0])
return False

View file

@ -155,10 +155,6 @@ class Request:
body = await self.post_body()
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
async def json(self):
body = await self.post_body()
return json.loads(body)
async def form(
self,
files: bool = False,
@ -334,11 +330,9 @@ async def asgi_send_html(send, html, status=200, headers=None):
async def asgi_send_redirect(send, location, status=302):
# Prevent open redirect vulnerability: collapse leading slashes and
# backslashes down to a single slash. //example.com is a protocol-relative
# URL, and browsers normalise backslashes to slashes so /\example.com would
# be treated as //example.com - https://github.com/simonw/datasette/issues/2680
location = re.sub(r"^[/\\]+", "/", location)
# Prevent open redirect vulnerability: strip multiple leading slashes
# //example.com would be interpreted as a protocol-relative URL (e.g., https://example.com/)
location = re.sub(r"^/+", "/", location)
await asgi_send(
send,
"",

View file

@ -103,37 +103,6 @@ async def initialize_metadata_tables(db):
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
"""))

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

View file

@ -95,8 +95,15 @@ class TestClient:
cookies = cookies or {}
post_data = post_data or {}
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.
# Datasette no longer uses CSRF tokens - see CrossOriginProtectionMiddleware.
# Maybe fetch a csrftoken first
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:
body = urlencode(post_data, doseq=True)
return await self._request(

View file

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

View file

@ -153,13 +153,7 @@ class BaseView:
if self.has_json_alternate:
alternate_url_json = self.ds.absolute_url(
request,
self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
self.ds.urls.path(path_with_format(request=request, format="json")),
)
template_context["alternate_url_json"] = alternate_url_json
headers.update(
@ -353,21 +347,13 @@ class DataView(BaseView):
if it_can_render:
renderers[key] = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
request=request, format=key, extra_qs={**url_labels_extra}
)
)
url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
path_with_format(request=request, format="csv", extra_qs=url_csv_args)
)
url_csv_path = url_csv.split("?")[0]
context = {

View file

@ -11,11 +11,8 @@ import sqlite_utils
import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
from datasette.write_sql import QueryWriteRejected
from datasette.utils import (
add_cors_headers,
await_me_maybe,
@ -38,12 +35,6 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm
from .base import BaseView, DatasetteError, View, _error, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_extras import (
QueryExtraContext,
resolve_query_extras,
table_extra_registry,
)
from . import Context
@ -66,16 +57,12 @@ class DatabaseView(View):
sql = (request.args.get("sql") or "").strip()
if sql:
redirect_url = datasette.urls.database(database) + "/-/query"
redirect_url = "/" + request.url_vars.get("database") + "/-/query"
if request.url_vars.get("format"):
redirect_url = path_with_format(
path=redirect_url, format=request.url_vars.get("format")
)
redirect_url += "." + request.url_vars.get("format")
redirect_url += "?" + request.query_string
response = Response.redirect(redirect_url)
if datasette.cors:
add_cors_headers(response.headers)
return response
return Response.redirect(redirect_url)
return await QueryView()(request, datasette)
if format_ not in ("html", "json"):
raise NotFound("Invalid format: {}".format(format_))
@ -103,34 +90,26 @@ class DatabaseView(View):
tables = await get_tables(datasette, request, db, allowed_dict)
queries_page = await datasette.list_queries(
database,
actor=request.actor,
limit=5,
include_private=True,
)
stored_queries = queries_page.queries
queries_more = queries_page.has_more
queries_count = (
await datasette.count_queries(database, actor=request.actor)
if queries_more
else len(stored_queries)
# Get allowed queries using the new permission system
allowed_query_page = await datasette.allowed_resources(
"view-query",
request.actor,
parent=database,
include_is_private=True,
limit=1000,
)
# Build canned_queries list by looking up each allowed query
all_queries = await datasette.get_canned_queries(database, request.actor)
canned_queries = []
for query_resource in allowed_query_page.resources:
query_name = query_resource.child
if query_name in all_queries:
canned_queries.append(
dict(all_queries[query_name], private=query_resource.private)
)
async def database_actions():
# Resolve the registered database-level actions for this
# database in one batched query, seeding the request permission
# cache so that allowed() calls made inside the plugin hooks
# below are served from the cache
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
links = []
for hook in pm.hook.database_actions(
datasette=datasette,
@ -151,7 +130,6 @@ class DatabaseView(View):
actor=request.actor,
)
json_data = {
"ok": True,
"database": database,
"private": private,
"path": datasette.urls.database(database),
@ -159,9 +137,7 @@ class DatabaseView(View):
"tables": tables,
"hidden_count": len([t for t in tables if t["hidden"]]),
"views": sql_views,
"queries": [stored_query_to_dict(query) for query in stored_queries],
"queries_more": queries_more,
"queries_count": queries_count,
"queries": canned_queries,
"allow_execute_sql": allow_execute_sql,
"table_columns": (
await _table_columns(datasette, database) if allow_execute_sql else {}
@ -178,13 +154,7 @@ class DatabaseView(View):
assert format_ == "html"
alternate_url_json = datasette.absolute_url(
request,
datasette.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
datasette.urls.path(path_with_format(request=request, format="json")),
)
templates = (f"database-{to_css_class(database)}.html", "database.html")
environment = datasette.get_jinja_environment(request)
@ -200,9 +170,7 @@ class DatabaseView(View):
tables=tables,
hidden_count=len([t for t in tables if t["hidden"]]),
views=sql_views,
queries=stored_queries,
queries_more=queries_more,
queries_count=queries_count,
queries=canned_queries,
allow_execute_sql=allow_execute_sql,
table_columns=(
await _table_columns(datasette, database)
@ -250,11 +218,7 @@ class DatabaseContext(Context):
tables: list = field(metadata={"help": "List of table objects in the database"})
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
views: list = field(metadata={"help": "List of view objects in the database"})
queries: list = field(metadata={"help": "List of stored query objects"})
queries_more: bool = field(
metadata={"help": "Boolean indicating if more stored queries are available"}
)
queries_count: int = field(metadata={"help": "Count of visible stored queries"})
queries: list = field(metadata={"help": "List of canned query objects"})
allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"}
)
@ -299,8 +263,8 @@ class QueryContext(Context):
query: dict = field(
metadata={"help": "The SQL query object containing the `sql` string"}
)
stored_query: str = field(
metadata={"help": "The name of the stored query if this is a stored query"}
canned_query: str = field(
metadata={"help": "The name of the canned query if this is a canned query"}
)
private: bool = field(
metadata={"help": "Boolean indicating if this is a private database"}
@ -308,13 +272,13 @@ class QueryContext(Context):
# urls: dict = field(
# metadata={"help": "Object containing URL helpers like `database()`"}
# )
stored_query_write: bool = field(
canned_query_write: bool = field(
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={"help": "Metadata about the database or the stored query"}
metadata={"help": "Metadata about the database or the canned query"}
)
db_is_immutable: bool = field(
metadata={"help": "Boolean indicating if this database is immutable"}
@ -335,15 +299,12 @@ class QueryContext(Context):
allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"}
)
save_query_url: str = field(
metadata={"help": "URL to save the current arbitrary SQL as a query"}
)
tables: list = field(metadata={"help": "List of table objects in the database"})
named_parameter_values: dict = field(
metadata={"help": "Dictionary of parameter names/values"}
)
edit_sql_url: str = field(
metadata={"help": "URL to edit the SQL for a 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"})
columns: list = field(metadata={"help": "List of column names"})
@ -367,8 +328,8 @@ class QueryContext(Context):
top_query: callable = field(
metadata={"help": "Callable to render the top_query slot"}
)
top_stored_query: callable = field(
metadata={"help": "Callable to render the top_stored_query slot"}
top_canned_query: callable = field(
metadata={"help": "Callable to render the top_canned_query slot"}
)
query_actions: callable = field(
metadata={
@ -459,47 +420,21 @@ class QueryView(View):
db = await datasette.resolve_database(request)
# We must be a stored query
# We must be a canned query
table_found = False
try:
await datasette.resolve_table(request)
table_found = True
except TableNotFound as table_not_found:
stored_query = await datasette.get_query(
table_not_found.database_name, table_not_found.table
canned_query = await datasette.get_canned_query(
table_not_found.database_name, table_not_found.table, request.actor
)
if stored_query is None:
if canned_query is None:
raise
if table_found:
# That should not have happened
raise DatasetteError("Unexpected table found on POST", status=404)
if not await datasette.allowed(
action="view-query",
resource=QueryResource(database=db.name, query=stored_query.name),
actor=request.actor,
):
raise Forbidden("You do not have permission to view this query")
try:
await _ensure_stored_query_execution_permissions(
datasette, db, stored_query, request.actor
)
except QueryWriteRejected as ex:
if request.headers.get("accept") == "application/json" or request.args.get(
"_json"
):
return Response.json(
{
"ok": False,
"message": ex.message,
"redirect": None,
},
status=403,
)
datasette.add_message(request, ex.message, datasette.ERROR)
return Response.redirect(stored_query.on_error_redirect or request.path)
# If database is immutable, return an error
if not db.is_mutable:
raise Forbidden("Database is immutable")
@ -524,18 +459,20 @@ class QueryView(View):
or request.args.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()
ok = None
redirect_url = None
try:
cursor = await db.execute_write(
stored_query.sql, params_for_query, request=request
canned_query["sql"], params_for_query, request=request
)
# success message can come from on_success_message or on_success_message_sql
message = None
message_type = datasette.INFO
on_success_message_sql = stored_query.on_success_message_sql
on_success_message_sql = canned_query.get("on_success_message_sql")
if on_success_message_sql:
try:
message_result = (
@ -547,21 +484,18 @@ class QueryView(View):
message = "Error running on_success_message_sql: {}".format(ex)
message_type = datasette.ERROR
if not message:
if stored_query.on_success_message:
message = stored_query.on_success_message
elif cursor.rowcount == -1:
message = "Query executed"
else:
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
message = canned_query.get(
"on_success_message"
) or "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
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
redirect_url = stored_query.on_error_redirect
redirect_url = canned_query.get("on_error_redirect")
ok = False
if should_return_json:
return Response.json(
@ -594,59 +528,53 @@ class QueryView(View):
# Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
# Are we a stored query?
stored_query = None
stored_query_write = False
# Are we a canned query?
canned_query = None
canned_query_write = False
if "table" in request.url_vars:
try:
await datasette.resolve_table(request)
except TableNotFound as table_not_found:
# Was this actually a stored query?
stored_query = await datasette.get_query(
table_not_found.database_name, table_not_found.table
# Was this actually a canned query?
canned_query = await datasette.get_canned_query(
table_not_found.database_name, table_not_found.table, request.actor
)
if stored_query is None:
if canned_query is None:
raise
stored_query_write = stored_query.is_write
canned_query_write = bool(canned_query.get("write"))
private = False
if stored_query:
# Respect stored query permissions
if canned_query:
# Respect canned query permissions
visible, private = await datasette.check_visibility(
request.actor,
action="view-query",
resource=QueryResource(database=database, query=stored_query.name),
resource=QueryResource(database=database, query=canned_query["name"]),
)
if not visible:
raise Forbidden("You do not have permission to view this query")
if not stored_query_write:
await _ensure_stored_query_execution_permissions(
datasette, db, stored_query, request.actor
)
else:
visible, private = await datasette.check_visibility(
request.actor,
await datasette.ensure_permission(
action="execute-sql",
resource=DatabaseResource(database=database),
actor=request.actor,
)
if not visible:
raise Forbidden("execute-sql")
# Flattened because of ?sql=&name1=value1&name2=value2 feature
params = {key: request.args.get(key) for key in request.args}
sql = None
if stored_query:
sql = stored_query.sql
if canned_query:
sql = canned_query["sql"]
elif "sql" in params:
sql = params.pop("sql")
# Extract any :named parameters
named_parameters = []
if stored_query and stored_query.parameters:
named_parameters = stored_query.parameters
if not named_parameters and sql:
if canned_query and canned_query.get("params"):
named_parameters = canned_query["params"]
if not named_parameters:
named_parameters = derive_named_parameters(sql)
named_parameter_values = {
named_parameter: params.get(named_parameter) or ""
@ -671,13 +599,13 @@ class QueryView(View):
params_for_query = params
if sql and not stored_query_write:
if not canned_query_write:
try:
if not stored_query:
if not canned_query:
# For regular queries we only allow SELECT, plus other rules
validate_sql_select(sql)
else:
# Stored queries can run magic parameters
# Canned queries can run magic parameters
params_for_query = MagicParameters(sql, params, request, datasette)
await params_for_query.execute_params()
results = await datasette.execute(
@ -713,17 +641,8 @@ class QueryView(View):
except DatasetteError:
raise
async def query_metadata():
if stored_query:
metadata = stored_query_to_dict(stored_query)
metadata.pop("source", None)
return metadata
return await datasette.get_database_metadata(database)
# Handle formats from plugins
if format_ == "csv":
if not sql:
raise DatasetteError("?sql= is required", status=400)
async def fetch_data_for_csv(request, _next=None):
results = await db.execute(sql, params, truncate=True)
@ -732,25 +651,6 @@ class QueryView(View):
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
elif format_ in datasette.renderers.keys():
data = {"ok": True, "rows": rows, "columns": columns}
extras = extra_names_from_request(request)
if extras:
query_extra_context = QueryExtraContext(
datasette=datasette,
request=request,
db=db,
database_name=database,
private=private,
rows=rows,
columns=columns,
sql=sql,
params=named_parameter_values,
query_name=stored_query.name if stored_query else None,
metadata=await query_metadata(),
extras=extras,
extra_registry=table_extra_registry,
)
data.update(await resolve_query_extras(extras, query_extra_context))
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
@ -759,7 +659,7 @@ class QueryView(View):
columns=columns,
rows=rows,
sql=sql,
query_name=stored_query.name if stored_query else None,
query_name=canned_query["name"] if canned_query else None,
database=database,
table=None,
request=request,
@ -768,7 +668,7 @@ class QueryView(View):
error=query_error,
# These will be deprecated in Datasette 1.0:
args=request.args,
data=data,
data={"ok": True, "rows": rows, "columns": columns},
)
if asyncio.iscoroutine(result):
result = await result
@ -791,33 +691,19 @@ class QueryView(View):
elif format_ == "html":
headers = {}
templates = [f"query-{to_css_class(database)}.html", "query.html"]
if stored_query:
if canned_query:
templates.insert(
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)
template = environment.select_template(templates)
alternate_url_json = datasette.absolute_url(
request,
datasette.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
datasette.urls.path(path_with_format(request=request, format="json")),
)
data = {
"ok": query_error is None,
"rows": rows,
"columns": columns,
"query": {"sql": sql, "params": params},
"query_name": stored_query.name if stored_query else None,
"database": database,
"table": None,
}
data = {}
headers.update(
{
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
@ -825,7 +711,8 @@ class QueryView(View):
)
}
)
metadata = await query_metadata()
metadata = await datasette.get_database_metadata(database)
renderers = {}
for key, (_, can_render) in datasette.renderers.items():
it_can_render = call_with_supported_arguments(
@ -843,11 +730,7 @@ class QueryView(View):
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = datasette.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
)
path_with_format(request=request, format=key)
)
allow_execute_sql = await datasette.allowed(
@ -855,14 +738,9 @@ class QueryView(View):
resource=DatabaseResource(database=database),
actor=request.actor,
)
allow_store_query = await datasette.allowed(
action="store-query",
resource=DatabaseResource(database=database),
actor=request.actor,
)
show_hide_hidden = ""
if stored_query and stored_query.hide_sql:
if canned_query and canned_query.get("hide_sql"):
if bool(params.get("_show_sql")):
show_hide_link = path_with_removed_args(request, {"_show_sql"})
show_hide_text = "hide"
@ -890,38 +768,24 @@ class QueryView(View):
# - No magic parameters, so no :_ in the SQL string
edit_sql_url = None
is_validated_sql = False
if sql:
try:
validate_sql_select(sql)
is_validated_sql = True
except InvalidSql:
pass
if allow_execute_sql and is_validated_sql and ":_" not in sql:
edit_sql_url = (
datasette.urls.database(database)
+ "/-/query"
+ "?"
+ urlencode(
{
**{
"sql": sql,
},
**named_parameter_values,
}
)
)
save_query_url = None
if (
not stored_query
and allow_execute_sql
and allow_store_query
and is_validated_sql
and ":_" not in sql
):
save_query_url = (
try:
validate_sql_select(sql)
is_validated_sql = True
except InvalidSql:
pass
if allow_execute_sql and is_validated_sql and ":_" not in sql:
edit_sql_url = (
datasette.urls.database(database)
+ "/-/queries/store?"
+ urlencode({"sql": sql})
+ "/-/query"
+ "?"
+ urlencode(
{
**{
"sql": sql,
},
**named_parameter_values,
}
)
)
async def query_actions():
@ -930,7 +794,7 @@ class QueryView(View):
datasette=datasette,
actor=request.actor,
database=database,
query_name=stored_query.name if stored_query else None,
query_name=canned_query["name"] if canned_query else None,
request=request,
sql=sql,
params=params,
@ -950,17 +814,16 @@ class QueryView(View):
"sql": sql,
"params": params,
},
stored_query=stored_query.name if stored_query else None,
canned_query=canned_query["name"] if canned_query else None,
private=private,
stored_query_write=stored_query_write,
canned_query_write=canned_query_write,
db_is_immutable=not db.is_mutable,
error=query_error,
hide_sql=hide_sql,
show_hide_link=datasette.urls.path(show_hide_link),
show_hide_text=show_hide_text,
editable=not stored_query,
editable=not canned_query,
allow_execute_sql=allow_execute_sql,
save_query_url=save_query_url,
tables=await get_tables(datasette, request, db, allowed_dict),
named_parameter_values=named_parameter_values,
edit_sql_url=edit_sql_url,
@ -976,14 +839,11 @@ class QueryView(View):
renderers=renderers,
url_csv=datasette.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs={"_size": "max"},
request=request, format="csv", extra_qs={"_size": "max"}
)
),
show_hide_hidden=markupsafe.Markup(show_hide_hidden),
metadata=metadata,
metadata=canned_query or metadata,
alternate_url_json=alternate_url_json,
select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}"
@ -992,12 +852,12 @@ class QueryView(View):
top_query=make_slot_function(
"top_query", datasette, request, database=database, sql=sql
),
top_stored_query=make_slot_function(
"top_stored_query",
top_canned_query=make_slot_function(
"top_canned_query",
datasette,
request,
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,
),
@ -1093,8 +953,9 @@ class TableCreateView(BaseView):
):
return _error(["Permission denied"], 403)
body = await request.post_body()
try:
data = await request.json()
data = json.loads(body)
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
@ -1309,6 +1170,22 @@ class TableCreateView(BaseView):
return Response.json(details, status=201)
async def _table_columns(datasette, database_name):
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(row["name"])
# Add views
db = datasette.get_database(database_name)
for view_name in await db.view_names():
table_columns[view_name] = []
return table_columns
async def display_rows(datasette, database, request, rows, columns):
display_rows = []
truncate_cells = datasette.setting("truncate_cells_html")
@ -1328,7 +1205,6 @@ async def display_rows(datasette, database, request, rows, columns):
database=database,
datasette=datasette,
request=request,
column_type=None,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:

View file

@ -1,474 +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,
SQL_PARAMETER_FORM_PREFIX,
_analysis_is_write,
_analysis_rows,
_analysis_rows_with_permissions,
_block_framing,
_coerce_execute_write_payload,
_derived_query_parameters,
_execute_write_analysis_data,
_execute_write_disabled_reason,
_inserted_row_url,
_json_or_form_payload,
_prepare_execute_write,
_table_columns,
_wants_json,
)
WRITE_TEMPLATE_LABELS = {
"insert": "Insert row",
"update": "Update rows",
"delete": "Delete rows",
}
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
def _parameter_names(columns):
seen = set()
names = {}
for column in columns:
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
base = base.strip("_") or "value"
if base[0].isdigit():
base = "p_{}".format(base)
name = base
index = 2
while name in seen:
name = "{}_{}".format(base, index)
index += 1
seen.add(name)
names[column] = name
return names
def _quote_identifier(identifier):
return '"{}"'.format(identifier.replace('"', '""'))
def _preferred_where_column(table, columns):
lower_table_id = "{}_id".format(table.lower())
return (
next((column for column in columns if column.lower() == "id"), None)
or next(
(column for column in columns if column.lower() == lower_table_id), None
)
or columns[0]
)
def _auto_incrementing_primary_key(columns):
primary_keys = [column for column in columns if column.is_pk]
if len(primary_keys) != 1:
return None
primary_key = primary_keys[0]
if primary_key.type and primary_key.type.lower() == "integer":
return primary_key.name
return None
def _insert_template_sql(table, columns):
column_names = [column.name for column in columns]
auto_pk = _auto_incrementing_primary_key(columns)
insert_columns = [column for column in column_names if column != auto_pk]
if not insert_columns:
return "insert into {}\ndefault values".format(_quote_identifier(table))
names = _parameter_names(insert_columns)
return "\n".join(
(
"insert into {} (".format(_quote_identifier(table)),
",\n".join(
" {}".format(_quote_identifier(column)) for column in insert_columns
),
")",
"values (",
",\n".join(" :{}".format(names[column]) for column in insert_columns),
")",
)
)
def _update_template_sql(table, columns):
column_names = [column.name for column in columns]
names = _parameter_names(column_names)
where_column = _preferred_where_column(table, column_names)
set_columns = [column for column in column_names if column != where_column]
if not set_columns:
return "\n".join(
(
"update {}".format(_quote_identifier(table)),
"set {} = :new_{}".format(
_quote_identifier(where_column), names[where_column]
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
return "\n".join(
(
"update {}".format(_quote_identifier(table)),
"set "
+ ",\n".join(
"{}{} = :{}".format(
" " if index else "",
_quote_identifier(column),
names[column],
)
for index, column in enumerate(set_columns)
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
def _delete_template_sql(table, columns):
column_names = [column.name for column in columns]
names = _parameter_names(column_names)
where_column = _preferred_where_column(table, column_names)
return "\n".join(
(
"delete from {}".format(_quote_identifier(table)),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
)
)
def _template_sqls_for_table(table, columns):
return {
"insert": _insert_template_sql(table, columns),
"update": _update_template_sql(table, columns),
"delete": _delete_template_sql(table, columns),
}
async def _template_sql_allowed(datasette, db, sql, actor):
params = {parameter: "" for parameter in _derived_query_parameters(sql)}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError:
return False
if not _analysis_is_write(analysis):
return False
analysis_rows = await _analysis_rows_with_permissions(datasette, analysis, actor)
return _execute_write_disabled_reason(sql, None, analysis_rows) is None
async def _write_template_tables(
datasette, db, table_columns, hidden_table_names, actor
):
write_template_tables = {}
for table in table_columns:
if table in hidden_table_names or not table_columns[table]:
continue
column_details = [
column
for column in await db.table_column_details(table)
if not column.hidden
]
if not column_details:
continue
templates = {}
for operation, sql in _template_sqls_for_table(table, column_details).items():
if await _template_sql_allowed(datasette, db, sql, actor):
templates[operation] = sql
if templates:
write_template_tables[table] = {
"templates": templates,
}
return write_template_tables
def _write_template_operations(write_template_tables):
operations = []
for operation in WRITE_TEMPLATE_OPERATIONS:
if any(
operation in table["templates"] for table in write_template_tables.values()
):
operations.append(
{
"name": operation,
"label": WRITE_TEMPLATE_LABELS[operation],
}
)
return operations
class ExecuteWriteView(BaseView):
name = "execute-write"
has_json_alternate = False
async def _render_form(
self,
request,
db,
*,
sql="",
parameter_values=None,
analysis=None,
analysis_error=None,
execution_message=None,
execution_links=None,
execution_ok=None,
execute_write_returns_rows=False,
execute_write_columns=None,
execute_write_display_rows=None,
execute_write_truncated=False,
status=200,
):
parameter_values = parameter_values or {}
execution_links = execution_links or []
execute_write_columns = execute_write_columns or []
execute_write_display_rows = execute_write_display_rows or []
parameter_names = []
analysis_rows = []
table_columns = await _table_columns(self.ds, db.name)
hidden_table_names = set(await db.hidden_table_names())
write_template_tables = await _write_template_tables(
self.ds, db, table_columns, hidden_table_names, request.actor
)
write_template_operations = _write_template_operations(write_template_tables)
if sql and analysis_error is None:
try:
parameter_names = _derived_query_parameters(sql)
if analysis is None:
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
self.ds, analysis, request.actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
allow_save_query = await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
) and await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
)
save_query_base_url = None
save_query_url = None
execute_disabled_reason = _execute_write_disabled_reason(
sql, analysis_error, analysis_rows
)
if allow_save_query:
save_query_base_url = self.ds.urls.database(db.name) + "/-/queries/store"
if not execute_disabled_reason:
save_query_url = save_query_base_url + "?" + urlencode({"sql": sql})
response = await self.render(
["execute_write.html"],
request,
{
"database": db.name,
"database_color": db.color,
"sql": sql,
"parameter_names": parameter_names,
"parameter_values": parameter_values,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"execution_message": execution_message,
"execution_links": execution_links,
"execution_ok": execution_ok,
"execute_write_returns_rows": execute_write_returns_rows,
"execute_write_columns": execute_write_columns,
"execute_write_display_rows": execute_write_display_rows,
"execute_write_truncated": execute_write_truncated,
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
"table_columns": table_columns,
"write_template_tables": write_template_tables,
"write_template_operations": write_template_operations,
"save_query_url": save_query_url,
"save_query_base_url": save_query_base_url,
},
)
response.status = status
return _block_framing(response)
async def get(self, request):
db = await self.ds.resolve_database(request)
await self.ds.ensure_permission(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
)
if not db.is_mutable:
return _block_framing(
_error(
["Cannot execute write SQL because this database is immutable."],
403,
)
)
return await self._render_form(
request,
db,
sql=request.args.get("sql") or "",
)
async def post(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 403)
)
if not db.is_mutable:
return _block_framing(_error(["Database is immutable"], 403))
data = {}
is_json = request.headers.get("content-type", "").startswith("application/json")
sql = ""
provided_params = {}
try:
data, is_json = await _json_or_form_payload(request)
sql, provided_params = _coerce_execute_write_payload(data, is_json)
parameter_names, params, analysis = await _prepare_execute_write(
self.ds, db, sql, provided_params, request.actor
)
except QueryValidationError as ex:
if _wants_json(request, is_json, data):
return _block_framing(_error([ex.message], ex.status))
if ex.flash:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
request,
db,
sql=sql or "",
parameter_values=provided_params,
analysis_error=None if ex.flash else ex.message,
execution_message=None if ex.flash else ex.message,
execution_ok=False,
status=ex.status,
)
wants_json = _wants_json(request, is_json, data)
try:
execute_write_kwargs = {"request": request}
cursor = await db.execute_write(sql, params, **execute_write_kwargs)
except sqlite3.DatabaseError as ex:
message = str(ex)
if wants_json:
return _block_framing(_error([message], 400))
return await self._render_form(
request,
db,
sql=sql,
parameter_values=params,
analysis=analysis,
execution_message=message,
execution_ok=False,
status=400,
)
if cursor.rowcount == -1:
message = "Query executed"
else:
message = "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
if wants_json:
data = {
"ok": True,
"message": message,
"rowcount": cursor.rowcount,
"rows": [],
"truncated": False,
"analysis": _analysis_rows(analysis),
}
if cursor.description is not None:
data["rows"] = [dict(row) for row in cursor.fetchall()]
data["truncated"] = cursor.truncated
return _block_framing(Response.json(data))
inserted_row_url = await _inserted_row_url(self.ds, db, analysis, cursor)
execution_links = (
[{"href": inserted_row_url, "label": "View row"}]
if inserted_row_url
else []
)
execute_write_returns_rows = cursor.description is not None
execute_write_columns = []
execute_write_display_rows = []
if execute_write_returns_rows:
execute_write_columns = [
description[0] for description in cursor.description
]
execute_write_display_rows = await display_query_rows(
self.ds,
db.name,
request,
cursor.fetchall(),
execute_write_columns,
)
return await self._render_form(
request,
db,
sql=sql,
parameter_values={name: params.get(name, "") for name in parameter_names},
analysis=analysis,
execution_message=message,
execution_links=execution_links,
execution_ok=True,
execute_write_returns_rows=execute_write_returns_rows,
execute_write_columns=execute_write_columns,
execute_write_display_rows=execute_write_display_rows,
execute_write_truncated=cursor.truncated,
)
class ExecuteWriteAnalyzeView(BaseView):
name = "execute-write-analyze"
has_json_alternate = False
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
return _block_framing(
Response.json(
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
)
)

View file

@ -1,638 +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",
}
SQL_PARAMETER_FORM_PREFIX = "_sql_param_"
class QueryValidationError(Exception):
def __init__(self, message, status=400, *, flash=False):
self.message = message
self.status = status
self.flash = flash
super().__init__(message)
def _actor_id(actor):
if isinstance(actor, dict):
return actor.get("id")
return None
def _as_bool(value):
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, int):
return bool(value)
if isinstance(value, str):
return value.lower() in {"1", "true", "t", "yes", "on"}
return bool(value)
def _as_optional_bool(value, name):
if value is None or value == "":
return None
if isinstance(value, bool):
return value
if isinstance(value, int):
return bool(value)
if isinstance(value, str):
lowered = value.lower()
if lowered in {"1", "true", "t", "yes", "on"}:
return True
if lowered in {"0", "false", "f", "no", "off"}:
return False
raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default=50):
if value in (None, ""):
return default
try:
return min(max(1, int(value)), 1000)
except ValueError as ex:
raise QueryValidationError("_size must be an integer") from ex
def _derived_query_parameters(sql):
parameters = []
seen = set()
for parameter in derive_named_parameters(sql):
if parameter.startswith("_"):
raise QueryValidationError("Magic parameters are not allowed")
if parameter not in seen:
parameters.append(parameter)
seen.add(parameter)
return parameters
def _coerce_query_parameters(value, derived):
if value is None:
return derived
if isinstance(value, str):
parameters = [
parameter.strip()
for parameter in re.split(r"[\s,]+", value)
if parameter.strip()
]
elif isinstance(value, list):
parameters = value
else:
raise QueryValidationError("parameters must be a list of strings")
if not all(isinstance(parameter, str) for parameter in parameters):
raise QueryValidationError("parameters must be a list of strings")
if any(parameter.startswith("_") for parameter in parameters):
raise QueryValidationError("Magic parameters are not allowed")
if set(parameters) != set(derived):
raise QueryValidationError("parameters must match SQL named parameters")
return parameters
def _analysis_is_write(analysis: SQLAnalysis) -> bool:
return any(operation_is_write(operation) for operation in analysis.operations)
def _block_framing(response):
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
response.headers["X-Frame-Options"] = "DENY"
return response
def _wants_json(request, is_json, data):
return (
is_json
or request.headers.get("accept") == "application/json"
or (isinstance(data, dict) and data.get("_json"))
)
def _query_create_form_error_message(message):
return {
"Query name is required": "URL is required",
"Invalid query name": "Invalid URL",
"Query name conflicts with a table or view": (
"URL conflicts with an existing table or view"
),
"Query already exists": "A query already exists at that URL",
}.get(message, message)
async def _json_or_form_payload(request):
content_type = request.headers.get("content-type", "")
if content_type.startswith("application/json"):
body = await request.post_body()
try:
return json.loads(body or b"{}"), True
except json.JSONDecodeError as e:
raise QueryValidationError("Invalid JSON: {}".format(e))
return await request.post_vars(), False
async def _check_query_name(db, name, *, existing=False):
if not name or not isinstance(name, str):
raise QueryValidationError("Query name is required")
if not _query_name_re.match(name):
raise QueryValidationError("Invalid query name")
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
raise QueryValidationError("Query name conflicts with a table or view")
async def _analyze_user_query(datasette, db, sql, *, actor):
if not sql or not isinstance(sql, str):
raise QueryValidationError("SQL is required")
derived = _derived_query_parameters(sql)
params = {parameter: "" for parameter in derived}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
is_write = _analysis_is_write(analysis)
if is_write:
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except QueryWriteRejected as ex:
raise QueryValidationError(ex.message, status=403, flash=True) from ex
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
else:
try:
validate_sql_select(sql)
except InvalidSql as ex:
raise QueryValidationError(str(ex)) from ex
return is_write, derived, analysis
def _display_operations(analysis: SQLAnalysis) -> list[Operation]:
operations = []
for operation in analysis.operations:
if isinstance(
decision_for_write_sql_operation(operation), IgnoreWriteSqlOperation
):
continue
operations.append(operation)
return operations
def _analysis_rows(analysis: SQLAnalysis) -> list[dict[str, object]]:
rows = []
for operation in _display_operations(analysis):
decision = decision_for_write_sql_operation(operation)
required_permission = (
", ".join(permission.action for permission in decision.permissions)
if isinstance(decision, RequireWriteSqlPermissions)
else ""
)
rows.append(
{
"operation": operation.operation,
"database": operation.database,
"table": operation.table or operation.target,
"required_permission": required_permission,
"source": operation.source,
}
)
return rows
async def _analysis_rows_with_permissions(
datasette, analysis: SQLAnalysis, actor
) -> list[dict[str, object]]:
rows = _analysis_rows(analysis)
is_write = _analysis_is_write(analysis)
for row, operation in zip(rows, _display_operations(analysis)):
decision = decision_for_write_sql_operation(operation)
if isinstance(decision, RequireWriteSqlPermissions):
row["allowed"] = True
for permission in decision.permissions:
if not await datasette.allowed(
action=permission.action,
resource=permission.resource,
actor=actor,
):
row["allowed"] = False
break
elif is_write:
row["allowed"] = False
else:
row["allowed"] = None
return rows
def _execute_write_disabled_reason(sql, analysis_error, analysis_rows):
if not (sql and sql.strip()):
return "Enter writable SQL before executing."
if analysis_error:
return analysis_error
if any(row.get("allowed") is False for row in analysis_rows):
return "You do not have permission for every operation listed above."
return None
def _coerce_execute_write_payload(data, is_json):
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
if is_json:
invalid_keys = set(data) - {"sql", "params"}
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
params = data.get("params") or {}
else:
params = {}
for key, value in data.items():
if key in {"sql", "csrftoken", "_json"}:
continue
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
params[key] = value
if not isinstance(params, dict):
raise QueryValidationError("params must be a dictionary")
return data.get("sql"), params
async def _prepare_execute_write(datasette, db, sql, params, actor):
if not sql or not isinstance(sql, str):
raise QueryValidationError("SQL is required")
parameter_names = _derived_query_parameters(sql)
extra_params = set(params) - set(parameter_names)
if extra_params:
raise QueryValidationError(
"Unknown parameters: {}".format(", ".join(sorted(extra_params)))
)
params = {name: params.get(name, "") for name in parameter_names}
try:
analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex:
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
if not _analysis_is_write(analysis):
raise QueryValidationError(
"Use /-/query for read-only SQL; this endpoint only executes writes"
)
try:
await datasette.ensure_query_write_permissions(
db.name, sql, actor=actor, analysis=analysis
)
except QueryWriteRejected as ex:
raise QueryValidationError(ex.message, status=403, flash=True) from ex
except Forbidden as ex:
raise QueryValidationError(str(ex), status=403) from ex
return parameter_names, params, analysis
async def _ensure_stored_query_execution_permissions(
datasette, db, query: StoredQuery, actor
):
if query.is_trusted:
return
if query.is_write:
await datasette.ensure_permission(
action="execute-write-sql",
resource=DatabaseResource(db.name),
actor=actor,
)
await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor)
else:
await datasette.ensure_permission(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=actor,
)
async def _execute_write_analysis_data(datasette, db, sql, actor):
parameter_names = []
analysis_rows = []
analysis_error = None
if sql:
try:
parameter_names = _derived_query_parameters(sql)
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
if _analysis_is_write(analysis):
analysis_rows = await _analysis_rows_with_permissions(
datasette, analysis, actor
)
else:
analysis_error = (
"Use /-/query for read-only SQL; "
"this endpoint only executes writes"
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
execute_disabled_reason = _execute_write_disabled_reason(
sql, analysis_error, analysis_rows
)
return {
"ok": analysis_error is None,
"parameters": parameter_names,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
}
async def _query_create_analysis_data(datasette, db, sql, actor):
has_sql = bool(sql and sql.strip())
parameter_names = []
analysis_rows = []
analysis_error = None
analysis: SQLAnalysis | None = None
if has_sql:
try:
parameter_names = _derived_query_parameters(sql)
params = {parameter: "" for parameter in parameter_names}
analysis = await db.analyze_sql(sql, params)
analysis_rows = await _analysis_rows_with_permissions(
datasette, analysis, actor
)
except (QueryValidationError, sqlite3.DatabaseError) as ex:
analysis_error = getattr(ex, "message", str(ex))
return {
"ok": analysis_error is None,
"parameters": parameter_names,
"analysis_error": analysis_error,
"analysis_rows": analysis_rows,
"has_sql": has_sql,
"analysis_is_write": _analysis_is_write(analysis) if analysis else False,
"save_disabled": bool(
(not has_sql)
or analysis_error
or any(row["allowed"] is False for row in analysis_rows)
),
}
async def _query_create_form_context(
datasette,
request,
db,
*,
sql="",
name="",
title="",
description="",
is_private=True,
):
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
return {
"database": db.name,
"database_color": db.color,
"sql": sql,
"name": name,
"title": title,
"description": description,
"is_private": is_private,
**analysis_data,
}
async def _query_edit_form_context(
datasette,
request,
db,
existing: StoredQuery,
*,
sql=None,
title=None,
description=None,
is_private=None,
):
sql = existing.sql if sql is None else sql
title = existing.title if title is None else title
description = existing.description if description is None else description
is_private = existing.is_private if is_private is None else is_private
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
return {
"database": db.name,
"database_color": db.color,
"name": existing.name,
"sql": sql,
"title": title or "",
"description": description or "",
"is_private": is_private,
"query_url": datasette.urls.table(db.name, existing.name),
**analysis_data,
}
async def _inserted_row_url(datasette, db, analysis, cursor):
if cursor.rowcount != 1:
return None
lastrowid = getattr(cursor, "lastrowid", None)
if lastrowid is None:
return None
direct_inserts = [
operation
for operation in analysis.operations
if operation.operation == "insert"
and operation.target_type == "table"
and not operation.internal
and operation.source is None
and operation.database == db.name
]
if len(direct_inserts) != 1:
return None
table = direct_inserts[0].table
if table is None:
return None
pks = await db.primary_keys(table)
use_rowid = not pks
select = (
"rowid"
if use_rowid
else ", ".join(escape_sqlite(primary_key) for primary_key in pks)
)
try:
result = await db.execute(
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
[lastrowid],
)
except sqlite3.DatabaseError:
return None
row = result.first()
if row is None:
return None
row_path = path_from_row_pks(row, pks, use_rowid)
return datasette.urls.row(db.name, table, row_path)
def _apply_query_data_types(data):
typed = dict(data)
for key in ("hide_sql", "is_private"):
if key in typed:
typed[key] = _as_bool(typed[key])
return typed
async def _prepare_query_create(datasette, request, db, data):
invalid_keys = set(data) - _query_create_fields
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
data = _apply_query_data_types(data)
name = data.get("name")
await _check_query_name(db, name)
if await datasette.get_query(db.name, name) is not None:
raise QueryValidationError("Query already exists")
is_write, derived, analysis = await _analyze_user_query(
datasette,
db,
data.get("sql"),
actor=request.actor,
)
if not is_write and any(data.get(field) for field in _query_write_fields):
raise QueryValidationError("Writable query fields require writable SQL")
parameters = _coerce_query_parameters(
data.get("parameters", data.get("params")),
derived,
)
return {
"name": name,
"sql": data["sql"],
"title": data.get("title"),
"description": data.get("description"),
"hide_sql": _as_bool(data.get("hide_sql")),
"fragment": data.get("fragment"),
"parameters": parameters,
"is_write": is_write,
"is_private": _as_bool(data.get("is_private", True)),
"is_trusted": False,
"source": "user",
"owner_id": _actor_id(request.actor),
"on_success_message": data.get("on_success_message"),
"on_success_redirect": data.get("on_success_redirect"),
"on_error_message": data.get("on_error_message"),
"on_error_redirect": data.get("on_error_redirect"),
"analysis": analysis,
}
async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update):
invalid_keys = set(update) - _query_update_fields
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
)
update = _apply_query_data_types(update)
sql = update.get("sql", existing.sql)
query_is_write = existing.is_write
derived = _derived_query_parameters(sql)
parameters = None
if "sql" in update:
query_is_write, derived, _ = await _analyze_user_query(
datasette,
db,
sql,
actor=request.actor,
)
if "parameters" in update or "params" in update:
parameters = _coerce_query_parameters(
update.get("parameters", update.get("params")),
derived,
)
elif "sql" in update:
parameters = derived
if not query_is_write and any(update.get(field) for field in _query_write_fields):
raise QueryValidationError("Writable query fields require writable SQL")
field_values = {
"sql": sql,
"title": update.get("title"),
"description": update.get("description"),
"hide_sql": update.get("hide_sql"),
"fragment": update.get("fragment"),
"parameters": parameters,
"is_write": query_is_write,
"is_private": update.get("is_private"),
"on_success_message": update.get("on_success_message"),
"on_success_redirect": update.get("on_success_redirect"),
"on_error_message": update.get("on_error_message"),
"on_error_redirect": update.get("on_error_redirect"),
}
update_kwargs = {}
for field_name, value in field_values.items():
if field_name in update:
update_kwargs[field_name] = value
if parameters is not None:
update_kwargs["parameters"] = parameters
if "sql" in update:
update_kwargs["is_write"] = query_is_write
return update_kwargs
async def _table_columns(datasette, database_name):
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(row["name"])
# Add views
db = datasette.get_database(database_name)
for view_name in await db.view_names():
table_columns[view_name] = []
return table_columns

View file

@ -7,7 +7,6 @@ from datasette.utils import (
await_me_maybe,
CustomRow,
make_slot_function,
path_from_row_pks,
to_css_class,
escape_sqlite,
)
@ -15,13 +14,7 @@ from datasette.plugins import pm
import json
import markupsafe
import sqlite_utils
from datasette.extras import extra_names_from_request
from .table import (
display_columns_and_rows,
_table_page_data,
row_label_from_label_column,
)
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
from .table import display_columns_and_rows, _get_extras
class RowView(DataView):
@ -54,7 +47,6 @@ class RowView(DataView):
pks = resolved.pks
async def template_data():
is_table = await db.table_exists(table)
# Reorder columns so primary keys come first
pk_set = set(pks)
pk_cols = [d for d in results.description if d[0] in pk_set]
@ -123,60 +115,7 @@ class RowView(DataView):
"<strong>{}</strong>".format(cell["value"])
)
label_column = await db.label_column_for_table(table) if is_table else None
row_path = path_from_row_pks(rows[0], pks, False)
pk_path = path_from_row_pks(rows[0], pks, False, False)
row_label = row_label_from_label_column(expanded_rows[0], label_column)
for display_row in display_rows:
display_row.pk_path = pk_path
display_row.row_path = row_path
display_row.row_label = row_label
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = "{} {}".format(pk_path, row_label)
row_action_permissions = {}
if is_table and db.is_mutable:
row_action_permissions = await self.ds.allowed_many(
actions=["update-row", "delete-row"],
resource=TableResource(database=database, table=table),
actor=request.actor,
)
row_actions = []
if row_action_permissions.get("update-row"):
attrs = {
"aria-label": "Edit row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "edit",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Edit row",
"description": "Open a dialog to edit this row.",
"attrs": attrs,
}
)
if row_action_permissions.get("delete-row"):
attrs = {
"aria-label": "Delete row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "delete",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Delete row",
"description": "Open a confirmation dialog to delete this row.",
"attrs": attrs,
}
)
for hook in pm.hook.row_actions(
datasette=self.ds,
actor=request.actor,
@ -203,16 +142,6 @@ class RowView(DataView):
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
"_table.html",
],
"row_mutation_ui": any(row_action_permissions.values()),
"table_page_data": await _table_page_data(
self.ds,
request,
db,
database,
table,
not is_table,
None,
),
"row_actions": row_actions,
"top_row": make_slot_function(
"top_row",
@ -235,27 +164,45 @@ class RowView(DataView):
"primary_key_values": pk_values,
}
extras = extra_names_from_request(request)
# Handle _extra parameter (new style)
extras = _get_extras(request)
# Also support legacy _extras parameter for backward compatibility
if "foreign_key_tables" in (request.args.get("_extras") or "").split(","):
extras.add("foreign_key_tables")
# Process extras
row_extra_context = RowExtraContext(
datasette=self.ds,
request=request,
db=db,
database_name=database,
table_name=table,
private=private,
rows=rows,
columns=columns,
pks=pks,
pk_values=pk_values,
sql=resolved.sql,
params=resolved.params,
extras=extras,
extra_registry=table_extra_registry,
foreign_key_tables=self.foreign_key_tables,
)
data.update(await resolve_row_extras(extras, row_extra_context))
if "foreign_key_tables" in extras:
data["foreign_key_tables"] = await self.foreign_key_tables(
database, table, pk_values
)
if "render_cell" in extras:
# Call render_cell plugin hook for each cell
rendered_rows = []
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
# Call render_cell plugin hook
plugin_display_value = 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,
):
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 (
data,
@ -318,27 +265,6 @@ class RowError(Exception):
self.error = error
ROW_FLASH_LABEL_MAX_LENGTH = 80
def _truncated_row_flash_label(label):
label = " ".join(str(label).split())
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
return label
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
async def _row_flash_message(db, action, resolved, row=None):
pk_label = ", ".join(resolved.pk_values)
label_column = await db.label_column_for_table(resolved.table)
label = row_label_from_label_column(row or resolved.row, label_column)
if label:
label = _truncated_row_flash_label(label)
if label and label != pk_label:
return "{} row {} ({})".format(action, pk_label, label)
return "{} row {}".format(action, pk_label)
async def _resolve_row_and_check_permission(datasette, request, permission):
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
@ -393,15 +319,6 @@ class RowDeleteView(BaseView):
)
)
if request.args.get("_redirect_to_table"):
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
self.ds.add_message(
request,
await _row_flash_message(resolved.db, "Deleted", resolved),
self.ds.INFO,
)
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
return Response.json({"ok": True}, status=200)
@ -418,8 +335,9 @@ class RowUpdateView(BaseView):
if not ok:
return resolved
body = await request.post_body()
try:
data = await request.json()
data = json.loads(body)
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
@ -434,15 +352,6 @@ class RowUpdateView(BaseView):
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")
if alter and not await self.ds.allowed(
action="alter-table",
@ -462,13 +371,11 @@ class RowUpdateView(BaseView):
return _error([str(e)], 400)
result = {"ok": True}
returned_row = None
if data.get("return"):
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
returned_row = results.dicts()[0]
result["row"] = returned_row
result["row"] = results.dicts()[0]
await self.ds.track_event(
UpdateRowEvent(
@ -479,19 +386,4 @@ class RowUpdateView(BaseView):
)
)
if request.args.get("_message"):
message_row = returned_row
if message_row is None:
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
message_row = results.first()
self.ds.add_message(
request,
await _row_flash_message(
resolved.db, "Updated", resolved, row=message_row
),
self.ds.INFO,
)
return Response.json(result, status=200)

View file

@ -1,14 +1,11 @@
import json
import logging
from datasette.jump import JumpSQL, namespace_sql_params
from datasette.plugins import pm
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import (
actor_matches_allow,
add_cors_headers,
await_me_maybe,
tilde_encode,
tilde_decode,
)
@ -67,7 +64,7 @@ class JsonDataView(BaseView):
context = {
"filename": self.filename,
"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
if self.permission == "permissions-debug":
@ -91,110 +88,6 @@ class PatternPortfolioView(View):
)
class AutocompleteDebugView(BaseView):
name = "autocomplete_debug"
has_json_alternate = False
async def _suggested_tables(self, request):
scanned = 0
reached_scan_limit = False
suggestions = []
for database_name, db in self.ds.databases.items():
if scanned >= 100 or len(suggestions) >= 5:
break
remaining = 100 - scanned
results = await db.execute(
"select name from sqlite_master where type = 'table' order by name limit ?",
[remaining],
)
for row in results.rows:
table_name = row["name"]
scanned += 1
if scanned >= 100:
reached_scan_limit = True
visible, _ = await self.ds.check_visibility(
request.actor,
action="view-table",
resource=TableResource(database=database_name, table=table_name),
)
if not visible:
if scanned >= 100:
break
continue
label_column = await db.label_column_for_table(table_name)
if label_column:
suggestions.append(
{
"database": database_name,
"table": table_name,
"label_column": label_column,
"url": self.ds.urls.path(
"-/debug/autocomplete?"
+ urllib.parse.urlencode(
{
"database": database_name,
"table": table_name,
}
)
),
}
)
if len(suggestions) >= 5:
break
if scanned >= 100:
break
return suggestions, scanned, reached_scan_limit
async def get(self, request):
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
database_name = request.args.get("database")
table_name = request.args.get("table")
context = {
"database_name": database_name,
"table_name": table_name,
}
if database_name or table_name:
if not database_name or not table_name:
context["error"] = "Both database and table are required."
elif database_name not in self.ds.databases:
context["error"] = "Database not found."
else:
db = self.ds.databases[database_name]
if not await db.table_exists(table_name):
context["error"] = "Table not found."
else:
await self.ds.ensure_permission(
action="view-table",
resource=TableResource(
database=database_name,
table=table_name,
),
actor=request.actor,
)
context.update(
{
"autocomplete_url": "{}/-/autocomplete".format(
self.ds.urls.table(database_name, table_name)
),
"label_column": await db.label_column_for_table(table_name),
}
)
else:
suggestions, scanned, reached_scan_limit = await self._suggested_tables(
request
)
context.update(
{
"suggestions": suggestions,
"scanned": scanned,
"reached_scan_limit": reached_scan_limit,
}
)
return await self.render(["debug_autocomplete.html"], request, context)
class AuthTokenView(BaseView):
name = "auth_token"
has_json_alternate = False
@ -601,13 +494,11 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
if action_obj.resource_class is None:
resource_obj = None
elif action_obj.takes_parent and action_obj.takes_child:
# Child-level resource (e.g., TableResource, QueryResource). The child
# argument is named differently per resource class (table, query, ...),
# so pass positionally - https://github.com/simonw/datasette/issues/2756
resource_obj = action_obj.resource_class(parent, child)
# Child-level resource (e.g., TableResource, QueryResource)
resource_obj = action_obj.resource_class(database=parent, table=child)
elif action_obj.takes_parent:
# Parent-level resource (e.g., DatabaseResource)
resource_obj = action_obj.resource_class(parent)
resource_obj = action_obj.resource_class(database=parent)
else:
# This shouldn't happen given validation in Action.__post_init__
return {"error": f"Invalid action configuration: {action}"}, 500
@ -922,18 +813,9 @@ class ApiExplorerView(BaseView):
"json": {
"rows": [
{
column: "<{}{}>".format(
column,
(
" (primary key)"
if column in (pks or ["rowid"])
else ""
),
)
for column in (
(["rowid"] if not pks else [])
+ await db.table_columns(table)
)
column: None
for column in await db.table_columns(table)
if column not in pks
}
]
},
@ -998,15 +880,14 @@ class ApiExplorerView(BaseView):
raise Forbidden("You do not have permission to view this instance")
def api_path(link):
return "{}#{}".format(
self.ds.urls.path("/-/api"),
return "/-/api#{}".format(
urllib.parse.urlencode(
{
key: json.dumps(value, indent=2) if key == "json" else value
for key, value in link.items()
if key in ("path", "method", "json")
}
),
)
)
return await self.render(
@ -1020,183 +901,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
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):
# Get search query parameter
q = request.args.get("q", "").strip()
terms = q.split()
pattern = "%" + "%".join(terms) + "%" if terms else "%"
fragments = await self._fragments(request)
fragments_by_database = {}
for index, fragment in enumerate(fragments):
fragments_by_database.setdefault(fragment.database, []).append(
(index, fragment)
# Get SQL for allowed resources using the permission system
permission_sql, params = await self.ds.allowed_resources_sql(
action="view-table", actor=request.actor
)
# 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}
)
rows = []
truncated = False
for database_name, indexed_fragments in fragments_by_database.items():
database_rows = await self._rows_for_database(
database_name, indexed_fragments, q, pattern
SELECT parent, child
FROM allowed_tables
WHERE child LIKE :pattern COLLATE NOCASE
ORDER BY length(child), child
"""
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:
truncated = True
database_rows = database_rows[:100]
rows.extend(database_rows)
rows.sort(key=lambda row: self._sort_key(row, q))
SELECT parent, child
FROM allowed_tables
ORDER BY parent, child
LIMIT 101
"""
all_params = params
if len(rows) > 100:
truncated = True
# Execute against internal database
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]
matches = []
for row in rows:
match = {
"name": row["label"],
"url": self._resolve_url(row["url"]),
"type": row["type"],
"description": row["description"],
matches = [
{
"name": f"{row['parent']}: {row['child']}",
"url": self.ds.urls.table(row["parent"], row["child"]),
}
if row["display_name"]:
match["display_name"] = row["display_name"]
matches.append(match)
for row in rows
]
return Response.json({"matches": matches, "truncated": truncated})

View file

@ -1,644 +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_edit_form_context,
_query_list_limit,
)
class QueryParametersView(BaseView):
name = "query-parameters"
has_json_alternate = False
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
try:
parameters = _derived_query_parameters(request.args.get("sql") or "")
except QueryValidationError as ex:
return _block_framing(_error([ex.message], ex.status))
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
set_args = set_args or {}
remove_args = set(remove_args or ())
skip = set(set_args) | remove_args | {"_next"}
pairs = [
(key, value)
for key, value in parse_qsl(query_string, keep_blank_values=True)
if key not in skip
]
for key, value in set_args.items():
if value not in (None, ""):
pairs.append((key, value))
return path + (("?" + urlencode(pairs)) if pairs else "")
class QueryListView(BaseView):
name = "query-list"
async def database_name(self, request):
return (await self.ds.resolve_database(request)).name
def query_list_path(self, database):
return self.ds.urls.database(database) + "/-/queries"
async def get(self, request):
database = await self.database_name(request)
format_ = request.url_vars.get("format") or "html"
try:
limit = _query_list_limit(
request.args.get("_size"),
default=20 if format_ == "html" else 50,
)
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
except QueryValidationError as ex:
return _error([ex.message], ex.status)
page = await self.ds.list_queries(
database,
actor=request.actor,
limit=limit,
cursor=request.args.get("_next"),
q=request.args.get("q") or None,
is_write=is_write,
is_private=is_private,
source=request.args.get("source") or None,
owner_id=request.args.get("owner_id") or None,
include_private=True,
)
query_list_path = self.query_list_path(database)
next_url = None
if page.next:
pairs = [
(key, value)
for key, value in parse_qsl(
request.query_string, keep_blank_values=True
)
if key != "_next"
]
pairs.append(("_next", page.next))
next_url = "{}?{}".format(
query_list_path,
urlencode(pairs),
)
current_filters = {
"actor": request.actor,
"q": request.args.get("q") or None,
"is_write": is_write,
"is_private": is_private,
"source": request.args.get("source") or None,
"owner_id": request.args.get("owner_id") or None,
}
async def facet_count(field, value):
if current_filters[field] is not None and current_filters[field] != value:
return 0
filters = dict(current_filters)
filters[field] = value
return await self.ds.count_queries(database, **filters)
def facet_href(field, value):
if current_filters[field] == value:
return _query_list_url(
query_list_path,
request.query_string,
remove_args=[field],
)
if current_filters[field] is not None:
return None
return _query_list_url(
query_list_path,
request.query_string,
set_args={field: str(int(value))},
)
async def facet_item(label, field, value):
count = await facet_count(field, value)
active = current_filters[field] == value
if not active and not count:
return None
return {
"label": label,
"count": count,
"href": facet_href(field, value) if active or count else None,
"active": active,
}
async def facet_items(items):
return [
item
for item in [
await facet_item(label, field, value)
for label, field, value in items
]
if item is not None
]
facets = [
{
"title": "Mode",
"items": await facet_items(
[
("Read-only", "is_write", False),
("Writable", "is_write", True),
]
),
},
{
"title": "Visibility",
"items": await facet_items(
[
("Not private", "is_private", False),
("Private", "is_private", True),
]
),
},
]
data = {
"ok": True,
"database": database,
"database_color": (
self.ds.get_database(database).color if database is not None else None
),
"queries": page.queries,
"next": page.next,
"next_url": next_url,
"has_more": page.has_more,
"limit": page.limit,
"show_private_note": any(query.is_private for query in page.queries),
"show_trusted_note": any(query.is_trusted for query in page.queries),
"query_list_path": query_list_path,
"show_database": database is None,
"facets": facets,
"filters": {
"q": request.args.get("q") or "",
"is_write": request.args.get("is_write") or "",
"is_private": request.args.get("is_private") or "",
"source": request.args.get("source") or "",
"owner_id": request.args.get("owner_id") or "",
},
}
if format_ == "json":
return Response.json(
{
**data,
"queries": [stored_query_to_dict(query) for query in page.queries],
}
)
return await self.render(
["query_list.html"],
request,
data,
)
class GlobalQueryListView(QueryListView):
name = "global-query-list"
async def database_name(self, request):
return None
def query_list_path(self, database):
return self.ds.urls.path("/-/queries")
class QueryCreateView(BaseView):
name = "query-create"
has_json_alternate = False
async def _render_form(
self,
request,
db,
*,
sql="",
name="",
title="",
description="",
is_private=True,
status=200,
):
response = await self.render(
["query_create.html"],
request,
await _query_create_form_context(
self.ds,
request,
db,
sql=sql,
name=name,
title=title,
description=description,
is_private=is_private,
),
)
response.status = status
return response
async def get(self, request):
db = await self.ds.resolve_database(request)
await self.ds.ensure_permission(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
)
await self.ds.ensure_permission(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
)
return await self._render_form(request, db, sql=request.args.get("sql") or "")
class QueryCreateAnalyzeView(BaseView):
name = "query-create-analyze"
has_json_alternate = False
async def get(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need store-query"], 403))
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
return _block_framing(
Response.json(
await _query_create_analysis_data(self.ds, db, sql, request.actor)
)
)
class QueryStoreView(QueryCreateView):
name = "query-store"
async def _error_response(self, request, db, query_data, message, status):
message = _query_create_form_error_message(message)
self.ds.add_message(request, message, self.ds.ERROR)
return await self._render_form(
request,
db,
sql=query_data.get("sql") or "",
name=query_data.get("name") or "",
title=query_data.get("title") or "",
description=query_data.get("description") or "",
is_private=_as_bool(query_data.get("is_private", True)),
status=status,
)
async def post(self, request):
db = await self.ds.resolve_database(request)
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _error(["Permission denied: need execute-sql"], 403)
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _error(["Permission denied: need store-query"], 403)
is_json = False
query_data = {}
try:
data, is_json = await _json_or_form_payload(request)
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
query_data = data.get("query") if is_json else data
if not isinstance(query_data, dict):
raise QueryValidationError("JSON must contain a query dictionary")
prepared = await _prepare_query_create(self.ds, request, db, query_data)
except QueryValidationError as ex:
if not is_json and isinstance(query_data, dict):
return await self._error_response(
request, db, query_data, ex.message, ex.status
)
return _error([ex.message], ex.status)
prepared.pop("analysis")
name = prepared.pop("name")
try:
await self.ds.add_query(db.name, name, replace=False, **prepared)
except sqlite3.IntegrityError as ex:
if not is_json and isinstance(query_data, dict):
return await self._error_response(request, db, query_data, str(ex), 400)
return _error([str(ex)], 400)
query = await self.ds.get_query(db.name, name)
assert query is not None
if is_json:
return Response.json(
{"ok": True, "query": stored_query_to_dict(query)}, status=201
)
self.ds.add_message(request, "Query saved", self.ds.INFO)
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
class QueryDefinitionView(BaseView):
name = "query-definition"
async def get(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name)
if query is None:
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="view-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
class QueryUpdateView(BaseView):
name = "query-update"
async def post(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return _error(["Trusted queries cannot be updated using the API"], 403)
try:
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
raise QueryValidationError("JSON must be a dictionary")
invalid_keys = set(data) - {"update", "return"}
if invalid_keys:
raise QueryValidationError(
"Invalid keys: {}".format(", ".join(invalid_keys))
)
update = data.get("update")
if not isinstance(update, dict):
raise QueryValidationError("JSON must contain an update dictionary")
if "sql" in update and not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
raise QueryValidationError(
"Permission denied: need execute-sql", status=403
)
update_kwargs = await _prepare_query_update(
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
return _error([ex.message], ex.status)
await self.ds.update_query(db.name, query_name, **update_kwargs)
if data.get("return"):
query = await self.ds.get_query(db.name, query_name)
assert query is not None
return Response.json(
{
"ok": True,
"query": stored_query_to_dict(query),
}
)
return Response.json({"ok": True})
class QueryEditView(BaseView):
name = "query-edit"
has_json_alternate = False
async def _load(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
return db, query_name, existing
async def _render_form(
self,
request,
db,
existing,
*,
sql=None,
title=None,
description=None,
is_private=None,
status=200,
):
response = await self.render(
["query_edit.html"],
request,
await _query_edit_form_context(
self.ds,
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
),
)
response.status = status
return response
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return _error(["Trusted queries cannot be edited"], 403)
return await self._render_form(request, db, existing)
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return _error(["Trusted queries cannot be edited"], 403)
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
return _error(["Invalid form submission"], 400)
sql = data.get("sql")
sql = existing.sql if sql is None else sql.strip()
title = data.get("title") or ""
description = data.get("description") or ""
is_private = _as_bool(data.get("is_private"))
update = {
"title": title,
"description": description,
"is_private": is_private,
}
if sql != existing.sql:
if not await self.ds.allowed(
action="execute-sql",
resource=DatabaseResource(db.name),
actor=request.actor,
):
self.ds.add_message(
request,
"Permission denied: need execute-sql to change the SQL",
self.ds.ERROR,
)
return await self._render_form(
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
status=403,
)
update["sql"] = sql
try:
update_kwargs = await _prepare_query_update(
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
request,
db,
existing,
sql=sql,
title=title,
description=description,
is_private=is_private,
status=ex.status,
)
await self.ds.update_query(db.name, query_name, **update_kwargs)
self.ds.add_message(request, "Query updated", self.ds.INFO)
return Response.redirect(
self.ds.urls.path(self.ds.urls.table(db.name, query_name))
)
class QueryDeleteView(BaseView):
name = "query-delete"
has_json_alternate = False
async def _load(self, request):
db = await self.ds.resolve_database(request)
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
return db, query_name, existing
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
return await self.render(
["query_delete.html"],
request,
{
"database": db.name,
"database_color": db.color,
"query": stored_query_to_dict(existing),
"query_url": self.ds.urls.table(db.name, query_name),
},
)
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need delete-query"], 403)
data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name)
if is_json:
return Response.json({"ok": True})
self.ds.add_message(
request,
"Query “{}” deleted".format(existing.title or query_name),
self.ds.INFO,
)
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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:
* 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``)
* 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
----------------------------
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
@ -468,7 +468,7 @@ You can control the following:
* Access to the entire Datasette instance
* Access to specific databases
* Access to specific tables and views
* Access to specific :ref:`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.
@ -641,12 +641,12 @@ This works for SQL views as well - you can list their names in the ``"tables"``
.. _authentication_permissions_query:
Access to specific 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
config_example(cog, """
@ -886,8 +886,6 @@ To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs``
}
.. [[[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:
.. [[[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.
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
@ -1285,46 +1283,12 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
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)``
``database`` is the name of the database (string)
``query`` is the name of the query (string)
.. _actions_store_query:
store-query
-----------
Actor is allowed to create stored queries against a database.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_update_query:
update-query
------------
Actor is allowed to update a stored query.
``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string)
``query`` is the name of the query (string)
.. _actions_delete_query:
delete-query
------------
Actor is allowed to delete a stored query.
``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string)
``query`` is the name of the query (string)
``query`` is the name of the canned query (string)
.. _actions_insert_row:
@ -1379,18 +1343,6 @@ alter-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)``
``database`` is the name of the database (string)
@ -1413,23 +1365,13 @@ Actor is allowed to drop a database table.
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)``
``database`` is the name of the database (string)
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`.
.. _actions_execute_write_sql:
execute-write-sql
-----------------
Actor is allowed to run arbitrary writable SQL queries against a specific database using the :ref:`write SQL queries page <pages_execute_write>`, subject to table-level write permissions such as ``insert-row``, ``update-row`` and ``delete-row``. SQL functions are allowed and are not separately restricted by Datasette permissions.
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
.. _actions_permissions_debug:
permissions-debug
@ -1442,4 +1384,4 @@ Actor is allowed to view the ``/-/permissions`` debug tools.
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.

View file

@ -3,219 +3,6 @@
=========
Changelog
=========
.. _unreleased:
Unreleased
----------
The big feature in this alpha is tools to **insert, edit and delete** rows within the Datasette interface. These features are available on table pages, and edit and delete are also available as action items on the row page.
The edit interface takes :ref:`custom column types <table_configuration_column_types>` into account. Plugins that define their own column types can use JavaScript to customize how those column types are presented in the edit interface.
- ``datasette.allowed_many()`` method for :ref:`resolving multiple permission checks at once <datasette_allowed_many>`. (:pr:`2775`)
- Permission checks are now cached on a per-request basis, speeding up table pages with multiple plugins that check permissions in order to populate the :ref:`table actions menu <plugin_hook_table_actions>`.
- Fixed a warning about ``gen.throw(*sys.exc_info())``. (:issue:`2776`)
- New default custom column type ``textarea`` for multi-line text content. This is rendered as a ``<textarea>`` input in the edit UI.
- The ``json`` column type now implements client-side validation in the edit UI.
- The :ref:`makeColumnField() <javascript_plugins_makeColumnField>` JavaScript plugin hook allows plugins to define custom fields in the edit interface for their custom column types.
- New UI for inserting, editing, and deleting rows within Datasette. (:issue:`2780`)
- New ``/<database>/<table>/-/autocomplete?q=term`` :ref:`autocomplete JSON API <TableAutocompleteView>` for rapid autocomplete search against the contents of a table. This is used by the edit interface to select related rows for foreign keys. You can try it out on the ``/-/debug/autocomplete`` debug page.
- New ``/<database>/<table>/-/fragment`` :ref:`HTML fragment endpoint <TableFragmentView>` for returning the HTML used to display a specific row.
- ``await request.json()`` utility method for consuming the request body as JSON. (:issue:`2767`)
- Database, table, query and row action menus can now be modified by plugins to :ref:`display buttons in addition to links <plugin_actions>`. (:issue:`2782`)
.. _v1_0_a33:
1.0a33 (2026-06-11)
-------------------
Stored queries can now be edited and deleted through the web interface, and the JSON API ``?_extra=`` mechanism has been extended to cover row and query pages in addition to tables. This release also fixes two security issues: an identifier-quoting bug involving table and column names that contain ``]``, and an open redirect.
Editing and deleting stored queries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The stored query page gained a "Query actions" menu with **Edit this query** and **Delete this query** links for actors with the necessary permissions. The owner of a query can always edit or delete it; for queries that are not private, any actor with the :ref:`update-query <actions_update_query>` or :ref:`delete-query <actions_delete_query>` permission can do so too. Private queries remain editable and deletable only by their owner. See :ref:`stored_queries` for details. (:issue:`2735`)
``?_extra=`` support for row and query pages
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Row and query JSON pages now support the same ``?_extra=`` mechanism as table pages. Row pages can request extras such as ``foreign_key_tables``, ``query``, ``metadata`` and ``database_color``; arbitrary SQL and stored query pages can request extras such as ``columns``, ``query``, ``metadata`` and ``private``. The implementation was refactored into a registry of extra classes shared by all three page types.
New generated reference documentation describes every ``?_extra=`` parameter available on table, row and query JSON pages, with example output captured from a live Datasette instance at documentation build time. See :ref:`json_api_extra` for the full list.
You can explore the new extras using this `Datasette extras API explorer tool <https://tools.simonwillison.net/datasette-extras-explorer>`__.
Other improvements and fixes to the extras mechanism:
- Extras that exist to serve the HTML interface (``filters``, ``actions``, ``display_rows``) are no longer advertised or reachable through the JSON API, where requesting them previously returned a 500 serialization error.
- The pre-1.0 ``?_extras=`` (plural) parameter on row pages has been removed - use ``?_extra=foreign_key_tables`` instead.
Security fixes
~~~~~~~~~~~~~~
- Fixed an identifier-quoting bug in ``datasette.utils.escape_sqlite()``. Datasette uses this helper when constructing SQL around table and column names; identifiers containing ``]`` could break out of SQLite bracket quoting and alter the generated SQL, for example by adding a ``UNION SELECT``. Identifiers containing ``]`` are now quoted using double quotes instead. (:issue:`2677`)
- Fixed an open redirect vulnerability. Requesting a path such as ``/\example.com/`` produced a redirect with a ``Location: /\example.com`` header - browsers normalize backslashes to forward slashes, turning that into the protocol-relative URL ``//example.com`` and redirecting the user off-site. Any run of leading slashes and backslashes in a redirect path is now collapsed to a single slash. (:issue:`2680`)
Bug fixes
~~~~~~~~~
- ``can_render()`` callbacks registered by the :ref:`register_output_renderer() <plugin_register_output_renderer>` plugin hook now receive the result ``rows`` and ``columns`` for stored queries. Previously renderers that inspect the available columns - such as `datasette-atom <https://github.com/simonw/datasette-atom>`__ and `datasette-ics <https://github.com/simonw/datasette-ics>`__ - never appeared as export options on stored query pages. (:issue:`2711`)
- Fixed a 500 error from the :ref:`/-/check <PermissionCheckView>` permission debugging endpoint when checking query actions such as ``view-query``, ``update-query`` and ``delete-query``. (:issue:`2756`)
- Write queries that use a named parameter called ``:sql`` no longer fail with an error. (:issue:`2761`)
- :ref:`db.execute_isolated_fn() <database_execute_isolated_fn>` now works against immutable databases, using a read-only connection that bypasses the write thread. It previously always attempted to open a writable connection, which would fail - breaking features built on top of it, such as the SQL analysis step used when storing a query. An exception raised while opening the connection for an isolated function no longer crashes the write thread. (:issue:`2768`)
- Facet counts are now displayed on the same line as the facet value instead of wrapping onto a second line. (:issue:`2754`)
- Datasette's pytest plugin no longer imports the rest of Datasette at pytest startup time. This means plugin test suites using ``pytest-cov`` now correctly record coverage of code that runs when ``datasette`` modules are first imported.
.. _v1_0_a32:
1.0a32 (2026-05-31)
-------------------
SQLite INSERT ... RETURNING clauses are now supported by ``/db/-/execute-write``, plus several fixes relating to the :ref:`base_url setting <setting_base_url>`.
- ``INSERT``/``UPDATE``/``DELETE`` statements that use SQLite's ``RETURNING`` clause now work correctly in the new ``/db/-/execute-write`` interface. Datasette fetches returned rows before committing the write transaction, displays them in the HTML UI and includes them in the ``"rows"`` key for the JSON API response. (:issue:`2762`, :pr:`2763`)
- ``Database.execute_write()`` now returns an ``ExecuteWriteResult`` object instead of the raw ``sqlite3.Cursor`` returned by ``conn.execute()``. The new object exposes ``.rowcount``, ``.lastrowid``, ``.description``, ``.truncated`` and ``.fetchall()``, and adds ``return_all=`` and ``returning_limit=`` options for controlling how rows from ``RETURNING`` statements are buffered. (:pr:`2763`)
- Fixed the ``/-/jump`` navigation search endpoint when Datasette is served with a configured ``base_url``. (:issue:`2757`)
- Fixed JSON and CSV export links, plus ``Link:`` alternate headers, on table, row and query pages when ``base_url`` is configured. These could previously be prefixed twice. (:issue:`2759`)
- Fixed several other ``base_url`` handling bugs, including the API explorer form actions and share links, the ``/-/patterns`` development page, permanent redirects such as ``/-`` to ``/-/`` and database query redirects from ``/<database>?sql=...`` to ``/<database>/-/query?sql=...``.
.. _v1_0_a31:
1.0a31 (2026-05-28)
-------------------
Datasette now offers users with the necessary permissions the ability to both **execute write queries** against their database and to **save stored queries** (renamed from "canned queries") both privately and for use by other members of their Datasette instance.
The ability to write is controlled by the new ``execute-write-sql`` permission, but the user also needs the relevant ``insert-row``/``update-row``/``delete-row``/``create-table``/etc permissions for the query they are trying to execute.
Write SQL UI
~~~~~~~~~~~~
- New "Write to this database" interface at ``/<database>/-/execute-write`` for running arbitrary writable SQL against mutable databases. The form extracts named parameters, analyzes the SQL, shows the table operations that will be attempted, includes starter templates for ``INSERT``, ``UPDATE`` and ``DELETE`` statements and links to a newly inserted row when a single-row insert succeeds. This is also available as a :ref:`JSON API <ExecuteWriteView>`. (:issue:`2742`)
- Added the new :ref:`execute-write-sql <actions_execute_write_sql>` permission for running arbitrary writable SQL. Execution is also gated by table-level permissions such as :ref:`insert-row <actions_insert_row>`, :ref:`update-row <actions_update_row>` and :ref:`delete-row <actions_delete_row>`, and writes to attached databases are rejected. (:issue:`2742`)
- The write SQL analyzer now uses a deny-by-default model for unsupported operations. Reads from source tables require :ref:`view-table <actions_view_table>` permission, schema changes require :ref:`create-table <actions_create_table>`, :ref:`alter-table <actions_alter_table>` or :ref:`drop-table <actions_drop_table>` as appropriate, and row mutation statements require the full ``insert-row``, ``update-row`` and ``delete-row`` permission set. SQL functions are allowed and are not separately permission-gated. (:issue:`2748`)
- User-supplied write SQL rejects both ``VACUUM`` operations and writes to SQLite virtual or shadow tables. These restrictions also apply to untrusted stored write queries; trusted queries in ``datasette.yml`` skip these filters. (:issue:`2748`)
Stored queries
~~~~~~~~~~~~~~
- The previous "canned queries" feature has been renamed and expanded into :ref:`stored queries <stored_queries>`. Queries configured in ``datasette.yaml`` are now loaded into a new ``queries`` table in Datasette's :ref:`internal database <internals_internal_schema>`, alongside user-created stored queries. (:issue:`2735`)
- New stored query management API methods available to plugins: ``datasette.add_query()``, ``datasette.update_query()``, ``datasette.remove_query()``, ``datasette.get_query()``, ``datasette.list_queries()`` and ``datasette.count_queries()``. These replace the removed ``datasette.get_canned_query()`` and ``datasette.get_canned_queries()`` methods. (:issue:`2735`)
- Users with :ref:`store-query <actions_store_query>` and :ref:`execute-sql <actions_execute_sql>` permission can create stored queries from the SQL query page or the new ``GET /<database>/-/queries/store`` form. (:issue:`2735`)
- The database page now shows a count and preview of stored queries, capped at five, and links to new paginated query lists at ``/-/queries`` and ``/<database>/-/queries``. Those pages support search. (:issue:`2735`)
- Stored queries created by users default to private and untrusted. Private stored queries can only be viewed, updated or deleted by their owner, even if another actor has broad ``view-query``, ``update-query`` or ``delete-query`` permission. Untrusted stored queries execute using the permissions of the actor running them. See :ref:`stored_queries` and :ref:`trusted_stored_queries` for details. (:issue:`2735`)
- Configured queries from ``datasette.yaml`` are trusted by default, so they can execute with ``view-query`` permission alone. They can opt out of that behavior using ``is_trusted: false`` but cannot be made private; private queries are only available for user-created stored queries. (:issue:`2735`)
- New ``store-query``, ``update-query`` and ``delete-query`` permissions, plus updated semantics for :ref:`view-query <actions_view_query>`. Trusted stored queries can still execute with ``view-query`` alone; untrusted read queries also require :ref:`execute-sql <actions_execute_sql>` and untrusted writable queries require :ref:`execute-write-sql <actions_execute_write_sql>` plus the relevant table-level write permissions. (:issue:`2735`)
Plugin API changes
~~~~~~~~~~~~~~~~~~
- The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() <plugin_hook_top_stored_query>`. (:issue:`2747`)
- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new :ref:`stored query management methods <datasette_stored_queries>` together with :ref:`startup() <plugin_hook_startup>` to register queries. (:issue:`2735`)
Bug fixes
~~~~~~~~~
- Fixed a bug where visiting ``/<database>/-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`)
- The ``datasette inspect`` command now correctly records row counts for tables with more than 10,000 rows. (:issue:`2712`)
.. _v1_0_a30:
1.0a30 (2026-05-24)
-------------------
The "Jump to" menu, activated by hitting ``/`` or through the application menu, can now be extended by plugins.
- New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`)
- The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``.
- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL. ``JumpSQL`` queries run against Datasette's internal database by default, or can target another database using the optional ``database=`` argument. (:issue:`2731`)
- ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog.
- New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query.
- Debug menu links now appear in the jump-to menu instead of the top-right app menu, with descriptions for each debug item.
- Dropped Janus as a dependency, previously used to manage the write queue. This should not have any impact on plugin developers or end-users. (:issue:`1752`)
- Fixed a bug where stale tables and other related resources were not removed from ``catalog_*`` tables when a database was removed. (:issue:`2723`)
- New documented :ref:`datasette.fixtures.populate_fixture_database(conn) <datasette_fixtures_populate_fixture_database>` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites.
- Keyboard accessibility and ARIA roles for actions menus, thanks `pintaste <https://github.com/pintaste>`__. (:pr:`2727`)
.. _v1_0_a29:
1.0a29 (2026-05-12)
-------------------
- New ``TokenRestrictions.abbreviated(datasette)`` :ref:`utility method <TokenRestrictions>` for creating ``"_r"`` dictionaries. (:issue:`2695`)
- Table headers and column options are now visible even if a table contains zero rows. (:issue:`2701`)
- Fixed bug with display of column actions dialog on Mobile Safari. (:issue:`2708`)
- Fixed bug where tests could crash with a segfault due to a race condition between ``Datasette.close()`` and ``Datasette.close()``. (:issue:`2709`)
.. _v1_0_a28:
1.0a28 (2026-04-16)
-------------------
- Fixed a compatibility bug introduced in 1.0a27 where ``execute_write_fn()`` callbacks with a parameter name other than ``conn`` were seeing errors. (:issue:`2691`)
- The :ref:`database.close() <database_close>` method now also shuts down the write connection for that database.
- New :ref:`datasette.close() <datasette_close>` method for closing down all databases and resources associated with a Datasette instance. This is called automatically when the server shuts down. (:pr:`2693`)
- Datasette now includes a pytest plugin which automatically calls ``datasette.close()`` on temporary instances created in function-scoped fixtures and during tests. See :ref:`testing_plugins_autoclose` for details. This helps avoid running out of file descriptors in plugin test suites that were written before the ``Database(is_temp_disk=True)`` feature introduced in Datasette 1.0a27. (:issue:`2692`)
.. _v1_0_a27:
1.0a27 (2026-04-15)
-------------------
CSRF protection no longer uses CSRF tokens
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Datasette's token-based CSRF protection has been replaced with a mechanism based on the ``Sec-Fetch-Site`` and ``Origin`` request headers, which are `supported by all modern browsers <https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site>`__. See `this article by Filippo Valsorda <https://words.filippo.io/csrf/>`__ for more details of this approach. This removes the need for CSRF tokens in forms and AJAX requests. (:pr:`2689`)
``RenameTableEvent`` when a table is renamed
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Renaming a table within Datasette will now fire a new :class:`~datasette.events.RenameTableEvent`, which plugins can use to react by updating ACL records or re-assigning comments or other associated records to the new table name. (:issue:`2681`)
This event will not be fired if the table is renamed by SQL running in some other process.
The ``datasette.track_event()`` method can now be called from within a write operation (using :ref:`database.execute_write() <database_execute_write>` and related methods) and the event will be fired after the write transaction has successfully committed. (:pr:`2682`)
Other changes
~~~~~~~~~~~~~
- New :ref:`actor= parameter <internals_datasette_client_actor>` for ``datasette.client`` methods, allowing internal requests to be made as a specific actor. This is particularly useful for writing automated tests. (:pr:`2688`)
- New ``Database(is_temp_disk=True)`` option, used internally for the internal database. This helps resolve intermittent database locked errors caused by the internal database being in-memory as opposed to on-disk. (:issue:`2683`) (:pr:`2684`)
- The ``/<database>/<table>/-/upsert`` API (:ref:`docs <TableUpsertView>`) now rejects rows with ``null`` primary key values. (:issue:`1936`)
- Improved example in the API explorer for the ``/-/upsert`` endpoint (:ref:`docs <TableUpsertView>`). (:issue:`1936`)
- The ``/<database>.json`` endpoint now includes an ``"ok": true`` key, for consistency with other JSON API responses.
- :ref:`call_with_supported_arguments() <internals_utils_call_with_supported_arguments>` is now documented as a supported public API. (:pr:`2678`)
.. _v1_0_a26:
1.0a26 (2026-03-18)
-------------------
New ``column_types`` system
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Table columns can now have custom column types assigned to them, using the new ``column_types`` table configuration option or at runtime using a new UI and ``POST /<database>/<table>/-/set-column-type`` JSON API.
Built-in column types include ``url``, ``email``, and ``json``, and plugins can register additional types using the new :ref:`register_column_types() <plugin_register_column_types>` plugin hook. (:issue:`2664`, :issue:`2671`)
Column types can customize HTML rendering, validate values written through the insert, update, and upsert APIs, and transform values returned by the JSON API. They can optionally restrict themselves to specific SQLite column types using ``sqlite_types``. This feature also introduces a new :ref:`set-column-type <actions_set_column_type>` permission for assigning column types to a table. (:issue:`2672`)
The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook now receives a ``column_type`` argument containing the assigned type instance, and a column type's own ``render_cell()`` method takes priority over the plugin hook chain.
The `datasette-files <https://github.com/datasette/datasette-files>`__ plugin will be the first to use this new feature.
UI for selecting columns and their order
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Table and view pages now include a dialog for selecting and re-ordering visible columns. (:issue:`2661`)
Other changes
~~~~~~~~~~~~~
- Fixed ``allowed_resources("view-query", actor)`` so actor-specific canned queries are returned correctly. Any plugin that defines a ``resources_sql()`` method on a ``Resource`` subclass needs to update to the new signature, see :ref:`the resources_sql() method<plugin_resources_sql>` documentation for details.
- Column actions can now be accessed in mobile view via a new "Column actions" button. Previously they were not available on mobile because table headers are not displayed there. (:issue:`2669`, :issue:`2670`)
- Row pages now render foreign key values as links to the referenced row. (:issue:`1592`)
- The ``startup()`` plugin hook now fires after metadata and internal schema tables have been populated, so plugins can reliably inspect that state during startup. (:issue:`2666`)
.. _v1_0_a25:
@ -225,7 +12,7 @@ Other changes
``write_wrapper()`` plugin hook for intercepting write operations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A new :ref:`write_wrapper() <plugin_hook_write_wrapper>` plugin hook allows plugins to intercept and wrap database write operations. (:pr:`2636`)
A new :ref:`write_wrapper() <plugin_hook_write_wrapper>` plugin hook allows plugins to intercept and wrap database write operations. (`#2636 <https://github.com/simonw/datasette/pull/2636>`__)
Plugins implement the hook as a generator-based context manager:
@ -243,20 +30,20 @@ Plugins implement the hook as a generator-based context manager:
``register_token_handler()`` plugin hook for custom API token backends
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A new :ref:`register_token_handler() <plugin_hook_register_token_handler>` plugin hook allows plugins to provide custom token backends for API authentication. (:pr:`2650`)
A new :ref:`register_token_handler() <plugin_hook_register_token_handler>` plugin hook allows plugins to provide custom token backends for API authentication. (`#2650 <https://github.com/simonw/datasette/pull/2650>`__)
This includes a **backwards incompatible change**: the ``datasette.create_token()`` internal method is now an ``async`` method. Consult the :ref:`upgrade guide <upgrade_guide_v1_a25>` for details on how to update your code.
``render_cell()`` now receives a ``pks`` parameter
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook now receives a ``pks`` parameter containing the list of primary key column names for the table being rendered. This avoids plugins needing to make redundant async calls to look up primary keys. (:pr:`2641`)
The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook now receives a ``pks`` parameter containing the list of primary key column names for the table being rendered. This avoids plugins needing to make redundant async calls to look up primary keys. (`#2641 <https://github.com/simonw/datasette/pull/2641>`__)
Other changes
~~~~~~~~~~~~~
- Facets defined in metadata now preserve their configured order, instead of being sorted by result count. Request-based facets added via the ``_facet`` parameter are still sorted by result count and appear after metadata-defined facets. (:issue:`2647`)
- Fixed ``--reload`` incorrectly interpreting the ``serve`` command as a file argument. Thanks, `Daniel Bates <https://github.com/danielalanbates>`__. (:pr:`2646`)
- Fixed ``--reload`` incorrectly interpreting the ``serve`` command as a file argument. Thanks, `Daniel Bates <https://github.com/danielalanbates>`__. (`#2646 <https://github.com/simonw/datasette/pull/2646>`__)
.. _v1_0_a24:
@ -266,7 +53,7 @@ Other changes
``request.form()`` method for POST data and file uploads
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Datasette now includes a ``request.form()`` method for parsing form submissions, including handling file uploads. (:pr:`2626`)
Datasette now includes a ``request.form()`` method for parsing form submissions, including handling file uploads. (`#2626 <https://github.com/simonw/datasette/pull/2626>`__)
This supports both ``application/x-www-form-urlencoded`` and ``multipart/form-data`` content types, and uses a new streaming multipart parser that processes uploads without buffering entire request bodies in memory.
@ -417,7 +204,7 @@ Other changes
- Fixed bug where ``link:`` HTTP headers used invalid syntax. (:issue:`2470`)
- No longer tested against Python 3.8. Now tests against Python 3.13.
- FTS tables are now hidden by default if they correspond to a content table. (:issue:`2477`)
- Fixed bug with foreign key links to rows in databases with filenames containing a special character. Thanks, `Jack Stratton <https://github.com/phroa>`__. (:pr:`2476`)
- Fixed bug with foreign key links to rows in databases with filenames containing a special character. Thanks, `Jack Stratton <https://github.com/phroa>`__. (`#2476 <https://github.com/simonw/datasette/pull/2476>`__)
.. _v1_0_a17:
@ -460,7 +247,7 @@ Other changes
This release focuses on performance, in particular against large tables, and introduces some minor breaking changes for CSS styling in Datasette plugins.
- Removed the unit conversions feature and its dependency, Pint. This means Datasette is now compatible with the upcoming Python 3.13. (:issue:`2400`, :issue:`2320`)
- The ``datasette --pdb`` option now uses the `ipdb <https://github.com/gotcha/ipdb>`__ debugger if it is installed. You can install it using ``datasette install ipdb``. Thanks, `Tiago Ilieve <https://github.com/myhro>`__. (:pr:`2342`)
- The ``datasette --pdb`` option now uses the `ipdb <https://github.com/gotcha/ipdb>`__ debugger if it is installed. You can install it using ``datasette install ipdb``. Thanks, `Tiago Ilieve <https://github.com/myhro>`__. (`#2342 <https://github.com/simonw/datasette/pull/2342>`__)
- Fixed a confusing error that occurred if ``metadata.json`` contained nested objects. (:issue:`2403`)
- Fixed a bug with ``?_trace=1`` where it returned a blank page if the response was larger than 256KB. (:issue:`2404`)
- Tracing mechanism now also displays SQL queries that returned errors or ran out of time. `datasette-pretty-traces 0.5 <https://github.com/simonw/datasette-pretty-traces/releases/tag/0.5>`__ includes support for displaying this new type of trace. (:issue:`2405`)
@ -490,7 +277,7 @@ This release focuses on performance, in particular against large tables, and int
- Failed CSRF checks now display a more user-friendly error page. (:issue:`2390`)
- Fixed a bug where the ``json1`` extension was not correctly detected on the ``/-/versions`` page. Thanks, `Seb Bacon <https://github.com/sebbacon>`__. (:issue:`2326`)
- Fixed a bug where the Datasette write API did not correctly accept ``Content-Type: application/json; charset=utf-8``. (:issue:`2384`)
- Fixed a bug where Datasette would fail to start if ``metadata.yml`` contained a ``queries`` block. (:pr:`2386`)
- Fixed a bug where Datasette would fail to start if ``metadata.yml`` contained a ``queries`` block. (`#2386 <https://github.com/simonw/datasette/pull/2386>`__)
.. _v1_0_a14:
@ -503,13 +290,13 @@ This alpha introduces significant changes to Datasette's :ref:`metadata` system,
- Metadata about tables, databases, instances and columns is now stored in :ref:`internals_internal`. Thanks, Alex Garcia. (:issue:`2341`)
- Database write connections now execute using the ``IMMEDIATE`` isolation level for SQLite. This should help avoid a rare ``SQLITE_BUSY`` error that could occur when a transaction upgraded to a write mid-flight. (:issue:`2358`)
- Fix for a bug where canned queries with named parameters could fail against SQLite 3.46. (:issue:`2353`)
- Datasette now serves ``E-Tag`` headers for static files. Thanks, `Agustin Bacigalup <https://github.com/redraw>`__. (:pr:`2306`)
- Datasette now serves ``E-Tag`` headers for static files. Thanks, `Agustin Bacigalup <https://github.com/redraw>`__. (`#2306 <https://github.com/simonw/datasette/pull/2306>`__)
- Dropdown menus now use a ``z-index`` that should avoid them being hidden by plugins. (:issue:`2311`)
- Incorrect table and row names are no longer reflected back on the resulting 404 page. (:issue:`2359`)
- Improved documentation for async usage of the :ref:`plugin_hook_track_event` hook. (:issue:`2319`)
- Fixed some HTTPX deprecation warnings. (:issue:`2307`)
- Datasette now serves a ``<html lang="en">`` attribute. Thanks, `Charles Nepote <https://github.com/CharlesNepote>`__. (:issue:`2348`)
- Datasette's automated tests now run against the maximum and minimum supported versions of SQLite: 3.25 (from September 2018) and 3.46 (from May 2024). Thanks, Alex Garcia. (:pr:`2352`)
- Datasette's automated tests now run against the maximum and minimum supported versions of SQLite: 3.25 (from September 2018) and 3.46 (from May 2024). Thanks, Alex Garcia. (`#2352 <https://github.com/simonw/datasette/pull/2352>`__)
- Fixed an issue where clicking twice on the URL output by ``datasette --root`` produced a confusing error. (:issue:`2375`)
.. _v0_64_8:
@ -650,7 +437,7 @@ Configuration
- The ``-s/--setting`` option can now be used to set plugin configuration as well. See :ref:`configuration_cli` for details. (:issue:`2252`)
The above YAML configuration example using ``-s/--setting`` looks like this:
.. code-block:: bash
datasette mydatabase.db \
@ -673,7 +460,7 @@ This provides two initial hooks, with more to come in the future:
- :ref:`makeAboveTablePanelConfigs() <javascript_plugins_makeAboveTablePanelConfigs>` can add additional panels to the top of the table page.
- :ref:`makeColumnActions() <javascript_plugins_makeColumnActions>` can add additional actions to the column menu.
Thanks `Cameron Yick <https://github.com/hydrosquall>`__ for contributing this feature. (:pr:`2052`)
Thanks `Cameron Yick <https://github.com/hydrosquall>`__ for contributing this feature. (`#2052 <https://github.com/simonw/datasette/pull/2052>`__)
Plugin hooks
~~~~~~~~~~~~
@ -739,7 +526,7 @@ Minor fixes
- Datasette now checks if the user has permission to view a table linked to by a foreign key before turning that foreign key into a clickable link. (:issue:`2178`)
- The ``execute-sql`` permission now implies that the actor can also view the database and instance. (:issue:`2169`)
- Documentation describing a pattern for building plugins that themselves :ref:`define further hooks <writing_plugins_extra_hooks>` for other plugins. (:issue:`1765`)
- Datasette is now tested against the Python 3.12 preview. (:pr:`2175`)
- Datasette is now tested against the Python 3.12 preview. (`#2175 <https://github.com/simonw/datasette/pull/2175>`__)
.. _v1_0_a5:
@ -764,7 +551,7 @@ For more information and workarounds, read `the security advisory <https://githu
Also in this alpha:
- The new ``datasette plugins --requirements`` option outputs a list of currently installed plugins in Python ``requirements.txt`` format, useful for duplicating that installation elsewhere. (:issue:`2133`)
- :ref:`queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
- :ref:`canned_queries_writable` can now define a ``on_success_message_sql`` field in their configuration, containing a SQL query that should be executed upon successful completion of the write operation in order to generate a message to be shown to the user. (:issue:`2138`)
- The automatically generated border color for a database is now shown in more places around the application. (:issue:`2119`)
- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
@ -942,11 +729,11 @@ Features
~~~~~~~~
- Now tested against Python 3.11. Docker containers used by ``datasette publish`` and ``datasette package`` both now use that version of Python. (:issue:`1853`)
- ``--load-extension`` option now supports entrypoints. Thanks, Alex Garcia. (:pr:`1789`)
- ``--load-extension`` option now supports entrypoints. Thanks, Alex Garcia. (`#1789 <https://github.com/simonw/datasette/pull/1789>`__)
- Facet size can now be set per-table with the new ``facet_size`` table metadata option. (:issue:`1804`)
- The :ref:`setting_truncate_cells_html` setting now also affects long URLs in columns. (:issue:`1805`)
- The non-JavaScript SQL editor textarea now increases height to fit the SQL query. (:issue:`1786`)
- Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (:pr:`1794`)
- Facets are now displayed with better line-breaks in long values. Thanks, Daniel Rech. (`#1794 <https://github.com/simonw/datasette/pull/1794>`__)
- The ``settings.json`` file used in :ref:`config_dir` is now validated on startup. (:issue:`1816`)
- SQL queries can now include leading SQL comments, using ``/* ... */`` or ``-- ...`` syntax. Thanks, Charles Nepote. (:issue:`1860`)
- SQL query is now re-displayed when terminated with a time limit error. (:issue:`1819`)
@ -968,7 +755,7 @@ Documentation
- New tutorial: `Cleaning data with sqlite-utils and Datasette <https://datasette.io/tutorials/clean-data>`__.
- Screenshots in the documentation are now maintained using `shot-scraper <https://shot-scraper.datasette.io/>`__, as described in `Automating screenshots for the Datasette documentation using shot-scraper <https://simonwillison.net/2022/Oct/14/automating-screenshots/>`__. (:issue:`1844`)
- More detailed command descriptions on the :ref:`CLI reference <cli_reference>` page. (:issue:`1787`)
- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (:pr:`1825`)
- New documentation on :ref:`deploying_openrc` - thanks, Adam Simpson. (`#1825 <https://github.com/simonw/datasette/pull/1825>`__)
.. _v0_62:
@ -984,14 +771,14 @@ Features
- Datasette is now compatible with `Pyodide <https://pyodide.org/>`__. This is the enabling technology behind `Datasette Lite <https://lite.datasette.io/>`__. (:issue:`1733`)
- Database file downloads now implement conditional GET using ETags. (:issue:`1739`)
- HTML for facet results and suggested results has been extracted out into new templates ``_facet_results.html`` and ``_suggested_facets.html``. Thanks, M. Nasimul Haque. (:pr:`1759`)
- HTML for facet results and suggested results has been extracted out into new templates ``_facet_results.html`` and ``_suggested_facets.html``. Thanks, M. Nasimul Haque. (`#1759 <https://github.com/simonw/datasette/pull/1759>`__)
- Datasette now runs some SQL queries in parallel. This has limited impact on performance, see `this research issue <https://github.com/simonw/datasette/issues/1727>`__ for details.
- New ``--nolock`` option for ignoring file locks when opening read-only databases. (:issue:`1744`)
- Spaces in the database names in URLs are now encoded as ``+`` rather than ``~20``. (:issue:`1701`)
- ``<Binary: 2427344 bytes>`` is now displayed as ``<Binary: 2,427,344 bytes>`` and is accompanied by tooltip showing "2.3MB". (:issue:`1712`)
- The base Docker image used by ``datasette publish cloudrun``, ``datasette package`` and the `official Datasette image <https://hub.docker.com/datasetteproject/datasette>`__ has been upgraded to ``3.10.6-slim-bullseye``. (:issue:`1768`)
- Canned writable queries against immutable databases now show a warning message. (:issue:`1728`)
- ``datasette publish cloudrun`` has a new ``--timeout`` option which can be used to increase the time limit applied by the Google Cloud build environment. Thanks, Tim Sherratt. (:pr:`1717`)
- ``datasette publish cloudrun`` has a new ``--timeout`` option which can be used to increase the time limit applied by the Google Cloud build environment. Thanks, Tim Sherratt. (`#1717 <https://github.com/simonw/datasette/pull/1717>`__)
- ``datasette publish cloudrun`` has new ``--min-instances`` and ``--max-instances`` options. (:issue:`1779`)
Plugin hooks
@ -999,7 +786,7 @@ Plugin hooks
- New plugin hook: :ref:`handle_exception() <plugin_hook_handle_exception>`, for custom handling of exceptions caught by Datasette. (:issue:`1770`)
- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook is now also passed a ``row`` argument, representing the ``sqlite3.Row`` object that is being rendered. (:issue:`1300`)
- The :ref:`configuration directory <config_dir>` is now stored in ``datasette.config_dir``, making it available to plugins. Thanks, Chris Amico. (:pr:`1766`)
- The :ref:`configuration directory <config_dir>` is now stored in ``datasette.config_dir``, making it available to plugins. Thanks, Chris Amico. (`#1766 <https://github.com/simonw/datasette/pull/1766>`__)
Bug fixes
~~~~~~~~~
@ -1052,7 +839,7 @@ Datasette also now requires Python 3.7 or higher.
- Common Datasette symbols can now be imported directly from the top-level ``datasette`` package, see :ref:`internals_shortcuts`. Those symbols are ``Response``, ``Forbidden``, ``NotFound``, ``hookimpl``, ``actor_matches_allow``. (:issue:`957`)
- ``/-/versions`` page now returns additional details for libraries used by SpatiaLite. (:issue:`1607`)
- Documentation now links to the `Datasette Tutorials <https://datasette.io/tutorials>`__.
- Datasette will now also look for SpatiaLite in ``/opt/homebrew`` - thanks, Dan Peterson. (:pr:`1649`)
- Datasette will now also look for SpatiaLite in ``/opt/homebrew`` - thanks, Dan Peterson. (`#1649 <https://github.com/simonw/datasette/pull/1649>`__)
- Fixed bug where :ref:`custom pages <custom_pages>` did not work on Windows. Thanks, Robert Christie. (:issue:`1545`)
- Fixed error caused when a table had a column named ``n``. (:issue:`1228`)
@ -1151,14 +938,14 @@ Other small fixes
- New :ref:`register_commands() <plugin_hook_register_commands>` plugin hook allows plugins to register additional Datasette CLI commands, e.g. ``datasette mycommand file.db``. (:issue:`1449`)
- Adding ``?_facet_size=max`` to a table page now shows the number of unique values in each facet. (:issue:`1423`)
- Upgraded dependency `httpx 0.20 <https://github.com/encode/httpx/releases/tag/0.20.0>`__ - the undocumented ``allow_redirects=`` parameter to :ref:`internals_datasette_client` is now ``follow_redirects=``, and defaults to ``False`` where it previously defaulted to ``True``. (:issue:`1488`)
- The ``--cors`` option now causes Datasette to return the ``Access-Control-Allow-Headers: Authorization`` header, in addition to ``Access-Control-Allow-Origin: *``. (:pr:`1467`)
- The ``--cors`` option now causes Datasette to return the ``Access-Control-Allow-Headers: Authorization`` header, in addition to ``Access-Control-Allow-Origin: *``. (`#1467 <https://github.com/simonw/datasette/pull/1467>`__)
- Code that figures out which named parameters a SQL query takes in order to display form fields for them is no longer confused by strings that contain colon characters. (:issue:`1421`)
- Renamed ``--help-config`` option to ``--help-settings``. (:issue:`1431`)
- ``datasette.databases`` property is now a documented API. (:issue:`1443`)
- The ``base.html`` template now wraps everything other than the ``<footer>`` in a ``<div class="not-footer">`` element, to help with advanced CSS customization. (:issue:`1446`)
- The :ref:`render_cell() <plugin_hook_render_cell>` plugin hook can now return an awaitable function. This means the hook can execute SQL queries. (:issue:`1425`)
- :ref:`plugin_register_routes` plugin hook now accepts an optional ``datasette`` argument. (:issue:`1404`)
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`queries_options`. (:issue:`1422`)
- New ``hide_sql`` canned query option for defaulting to hiding the SQL query used by a canned query, see :ref:`canned_queries_options`. (:issue:`1422`)
- New ``--cpu`` option for :ref:`datasette publish cloudrun <publish_cloud_run>`. (:issue:`1420`)
- If `Rich <https://github.com/willmcgugan/rich>`__ is installed in the same virtual environment as Datasette, it will be used to provide enhanced display of error tracebacks on the console. (:issue:`1416`)
- ``datasette.utils`` :ref:`internals_utils_parse_metadata` function, used by the new `datasette-remote-metadata plugin <https://datasette.io/plugins/datasette-remote-metadata>`__, is now a documented API. (:issue:`1405`)
@ -1180,7 +967,7 @@ Other small fixes
- New ``datasette --uds /tmp/datasette.sock`` option for binding Datasette to a Unix domain socket, see :ref:`proxy documentation <deploying_proxy>` (:issue:`1388`)
- ``"searchmode": "raw"`` table metadata option for defaulting a table to executing SQLite full-text search syntax without first escaping it, see :ref:`full_text_search_advanced_queries`. (:issue:`1389`)
- New plugin hook: ``get_metadata()``, for returning custom metadata for an instance, database or table. Thanks, Brandon Roberts! (:issue:`1384`)
- New plugin hook: ``skip_csrf``, for opting out of CSRF protection based on the incoming request. (:issue:`1377`)
- New plugin hook: :ref:`plugin_hook_skip_csrf`, for opting out of CSRF protection based on the incoming request. (:issue:`1377`)
- The :ref:`menu_links() <plugin_hook_menu_links>`, :ref:`table_actions() <plugin_hook_table_actions>` and :ref:`database_actions() <plugin_hook_database_actions>` plugin hooks all gained a new optional ``request`` argument providing access to the current request. (:issue:`1371`)
- Major performance improvement for Datasette faceting. (:issue:`1394`)
- Improved documentation for :ref:`deploying_proxy` to recommend using ``ProxyPreservehost On`` with Apache. (:issue:`1387`)
@ -1250,8 +1037,8 @@ Documentation improvements, bug fixes and support for SpatiaLite 5.
- The :ref:`Response.asgi_send() <internals_response_asgi_send>` method is now documented. (:issue:`1266`)
- The official Datasette Docker image now bundles SpatiaLite version 5. (:issue:`1278`)
- Fixed a ``no such table: pragma_database_list`` bug when running Datasette against SQLite versions prior to SQLite 3.16.0. (:issue:`1276`)
- HTML lists displayed in table cells are now styled correctly. Thanks, Bob Whitelock. (:issue:`1141`, :pr:`1252`)
- Configuration directory mode now correctly serves immutable databases that are listed in ``inspect-data.json``. Thanks Campbell Allen and Frankie Robertson. (:pr:`1031`, :pr:`1229`)
- HTML lists displayed in table cells are now styled correctly. Thanks, Bob Whitelock. (:issue:`1141`, `#1252 <https://github.com/simonw/datasette/pull/1252>`__)
- Configuration directory mode now correctly serves immutable databases that are listed in ``inspect-data.json``. Thanks Campbell Allen and Frankie Robertson. (`#1031 <https://github.com/simonw/datasette/pull/1031>`__, `#1229 <https://github.com/simonw/datasette/pull/1229>`__)
.. _v0_55:
@ -1428,7 +1215,7 @@ A new visual design, plugin hooks for adding navigation options, better handling
New visual design
~~~~~~~~~~~~~~~~~
Datasette is no longer white and grey with blue and purple links! `Natalie Downe <https://twitter.com/natbat>`__ has been working on a visual refresh, the first iteration of which is included in this release. (:pr:`1056`)
Datasette is no longer white and grey with blue and purple links! `Natalie Downe <https://twitter.com/natbat>`__ has been working on a visual refresh, the first iteration of which is included in this release. (`#1056 <https://github.com/simonw/datasette/pull/1056>`__)
.. image:: datasette-0.51.png
:width: 740px
@ -1505,7 +1292,7 @@ New :ref:`deploying` documentation with guides for deploying Datasette on a Linu
Other improvements in this release:
- :ref:`publish_cloud_run` documentation now covers Google Cloud SDK options. Thanks, Geoffrey Hing. (:pr:`995`)
- :ref:`publish_cloud_run` documentation now covers Google Cloud SDK options. Thanks, Geoffrey Hing. (`#995 <https://github.com/simonw/datasette/pull/995>`__)
- New ``datasette -o`` option which opens your browser as soon as Datasette starts up. (:issue:`970`)
- Datasette now sets ``sqlite3.enable_callback_tracebacks(True)`` so that errors in custom SQL functions will display tracebacks. (:issue:`891`)
- Fixed two rendering bugs with column headers in portrait mobile view. (:issue:`978`, :issue:`980`)
@ -1532,7 +1319,7 @@ See also `Datasette 0.50: The annotated release notes <https://simonwillison.net
See also `Datasette 0.49: The annotated release notes <https://simonwillison.net/2020/Sep/15/datasette-0-49/>`__.
- Writable canned queries now expose a JSON API, see :ref:`queries_json_api`. (:issue:`880`)
- Writable canned queries now expose a JSON API, see :ref:`canned_queries_json_api`. (:issue:`880`)
- New mechanism for defining page templates with custom path parameters - a template file called ``pages/about/{slug}.html`` will be used to render any requests to ``/about/something``. See :ref:`custom_pages_parameters`. (:issue:`944`)
- ``register_output_renderer()`` render functions can now return a ``Response``. (:issue:`953`)
- New ``--upgrade`` option for ``datasette install``. (:issue:`945`)
@ -1609,7 +1396,7 @@ See also `Datasette 0.49: The annotated release notes <https://simonwillison.net
- ``tests`` are now excluded from the Datasette package properly - thanks, abeyerpath. (:issue:`456`)
- The Datasette package published to PyPI now includes ``sdist`` as well as ``bdist_wheel``.
- Better titles for canned query pages. (:issue:`887`)
- Now only loads Python files from a directory passed using the ``--plugins-dir`` option - thanks, Amjith Ramanujam. (:pr:`890`)
- Now only loads Python files from a directory passed using the ``--plugins-dir`` option - thanks, Amjith Ramanujam. (`#890 <https://github.com/simonw/datasette/pull/890>`__)
- New documentation section on :ref:`publish_vercel`.
.. _v0_45:
@ -1624,7 +1411,7 @@ Magic parameters for canned queries, a log out feature, improved plugin document
Magic parameters for canned queries
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Canned queries now support :ref:`queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
Canned queries now support :ref:`canned_queries_magic_parameters`, which can be used to insert or select automatically generated values. For example::
insert into logs
(user_id, timestamp)
@ -1655,7 +1442,7 @@ New plugin hooks
- :ref:`plugin_hook_register_magic_parameters` can be used to define new types of magic canned query parameters.
- :ref:`plugin_hook_startup` can run custom code when Datasette first starts up. `datasette-init <https://github.com/simonw/datasette-init>`__ is a new plugin that uses this hook to create database tables and views on startup if they have not yet been created. (:issue:`834`)
- ``canned_queries()`` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
- :ref:`plugin_hook_canned_queries` lets plugins provide additional canned queries beyond those defined in Datasette's metadata. See `datasette-saved-queries <https://github.com/simonw/datasette-saved-queries>`__ for an example of this hook in action. (:issue:`852`)
- :ref:`plugin_hook_forbidden` is a hook for customizing how Datasette responds to 403 forbidden errors. (:issue:`812`)
Smaller changes
@ -1730,7 +1517,7 @@ A new debug page at ``/-/permissions`` shows recent permission checks, to help a
Writable canned queries
~~~~~~~~~~~~~~~~~~~~~~~
Datasette's :ref:`queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
Datasette's :ref:`canned_queries` feature lets you define SQL queries in ``metadata.json`` which can then be executed by users visiting a specific URL. https://latest.datasette.io/fixtures/neighborhood_search for example.
Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 introduces the ability for canned queries to execute ``INSERT`` or ``UPDATE`` queries as well, using the new ``"write": true`` property (:issue:`800`):
@ -1749,7 +1536,7 @@ Canned queries were previously restricted to ``SELECT``, but Datasette 0.44 intr
}
}
See :ref:`queries_writable` for more details.
See :ref:`canned_queries_writable` for more details.
Flash messages
~~~~~~~~~~~~~~
@ -1804,7 +1591,7 @@ Smaller changes
- New ``request.cookies`` property.
- ``/-/plugins`` endpoint now shows a list of hooks implemented by each plugin, e.g. https://latest.datasette.io/-/plugins?all=1
- ``request.post_vars()`` method no longer discards empty values.
- New "params" canned query key for explicitly setting named parameters, see :ref:`queries_named_parameters`. (:issue:`797`)
- New "params" canned query key for explicitly setting named parameters, see :ref:`canned_queries_named_parameters`. (:issue:`797`)
- ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object.
- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)
- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)
@ -1837,8 +1624,8 @@ The main focus of this release is a major upgrade to the :ref:`plugin_register_o
* Redesign of :ref:`plugin_register_output_renderer` to provide more context to the render callback and support an optional ``"can_render"`` callback that controls if a suggested link to the output format is provided. (:issue:`581`, :issue:`770`)
* Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`)
* The :ref:`internals_request`, which is passed to several plugin hooks, is now documented. (:issue:`706`)
* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`table_configuration_size`. (:issue:`751`)
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`queries_options`. (:issue:`706`)
* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`metadata_page_size`. (:issue:`751`)
* Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, see :ref:`canned_queries_options`. (:issue:`706`)
* Fixed a bug in ``datasette publish`` when running on operating systems where the ``/tmp`` directory lives in a different volume, using a backport of the Python 3.8 ``shutil.copytree()`` function. (:issue:`744`)
* Every plugin hook is now covered by the unit tests, and a new unit test checks that each plugin hook has at least one corresponding test. (:issue:`771`, :issue:`773`)
@ -1878,7 +1665,7 @@ Also in this release:
* Datasette now has a *pattern portfolio* at ``/-/patterns`` - e.g. https://latest.datasette.io/-/patterns. This is a page that shows every Datasette user interface component in one place, to aid core development and people building custom CSS themes. (:issue:`151`)
* SQLite `PRAGMA functions <https://www.sqlite.org/pragma.html#pragfunc>`__ such as ``pragma_table_info(tablename)`` are now allowed in Datasette SQL queries. (:issue:`761`)
* Datasette pages now consistently return a ``content-type`` of ``text/html; charset=utf-8"``. (:issue:`752`)
* Datasette now handles an ASGI ``raw_path`` value of ``None``, which should allow compatibility with the `Mangum <https://github.com/erm/mangum>`__ adapter for running ASGI apps on AWS Lambda. Thanks, Colin Dellow. (:pr:`719`)
* Datasette now handles an ASGI ``raw_path`` value of ``None``, which should allow compatibility with the `Mangum <https://github.com/erm/mangum>`__ adapter for running ASGI apps on AWS Lambda. Thanks, Colin Dellow. (`#719 <https://github.com/simonw/datasette/pull/719>`__)
* Installation documentation now covers how to :ref:`installation_pipx`. (:issue:`756`)
* Improved the documentation for :ref:`full_text_search`. (:issue:`748`)
@ -1901,7 +1688,7 @@ Also in this release:
-----------------
* New :ref:`setting_base_url` configuration setting for serving up the correct links while running Datasette under a different URL prefix. (:issue:`394`)
* New metadata settings ``"sort"`` and ``"sort_desc"`` for setting the default sort order for a table. See :ref:`table_configuration_sort`. (:issue:`702`)
* New metadata settings ``"sort"`` and ``"sort_desc"`` for setting the default sort order for a table. See :ref:`metadata_default_sort`. (:issue:`702`)
* Sort direction arrow now displays by default on the primary key. This means you only have to click once (not twice) to sort in reverse order. (:issue:`677`)
* New ``await Request(scope, receive).post_vars()`` method for accessing POST form variables. (:issue:`700`)
* :ref:`plugin_hooks` documentation now links to example uses of each plugin. (:issue:`709`)
@ -1931,7 +1718,7 @@ Also in this release:
-----------------
* Plugins now have a supported mechanism for writing to a database, using the new ``.execute_write()`` and ``.execute_write_fn()`` methods. :ref:`Documentation <database_execute_write>`. (:issue:`682`)
* Immutable databases that have had their rows counted using the ``inspect`` command now use the calculated count more effectively - thanks, Kevin Keogh. (:pr:`666`)
* Immutable databases that have had their rows counted using the ``inspect`` command now use the calculated count more effectively - thanks, Kevin Keogh. (`#666 <https://github.com/simonw/datasette/pull/666>`__)
* ``--reload`` no longer restarts the server if a database file is modified, unless that database was opened immutable mode with ``-i``. (:issue:`494`)
* New ``?_searchmode=raw`` option turns off escaping for FTS queries in ``?_search=`` allowing full use of SQLite's `FTS5 query syntax <https://www.sqlite.org/fts5.html#full_text_query_syntax>`__. (:issue:`676`)
@ -1952,7 +1739,7 @@ Also in this release:
* Added five new plugins and one new conversion tool to the :ref:`ecosystem`.
* The ``Datasette`` class has a new ``render_template()`` method which can be used by plugins to render templates using Datasette's pre-configured `Jinja <https://jinja.palletsprojects.com/>`__ templating library.
* You can now execute SQL queries that start with a ``-- comment`` - thanks, Jay Graves (:pr:`653`)
* You can now execute SQL queries that start with a ``-- comment`` - thanks, Jay Graves (`#653 <https://github.com/simonw/datasette/pull/653>`__)
.. _v0_34:
@ -1960,7 +1747,7 @@ Also in this release:
-----------------
* ``_search=`` queries are now correctly escaped using a new ``escape_fts()`` custom SQL function. This means you can now run searches for strings like ``park.`` without seeing errors. (:issue:`651`)
* `Google Cloud Run <https://cloud.google.com/run/>`__ is no longer in beta, so ``datasette publish cloudrun`` has been updated to work even if the user has not installed the ``gcloud`` beta components package. Thanks, Katie McLaughlin (:pr:`660`)
* `Google Cloud Run <https://cloud.google.com/run/>`__ is no longer in beta, so ``datasette publish cloudrun`` has been updated to work even if the user has not installed the ``gcloud`` beta components package. Thanks, Katie McLaughlin (`#660 <https://github.com/simonw/datasette/pull/660>`__)
* ``datasette package`` now accepts a ``--port`` option for specifying which port the resulting Docker container should listen on. (:issue:`661`)
.. _v0_33:
@ -1998,7 +1785,7 @@ Datasette now renders templates using `Jinja async mode <https://jinja.palletspr
0.31.1 (2019-11-12)
-------------------
- Deployments created using ``datasette publish`` now use ``python:3.8`` base Docker image (:pr:`629`)
- Deployments created using ``datasette publish`` now use ``python:3.8`` base Docker image (`#629 <https://github.com/simonw/datasette/pull/629>`__)
.. _v0_31:
@ -2011,10 +1798,10 @@ If you are still running Python 3.5 you should stick with ``0.30.2``, which you
pip install datasette==0.30.2
- Format SQL button now works with read-only SQL queries - thanks, Tobias Kunze (:pr:`602`)
- Format SQL button now works with read-only SQL queries - thanks, Tobias Kunze (`#602 <https://github.com/simonw/datasette/pull/602>`__)
- New ``?column__notin=x,y,z`` filter for table views (:issue:`614`)
- Table view now uses ``select col1, col2, col3`` instead of ``select *``
- Database filenames can now contain spaces - thanks, Tobias Kunze (:pr:`590`)
- Database filenames can now contain spaces - thanks, Tobias Kunze (`#590 <https://github.com/simonw/datasette/pull/590>`__)
- Removed obsolete ``?_group_count=col`` feature (:issue:`504`)
- Improved user interface and documentation for ``datasette publish cloudrun`` (:issue:`608`)
- Tables with indexes now show the ``CREATE INDEX`` statements on the table page (:issue:`618`)
@ -2050,7 +1837,7 @@ If you are still running Python 3.5 you should stick with ``0.30.2``, which you
- Allow ``EXPLAIN WITH...`` (:issue:`583`)
- Button to format SQL - thanks, Tobias Kunze (:issue:`136`)
- Sort databases on homepage by argument order - thanks, Tobias Kunze (:issue:`585`)
- Display metadata footer on custom SQL queries - thanks, Tobias Kunze (:pr:`589`)
- Display metadata footer on custom SQL queries - thanks, Tobias Kunze (`#589 <https://github.com/simonw/datasette/pull/589>`__)
- Use ``--platform=managed`` for ``publish cloudrun`` (:issue:`587`)
- Fixed bug returning non-ASCII characters in CSV (:issue:`584`)
- Fix for ``/foo`` v.s. ``/foo-bar`` bug (:issue:`601`)
@ -2061,7 +1848,7 @@ If you are still running Python 3.5 you should stick with ``0.30.2``, which you
-------------------
- Fixed implementation of CodeMirror on database page (:issue:`560`)
- Documentation typo fixes - thanks, Min ho Kim (:pr:`561`)
- Documentation typo fixes - thanks, Min ho Kim (`#561 <https://github.com/simonw/datasette/pull/561>`__)
- Mechanism for detecting if a table has FTS enabled now works if the table name used alternative escaping mechanisms (:issue:`570`) - for compatibility with `a recent change to sqlite-utils <https://github.com/simonw/sqlite-utils/pull/57>`__.
.. _v0_29_2:
@ -2206,7 +1993,7 @@ Datasette :ref:`facets` provide an intuitive way to quickly summarize and intera
Facet by array (:issue:`359`) is only available if your SQLite installation provides the ``json1`` extension. Datasette will automatically detect columns that contain JSON arrays of values and offer a faceting interface against those columns - useful for modelling things like tags without needing to break them out into a new table. See :ref:`facet_by_json_array` for more.
The new :ref:`plugin_register_facet_classes` plugin hook (:pr:`445`) can be used to register additional custom facet classes. Each facet class should provide two methods: ``suggest()`` which suggests facet selections that might be appropriate for a provided SQL query, and ``facet_results()`` which executes a facet operation and returns results. Datasette's own faceting implementations have been refactored to use the same API as these plugins.
The new :ref:`plugin_register_facet_classes` plugin hook (`#445 <https://github.com/simonw/datasette/pull/445>`__) can be used to register additional custom facet classes. Each facet class should provide two methods: ``suggest()`` which suggests facet selections that might be appropriate for a provided SQL query, and ``facet_results()`` which executes a facet operation and returns results. Datasette's own faceting implementations have been refactored to use the same API as these plugins.
.. _v0_28_publish_cloudrun:
@ -2215,7 +2002,7 @@ datasette publish cloudrun
`Google Cloud Run <https://cloud.google.com/run/>`__ is a brand new serverless hosting platform from Google, which allows you to build a Docker container which will run only when HTTP traffic is received and will shut down (and hence cost you nothing) the rest of the time. It's similar to Zeit's Now v1 Docker hosting platform which sadly is `no longer accepting signups <https://hyperion.alpha.spectrum.chat/zeit/now/cannot-create-now-v1-deployments~d206a0d4-5835-4af5-bb5c-a17f0171fb25?m=MTU0Njk2NzgwODM3OA==>`__ from new users.
The new ``datasette publish cloudrun`` command was contributed by Romain Primet (:pr:`434`) and publishes selected databases to a new Datasette instance running on Google Cloud Run.
The new ``datasette publish cloudrun`` command was contributed by Romain Primet (`#434 <https://github.com/simonw/datasette/pull/434>`__) and publishes selected databases to a new Datasette instance running on Google Cloud Run.
See :ref:`publish_cloud_run` for full documentation.
@ -2224,7 +2011,7 @@ See :ref:`publish_cloud_run` for full documentation.
register_output_renderer plugins
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Russ Garrett implemented a new Datasette plugin hook called :ref:`register_output_renderer <plugin_register_output_renderer>` (:pr:`441`) which allows plugins to create additional output renderers in addition to Datasette's default ``.json`` and ``.csv``.
Russ Garrett implemented a new Datasette plugin hook called :ref:`register_output_renderer <plugin_register_output_renderer>` (`#441 <https://github.com/simonw/datasette/pull/441>`__) which allows plugins to create additional output renderers in addition to Datasette's default ``.json`` and ``.csv``.
Russ's in-development `datasette-geo <https://github.com/russss/datasette-geo>`__ plugin includes `an example <https://github.com/russss/datasette-geo/blob/d4cecc020848bbde91e9e17bf352f7c70bc3dccf/datasette_plugin_geo/geojson.py>`__ of this hook being used to output ``.geojson`` automatically converted from SpatiaLite.
@ -2233,7 +2020,7 @@ Russ's in-development `datasette-geo <https://github.com/russss/datasette-geo>`_
Medium changes
~~~~~~~~~~~~~~
- Datasette now conforms to the `Black coding style <https://github.com/python/black>`__ (:pr:`449`) - and has a unit test to enforce this in the future
- Datasette now conforms to the `Black coding style <https://github.com/python/black>`__ (`#449 <https://github.com/simonw/datasette/pull/449>`__) - and has a unit test to enforce this in the future
- New :ref:`json_api_table_arguments`:
- ``?columnname__in=value1,value2,value3`` filter for executing SQL IN queries against a table, see :ref:`table_arguments` (:issue:`433`)
- ``?columnname__date=yyyy-mm-dd`` filter which returns rows where the spoecified datetime column falls on the specified date (`583b22a <https://github.com/simonw/datasette/commit/583b22aa28e26c318de0189312350ab2688c90b1>`__)
@ -2254,17 +2041,17 @@ Small changes
- We now show the size of the database file next to the download link (:issue:`172`)
- New ``/-/databases`` introspection page shows currently connected databases (:issue:`470`)
- Binary data is no longer displayed on the table and row pages (:pr:`442` - thanks, Russ Garrett)
- Binary data is no longer displayed on the table and row pages (`#442 <https://github.com/simonw/datasette/pull/442>`__ - thanks, Russ Garrett)
- New show/hide SQL links on custom query pages (:issue:`415`)
- The :ref:`extra_body_script <plugin_hook_extra_body_script>` plugin hook now accepts an optional ``view_name`` argument (:pr:`443` - thanks, Russ Garrett)
- Bumped Jinja2 dependency to 2.10.1 (:pr:`426`)
- The :ref:`extra_body_script <plugin_hook_extra_body_script>` plugin hook now accepts an optional ``view_name`` argument (`#443 <https://github.com/simonw/datasette/pull/443>`__ - thanks, Russ Garrett)
- Bumped Jinja2 dependency to 2.10.1 (`#426 <https://github.com/simonw/datasette/pull/426>`__)
- All table filters are now documented, and documentation is enforced via unit tests (`2c19a27 <https://github.com/simonw/datasette/commit/2c19a27d15a913e5f3dd443f04067169a6f24634>`__)
- New project guideline: master should stay shippable at all times! (`31f36e1 <https://github.com/simonw/datasette/commit/31f36e1b97ccc3f4387c80698d018a69798b6228>`__)
- Fixed a bug where ``sqlite_timelimit()`` occasionally failed to clean up after itself (`bac4e01 <https://github.com/simonw/datasette/commit/bac4e01f40ae7bd19d1eab1fb9349452c18de8f5>`__)
- We no longer load additional plugins when executing pytest (:issue:`438`)
- Homepage now links to database views if there are less than five tables in a database (:issue:`373`)
- The ``--cors`` option is now respected by error pages (:issue:`453`)
- ``datasette publish heroku`` now uses the ``--include-vcs-ignore`` option, which means it works under Travis CI (:pr:`407`)
- ``datasette publish heroku`` now uses the ``--include-vcs-ignore`` option, which means it works under Travis CI (`#407 <https://github.com/simonw/datasette/pull/407>`__)
- ``datasette publish heroku`` now publishes using Python 3.6.8 (`666c374 <https://github.com/simonw/datasette/commit/666c37415a898949fae0437099d62a35b1e9c430>`__)
- Renamed ``datasette publish now`` to ``datasette publish nowv1`` (:issue:`472`)
- ``datasette publish nowv1`` now accepts multiple ``--alias`` parameters (`09ef305 <https://github.com/simonw/datasette/commit/09ef305c687399384fe38487c075e8669682deb4>`__)
@ -2336,7 +2123,7 @@ New plugin hooks, improved database view support and an easier way to use more r
- New ``render_cell`` plugin hook. Plugins can now customize how values are displayed in the HTML tables produced by Datasette's browsable interface. `datasette-json-html <https://github.com/simonw/datasette-json-html>`__ and `datasette-render-images <https://github.com/simonw/datasette-render-images>`__ are two new plugins that use this hook. :ref:`render_cell documentation <plugin_hook_render_cell>`. Closes :issue:`352`
- New ``extra_body_script`` plugin hook, enabling plugins to provide additional JavaScript that should be added to the page footer. :ref:`extra_body_script documentation <plugin_hook_extra_body_script>`.
- ``extra_css_urls`` and ``extra_js_urls`` hooks now take additional optional parameters, allowing them to be more selective about which pages they apply to. :ref:`Documentation <plugin_hook_extra_css_urls>`.
- You can now use the :ref:`sortable_columns metadata setting <table_configuration_sortable_columns>` to explicitly enable sort-by-column in the interface for database views, as well as for specific tables.
- You can now use the :ref:`sortable_columns metadata setting <metadata_sortable_columns>` to explicitly enable sort-by-column in the interface for database views, as well as for specific tables.
- The new ``fts_table`` and ``fts_pk`` metadata settings can now be used to :ref:`explicitly configure full-text search for a table or a view <full_text_search_table_or_view>`, even if that table is not directly coupled to the SQLite FTS feature in the database schema itself.
- Datasette will now use `pysqlite3 <https://github.com/coleifer/pysqlite3>`__ in place of the standard library ``sqlite3`` module if it has been installed in the current environment. This makes it much easier to run Datasette against a more recent version of SQLite, including the just-released `SQLite 3.25.0 <https://www.sqlite.org/releaselog/3_25_0.html>`__ which adds window function support. More details on how to use this in :issue:`360`
- New mechanism that allows :ref:`plugin configuration options <plugins_configuration>` to be set using ``metadata.json``.
@ -2355,7 +2142,7 @@ A number of small new features:
- Documentation for :ref:`datasette publish and datasette package <publishing>`, closes `#337 <https://github.com/simonw/datasette/issues/337>`_
- Fixed compatibility with Python 3.7
- ``datasette publish heroku`` now supports app names via the ``-n`` option, which can also be used to overwrite an existing application [Russ Garrett]
- Title and description metadata can now be set for :ref:`canned SQL queries <queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
- Title and description metadata can now be set for :ref:`canned SQL queries <canned_queries>`, closes `#342 <https://github.com/simonw/datasette/issues/342>`_
- New ``force_https_on`` config option, fixes ``https://`` API URLs when deploying to Zeit Now - closes `#333 <https://github.com/simonw/datasette/issues/333>`_
- ``?_json_infinity=1`` query string argument for handling Infinity/-Infinity values in JSON, closes `#332 <https://github.com/simonw/datasette/issues/332>`_
- URLs displayed in the results of custom SQL queries are now URLified, closes `#298 <https://github.com/simonw/datasette/issues/298>`_
@ -2421,7 +2208,7 @@ Foreign key expansions
~~~~~~~~~~~~~~~~~~~~~~
When Datasette detects a foreign key reference it attempts to resolve a label
for that reference (automatically or using the :ref:`table_configuration_label_column` metadata
for that reference (automatically or using the :ref:`label_columns` metadata
option) so it can display a link to the associated row.
This expansion is now also available for JSON and CSV representations of the

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