diff --git a/.github/workflows/deploy-branch-preview.yml b/.github/workflows/deploy-branch-preview.yml
deleted file mode 100644
index e56d9c27..00000000
--- a/.github/workflows/deploy-branch-preview.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: Deploy a Datasette branch preview to Vercel
-
-on:
- workflow_dispatch:
- inputs:
- branch:
- description: "Branch to deploy"
- required: true
- type: string
-
-jobs:
- deploy-branch-preview:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- - name: Set up Python 3.11
- uses: actions/setup-python@v6
- with:
- python-version: "3.11"
- - name: Install dependencies
- run: |
- pip install datasette-publish-vercel
- - name: Deploy the preview
- env:
- VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }}
- run: |
- export BRANCH="${{ github.event.inputs.branch }}"
- wget https://latest.datasette.io/fixtures.db
- datasette publish vercel fixtures.db \
- --branch $BRANCH \
- --project "datasette-preview-$BRANCH" \
- --token $VERCEL_TOKEN \
- --scope datasette \
- --about "Preview of $BRANCH" \
- --about_url "https://github.com/simonw/datasette/tree/$BRANCH"
diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml
index 7349a1ab..b0640ae8 100644
--- a/.github/workflows/deploy-latest.yml
+++ b/.github/workflows/deploy-latest.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -57,7 +57,7 @@ jobs:
db.route = "alternative-route"
' > plugins/alternative_route.py
cp fixtures.db fixtures2.db
- - name: And the counters writable canned query demo
+ - name: And the counters writable stored query demo
run: |
cat > plugins/counters.py <=0.2.2' \
--service "datasette-latest$SUFFIX" \
--secret $LATEST_DATASETTE_SECRET
diff --git a/.github/workflows/documentation-links.yml b/.github/workflows/documentation-links.yml
index a54bd83a..b8fb8aaa 100644
--- a/.github/workflows/documentation-links.yml
+++ b/.github/workflows/documentation-links.yml
@@ -1,6 +1,6 @@
name: Read the Docs Pull Request Preview
on:
- pull_request_target:
+ pull_request:
types:
- opened
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 00000000..5275ddef
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,48 @@
+name: Playwright
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ browser: [chromium, firefox, webkit]
+ steps:
+ - uses: actions/checkout@v6
+ - name: Set up Python 3.14
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.14"
+ allow-prereleases: true
+ cache: pip
+ cache-dependency-path: pyproject.toml
+ - name: Cache uv
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/uv
+ key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-py3.14-uv-
+ - name: Cache Playwright browsers
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/ms-playwright/
+ key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-playwright-${{ matrix.browser }}-
+ - name: Install uv
+ run: python -m pip install uv
+ - name: Install dependencies
+ run: uv sync --group dev --group playwright
+ - name: Install ${{ matrix.browser }}
+ run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }}
+ - name: Run Playwright tests
+ run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }}
diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml
index 77cce7d1..735e14e9 100644
--- a/.github/workflows/prettier.yml
+++ b/.github/workflows/prettier.yml
@@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repo
- uses: actions/checkout@v4
- - uses: actions/cache@v4
+ uses: actions/checkout@v6
+ - uses: actions/cache@v5
name: Configure npm caching
with:
path: ~/.npm
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2e8cea9c..87300593 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -14,7 +14,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@@ -35,7 +35,7 @@ jobs:
permissions:
id-token: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -56,7 +56,7 @@ jobs:
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -92,7 +92,7 @@ jobs:
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Build and push to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}
diff --git a/.github/workflows/push_docker_tag.yml b/.github/workflows/push_docker_tag.yml
index afe8d6b2..e622ef4c 100644
--- a/.github/workflows/push_docker_tag.yml
+++ b/.github/workflows/push_docker_tag.yml
@@ -13,7 +13,7 @@ jobs:
deploy_docker:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v6
- name: Build and push to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}
diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml
index d42ae96b..9a808194 100644
--- a/.github/workflows/spellcheck.yml
+++ b/.github/workflows/spellcheck.yml
@@ -9,7 +9,7 @@ jobs:
spellcheck:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/stable-docs.yml b/.github/workflows/stable-docs.yml
index 3119d617..59b5fbc0 100644
--- a/.github/workflows/stable-docs.yml
+++ b/.github/workflows/stable-docs.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 0 # We need all commits to find docs/ changes
- name: Set up Git user
diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml
index 1b3d2f2c..c514048e 100644
--- a/.github/workflows/test-coverage.yml
+++ b/.github/workflows/test-coverage.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/test-pyodide.yml b/.github/workflows/test-pyodide.yml
index b490a9bf..5162c47a 100644
--- a/.github/workflows/test-pyodide.yml
+++ b/.github/workflows/test-pyodide.yml
@@ -12,7 +12,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python 3.10
uses: actions/setup-python@v6
with:
@@ -20,7 +20,7 @@ jobs:
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- name: Cache Playwright browsers
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers
diff --git a/.github/workflows/test-sqlite-support.yml b/.github/workflows/test-sqlite-support.yml
index c81a3c0b..23fce459 100644
--- a/.github/workflows/test-sqlite-support.yml
+++ b/.github/workflows/test-sqlite-support.yml
@@ -25,7 +25,7 @@ jobs:
#"3.23.1" # 2018-04-10, before UPSERT
]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a0f5477b..9e47db6f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,10 +9,11 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/tmate-mac.yml b/.github/workflows/tmate-mac.yml
index fcee0f21..a033cd92 100644
--- a/.github/workflows/tmate-mac.yml
+++ b/.github/workflows/tmate-mac.yml
@@ -10,6 +10,6 @@ jobs:
build:
runs-on: macos-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v6
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
diff --git a/.github/workflows/tmate.yml b/.github/workflows/tmate.yml
index 123f6c71..72af1eec 100644
--- a/.github/workflows/tmate.yml
+++ b/.github/workflows/tmate.yml
@@ -11,7 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v6
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
env:
diff --git a/.gitignore b/.gitignore
index 12acd87e..8c058692 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
build-metadata.json
datasets.json
+.playwright-mcp
+
scratchpad
.vscode
@@ -131,4 +133,4 @@ tests/*.dylib
tests/*.so
tests/*.dll
-.idea
\ No newline at end of file
+.idea
diff --git a/Justfile b/Justfile
index 657881be..5fcd9afd 100644
--- a/Justfile
+++ b/Justfile
@@ -11,6 +11,22 @@ export DATASETTE_SECRET := "not_a_secret"
@test *options: init
uv run pytest -n auto {{options}}
+# Install Playwright browser support, Chromium by default
+@playwright-install browser="chromium":
+ uv run --group playwright playwright install {{browser}}
+
+# Install all Playwright browsers used by the test suite
+@playwright-install-all:
+ uv run --group playwright playwright install chromium firefox webkit
+
+# Run Playwright tests, Chromium by default
+@playwright browser="chromium" *options:
+ uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}}
+
+# Run Playwright tests against all supported browsers
+@playwright-all *options:
+ uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}}
+
@codespell:
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py
new file mode 100644
index 00000000..103c616d
--- /dev/null
+++ b/datasette/_pytest_plugin.py
@@ -0,0 +1,123 @@
+"""
+Pytest plugin that automatically closes any Datasette instances constructed
+during a pytest test — both in the test body and in function-scoped
+fixtures. Instances constructed by session-, module-, class- or package-
+scoped fixtures are left alone, because other tests in the session will
+still want to use them.
+
+Registered as a pytest11 entry point in pyproject.toml so that downstream
+projects using Datasette get the same FD-safety net for their own tests.
+
+Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the
+equivalent ini file).
+"""
+
+from __future__ import annotations
+
+import contextvars
+import weakref
+
+import pytest
+
+_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
+ "datasette_active_instances", default=None
+)
+
+_original_init = None
+
+
+def _install_tracking():
+ # datasette.app is imported lazily here rather than at module level:
+ # as a pytest11 entry point this module is imported during pytest
+ # startup, before pytest-cov starts measuring, so a module-level
+ # import would drag in all of datasette and make every import-time
+ # line in the package invisible to coverage
+ global _original_init
+ if _original_init is not None:
+ return
+ from datasette.app import Datasette
+
+ _original_init = Datasette.__init__
+
+ def _tracking_init(self, *args, **kwargs):
+ _original_init(self, *args, **kwargs)
+ instances = _active_instances.get()
+ if instances is not None:
+ instances.append(weakref.ref(self))
+
+ Datasette.__init__ = _tracking_init
+
+
+def pytest_configure(config):
+ if _enabled(config):
+ _install_tracking()
+
+
+def pytest_addoption(parser):
+ parser.addini(
+ "datasette_autoclose",
+ help=(
+ "Automatically close Datasette instances created inside test "
+ "bodies and function-scoped fixtures (default: true)."
+ ),
+ default="true",
+ )
+
+
+def _enabled(config) -> bool:
+ value = config.getini("datasette_autoclose")
+ if isinstance(value, bool):
+ return value
+ return str(value).strip().lower() not in ("false", "0", "no", "off")
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_protocol(item, nextitem):
+ """Track Datasette instances across setup, call and teardown; close at end."""
+ if not _enabled(item.config):
+ yield
+ return
+ refs: list[weakref.ref] = []
+ token = _active_instances.set(refs)
+ try:
+ yield
+ finally:
+ _active_instances.reset(token)
+ for ref in reversed(refs):
+ ds = ref()
+ if ds is None:
+ continue
+ try:
+ ds.close()
+ except Exception as e:
+ item.warn(
+ pytest.PytestUnraisableExceptionWarning(
+ f"Error closing Datasette instance: {e!r}"
+ )
+ )
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_fixture_setup(fixturedef, request):
+ """Exempt instances created by non-function-scoped fixtures.
+
+ Session-, module-, class- and package-scoped fixtures produce Datasette
+ instances that must survive beyond the current test — other tests in
+ the session will still use them. When such a fixture creates one or
+ more Datasette instances during its setup, we snapshot the tracking
+ list before the fixture runs and subtract off any instances that were
+ added during its setup, so they don't get closed at test teardown.
+ """
+ refs = _active_instances.get()
+ if refs is None:
+ yield
+ return
+ before_ids = {id(ref) for ref in refs}
+ yield
+ if fixturedef.scope != "function":
+ new_refs = [ref for ref in refs if id(ref) not in before_ids]
+ for new_ref in new_refs:
+ try:
+ refs.remove(new_ref)
+ except ValueError:
+ pass
diff --git a/datasette/app.py b/datasette/app.py
index 2df6e4e8..9c9b7de4 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -1,20 +1,17 @@
from __future__ import annotations
-from asgi_csrf import Errors
import asyncio
import contextvars
-from typing import TYPE_CHECKING, Any, Dict, Iterable, List
+from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
if TYPE_CHECKING:
from datasette.permissions import Resource
from datasette.tokens import TokenRestrictions
-import asgi_csrf
import collections
import dataclasses
import datetime
import functools
import glob
-import hashlib
import httpx
import importlib.metadata
import inspect
@@ -37,18 +34,44 @@ from jinja2 import (
ChoiceLoader,
Environment,
FileSystemLoader,
+ pass_context,
PrefixLoader,
)
from jinja2.environment import Template
from jinja2.exceptions import TemplateNotFound
from .events import Event
+from .column_types import SQLiteType
+from . import stored_queries, write_sql
from .views import Context
-from .views.database import database_download, DatabaseView, TableCreateView, QueryView
+from .views.database import (
+ database_download,
+ DatabaseView,
+ QueryView,
+)
+from .views.table_create_alter import (
+ DatabaseForeignKeyTargetsView,
+ TableAlterView,
+ TableCreateView,
+ TableForeignKeySuggestionsView,
+)
+from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
+from .views.stored_queries import (
+ QueryCreateAnalyzeView,
+ QueryDeleteView,
+ QueryDefinitionView,
+ QueryEditView,
+ GlobalQueryListView,
+ QueryListView,
+ QueryParametersView,
+ QueryStoreView,
+ QueryUpdateView,
+)
from .views.index import IndexView
from .views.special import (
JsonDataView,
PatternPortfolioView,
+ AutocompleteDebugView,
AuthTokenView,
ApiExplorerView,
CreateTokenView,
@@ -59,15 +82,18 @@ from .views.special import (
AllowedResourcesView,
PermissionRulesView,
PermissionCheckView,
- TablesView,
+ JumpView,
InstanceSchemaView,
DatabaseSchemaView,
TableSchemaView,
)
from .views.table import (
+ TableAutocompleteView,
TableInsertView,
TableUpsertView,
+ TableSetColumnTypeView,
TableDropView,
+ TableFragmentView,
table_view,
)
from .views.row import RowView, RowDeleteView, RowUpdateView
@@ -96,6 +122,7 @@ from .utils import (
parse_metadata,
resolve_env_secrets,
resolve_routes,
+ sha256_file,
tilde_decode,
tilde_encode,
to_css_class,
@@ -118,6 +145,7 @@ from .utils.asgi import (
asgi_send_file,
asgi_send_redirect,
)
+from .csrf import CrossOriginProtectionMiddleware
from .utils.internal_db import init_internal_db, populate_schema_tables
from .utils.sqlite import (
sqlite3,
@@ -272,12 +300,21 @@ DEFAULT_NOT_SET = object()
ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params"))
+def _permission_cache_key(actor, action, parent, child):
+ # Key on the full serialized actor so actors differing in any field
+ # (e.g. token restrictions) never share cache entries
+ actor_key = (
+ json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None
+ )
+ return (actor_key, action, parent, child)
+
+
async def favicon(request, send):
await asgi_send_file(
send,
str(FAVICON_PATH),
content_type="image/png",
- headers={"Cache-Control": "max-age=3600, immutable, public"},
+ headers={"Cache-Control": "max-age=3600, public"},
)
@@ -294,6 +331,57 @@ def _to_string(value):
return json.dumps(value, default=str)
+def _template_context_json_default(value):
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
+ return {
+ field.name: getattr(value, field.name)
+ for field in dataclasses.fields(value)
+ }
+ return repr(value)
+
+
+@pass_context
+def _legacy_template_csrftoken(context):
+ request = context.get("request")
+ if request and "csrftoken" in request.scope:
+ return request.scope["csrftoken"]()
+ return ""
+
+
+def _resolve_static_asset_path(root_path, path):
+ root = Path(root_path).resolve()
+ full_path = (root / path).resolve()
+ try:
+ full_path.relative_to(root)
+ except ValueError:
+ raise ValueError("Static asset path cannot escape static root") from None
+ return full_path
+
+
+# Documentation for the variables Datasette.render_template() adds to the
+# context for every page. This is part of the documented template contract:
+# keys added in render_template() must be documented here - the contract
+# tests in tests/test_template_context.py enforce this, and the docs in
+# docs/template_context.rst are generated from it.
+TEMPLATE_BASE_CONTEXT = {
+ "request": "The current :ref:`Request object `, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.",
+ "crumb_items": 'Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.',
+ "urls": "Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.",
+ "actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.",
+ "menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.",
+ "display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.",
+ "show_logout": "True if the logout link should be shown in the navigation menu",
+ "zip": "Python's ``zip()`` builtin, made available to template logic",
+ "body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``
diff --git a/datasette/templates/_codemirror.html b/datasette/templates/_codemirror.html
index c4629aeb..75c16168 100644
--- a/datasette/templates/_codemirror.html
+++ b/datasette/templates/_codemirror.html
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/datasette/templates/_facet_results.html b/datasette/templates/_facet_results.html
index 034e9678..570bb37e 100644
--- a/datasette/templates/_facet_results.html
+++ b/datasette/templates/_facet_results.html
@@ -12,9 +12,9 @@
{% for facet_value in facet_info.results %}
{% if not facet_value.selected %}
- - {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
+ - {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
{% else %}
- - {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} ✖
+ - {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} ✖
{% endif %}
{% endfor %}
{% if facet_info.truncated %}
diff --git a/datasette/templates/_query_form_styles.html b/datasette/templates/_query_form_styles.html
new file mode 100644
index 00000000..cf2dd42c
--- /dev/null
+++ b/datasette/templates/_query_form_styles.html
@@ -0,0 +1,138 @@
+
diff --git a/datasette/templates/_query_results.html b/datasette/templates/_query_results.html
new file mode 100644
index 00000000..5e1e2f72
--- /dev/null
+++ b/datasette/templates/_query_results.html
@@ -0,0 +1,20 @@
+{% if display_rows %}
+
+
+
+ {% for column in columns %}| {{ column }} | {% endfor %}
+
+
+
+ {% for row in display_rows %}
+
+ {% for column, td in zip(columns, row) %}
+ | {{ td }} |
+ {% endfor %}
+
+ {% endfor %}
+
+
+{% elif show_zero_results %}
+ 0 results
+{% endif %}
diff --git a/datasette/templates/_sql_parameter_scripts.html b/datasette/templates/_sql_parameter_scripts.html
new file mode 100644
index 00000000..9b83889e
--- /dev/null
+++ b/datasette/templates/_sql_parameter_scripts.html
@@ -0,0 +1,307 @@
+
diff --git a/datasette/templates/_sql_parameter_styles.html b/datasette/templates/_sql_parameter_styles.html
new file mode 100644
index 00000000..bc6838f5
--- /dev/null
+++ b/datasette/templates/_sql_parameter_styles.html
@@ -0,0 +1,58 @@
+
diff --git a/datasette/templates/_sql_parameters.html b/datasette/templates/_sql_parameters.html
new file mode 100644
index 00000000..b5c1bde8
--- /dev/null
+++ b/datasette/templates/_sql_parameters.html
@@ -0,0 +1,10 @@
+{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %}
+
+ {% if parameter_names %}
+
Parameters
+ {% for parameter in parameter_names %}
+ {% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
+
{% if sql_parameters_allow_expand|default(false) %} {% endif %}
+ {% endfor %}
+ {% endif %}
+
diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html
index a1329ba7..171b6442 100644
--- a/datasette/templates/_table.html
+++ b/datasette/templates/_table.html
@@ -1,12 +1,12 @@
-{% if display_rows %}
+{% if display_columns %}
{% for column in display_columns %}
- |
+ |
{% if not column.sortable %}
{{ column.name }}
{% else %}
@@ -22,7 +22,7 @@
|
{% for row in display_rows %}
-
+
{% for cell in row %}
| {{ cell.value }} |
{% endfor %}
@@ -31,6 +31,7 @@
-{% else %}
+{% endif %}
+{% if not display_rows %}
0 records
{% endif %}
diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html
index dc393c20..4927cb8d 100644
--- a/datasette/templates/api_explorer.html
+++ b/datasette/templates/api_explorer.html
@@ -3,7 +3,7 @@
{% block title %}API Explorer{% endblock %}
{% block extra_head %}
-
+
{% endblock %}
{% block content %}
@@ -19,7 +19,7 @@
GET
-