mirror of
https://github.com/simonw/datasette.git
synced 2026-05-29 21:26:59 +02:00
Compare commits
1 commit
main
...
datasette-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df6555ef3 |
109 changed files with 1433 additions and 12758 deletions
35
.github/workflows/deploy-branch-preview.yml
vendored
Normal file
35
.github/workflows/deploy-branch-preview.yml
vendored
Normal 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"
|
||||||
39
.github/workflows/deploy-latest.yml
vendored
39
.github/workflows/deploy-latest.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out datasette
|
- name: Check out datasette
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
db.route = "alternative-route"
|
db.route = "alternative-route"
|
||||||
' > plugins/alternative_route.py
|
' > plugins/alternative_route.py
|
||||||
cp fixtures.db fixtures2.db
|
cp fixtures.db fixtures2.db
|
||||||
- name: And the counters writable stored query demo
|
- name: And the counters writable canned query demo
|
||||||
run: |
|
run: |
|
||||||
cat > plugins/counters.py <<EOF
|
cat > plugins/counters.py <<EOF
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
|
@ -69,24 +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_a', 0)")
|
||||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
||||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
||||||
for name in ("counter_a", "counter_b", "counter_c"):
|
|
||||||
await datasette.add_query(
|
|
||||||
"counters",
|
|
||||||
"increment_{}".format(name),
|
|
||||||
"update counters set value = value + 1 where name = '{}'".format(name),
|
|
||||||
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
|
||||||
is_write=True,
|
|
||||||
is_trusted=True,
|
|
||||||
)
|
|
||||||
await datasette.add_query(
|
|
||||||
"counters",
|
|
||||||
"decrement_{}".format(name),
|
|
||||||
"update counters set value = value - 1 where name = '{}'".format(name),
|
|
||||||
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
|
||||||
is_write=True,
|
|
||||||
is_trusted=True,
|
|
||||||
)
|
|
||||||
return inner
|
return inner
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(database):
|
||||||
|
if database == "counters":
|
||||||
|
queries = {}
|
||||||
|
for name in ("counter_a", "counter_b", "counter_c"):
|
||||||
|
queries["increment_{}".format(name)] = {
|
||||||
|
"sql": "update counters set value = value + 1 where name = '{}'".format(name),
|
||||||
|
"on_success_message_sql": "select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||||
|
"write": True,
|
||||||
|
}
|
||||||
|
queries["decrement_{}".format(name)] = {
|
||||||
|
"sql": "update counters set value = value - 1 where name = '{}'".format(name),
|
||||||
|
"on_success_message_sql": "select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||||
|
"write": True,
|
||||||
|
}
|
||||||
|
return queries
|
||||||
EOF
|
EOF
|
||||||
# - name: Make some modifications to metadata.json
|
# - name: Make some modifications to metadata.json
|
||||||
# run: |
|
# run: |
|
||||||
|
|
@ -117,7 +116,7 @@ jobs:
|
||||||
--plugins-dir=plugins \
|
--plugins-dir=plugins \
|
||||||
--branch=$GITHUB_SHA \
|
--branch=$GITHUB_SHA \
|
||||||
--version-note=$GITHUB_SHA \
|
--version-note=$GITHUB_SHA \
|
||||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
|
||||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||||
--service "datasette-latest$SUFFIX" \
|
--service "datasette-latest$SUFFIX" \
|
||||||
--secret $LATEST_DATASETTE_SECRET
|
--secret $LATEST_DATASETTE_SECRET
|
||||||
|
|
|
||||||
2
.github/workflows/documentation-links.yml
vendored
2
.github/workflows/documentation-links.yml
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
name: Read the Docs Pull Request Preview
|
name: Read the Docs Pull Request Preview
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
|
||||||
|
|
|
||||||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
name: Configure npm caching
|
name: Configure npm caching
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
|
|
|
||||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -35,7 +35,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
if: "!github.event.release.prerelease"
|
if: "!github.event.release.prerelease"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -92,7 +92,7 @@ jobs:
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
if: "!github.event.release.prerelease"
|
if: "!github.event.release.prerelease"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Build and push to Docker Hub
|
- name: Build and push to Docker Hub
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
|
|
||||||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
deploy_docker:
|
deploy_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v2
|
||||||
- name: Build and push to Docker Hub
|
- name: Build and push to Docker Hub
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
|
|
||||||
2
.github/workflows/spellcheck.yml
vendored
2
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
||||||
spellcheck:
|
spellcheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/stable-docs.yml
vendored
2
.github/workflows/stable-docs.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||||
- name: Set up Git user
|
- name: Set up Git user
|
||||||
|
|
|
||||||
2
.github/workflows/test-coverage.yml
vendored
2
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out datasette
|
- name: Check out datasette
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
4
.github/workflows/test-pyodide.yml
vendored
4
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
cache-dependency-path: '**/pyproject.toml'
|
cache-dependency-path: '**/pyproject.toml'
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright/
|
path: ~/.cache/ms-playwright/
|
||||||
key: ${{ runner.os }}-browsers
|
key: ${{ runner.os }}-browsers
|
||||||
|
|
|
||||||
2
.github/workflows/test-sqlite-support.yml
vendored
2
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
#"3.23.1" # 2018-04-10, before UPSERT
|
#"3.23.1" # 2018-04-10, before UPSERT
|
||||||
]
|
]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v2
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
|
|
||||||
2
.github/workflows/tmate.yml
vendored
2
.github/workflows/tmate.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v2
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
324
datasette/app.py
324
datasette/app.py
|
|
@ -42,25 +42,8 @@ from jinja2.exceptions import TemplateNotFound
|
||||||
|
|
||||||
from .events import Event
|
from .events import Event
|
||||||
from .column_types import SQLiteType
|
from .column_types import SQLiteType
|
||||||
from . import stored_queries, write_sql
|
|
||||||
from .views import Context
|
from .views import Context
|
||||||
from .views.database import (
|
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
|
||||||
database_download,
|
|
||||||
DatabaseView,
|
|
||||||
TableCreateView,
|
|
||||||
QueryView,
|
|
||||||
)
|
|
||||||
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
|
||||||
from .views.stored_queries import (
|
|
||||||
QueryCreateAnalyzeView,
|
|
||||||
QueryDeleteView,
|
|
||||||
QueryDefinitionView,
|
|
||||||
GlobalQueryListView,
|
|
||||||
QueryListView,
|
|
||||||
QueryParametersView,
|
|
||||||
QueryStoreView,
|
|
||||||
QueryUpdateView,
|
|
||||||
)
|
|
||||||
from .views.index import IndexView
|
from .views.index import IndexView
|
||||||
from .views.special import (
|
from .views.special import (
|
||||||
JsonDataView,
|
JsonDataView,
|
||||||
|
|
@ -75,7 +58,7 @@ from .views.special import (
|
||||||
AllowedResourcesView,
|
AllowedResourcesView,
|
||||||
PermissionRulesView,
|
PermissionRulesView,
|
||||||
PermissionCheckView,
|
PermissionCheckView,
|
||||||
JumpView,
|
TablesView,
|
||||||
InstanceSchemaView,
|
InstanceSchemaView,
|
||||||
DatabaseSchemaView,
|
DatabaseSchemaView,
|
||||||
TableSchemaView,
|
TableSchemaView,
|
||||||
|
|
@ -588,9 +571,6 @@ class Datasette:
|
||||||
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
|
# TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log
|
||||||
# a warning to user that they should delete their metadata.json file
|
# a warning to user that they should delete their metadata.json file
|
||||||
|
|
||||||
async def _save_queries_from_config(self):
|
|
||||||
await stored_queries.save_queries_from_config(self)
|
|
||||||
|
|
||||||
def get_jinja_environment(self, request: Request = None) -> Environment:
|
def get_jinja_environment(self, request: Request = None) -> Environment:
|
||||||
environment = self._jinja_env
|
environment = self._jinja_env
|
||||||
if request:
|
if request:
|
||||||
|
|
@ -634,36 +614,15 @@ class Datasette:
|
||||||
"select database_name, schema_version from catalog_databases"
|
"select database_name, schema_version from catalog_databases"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
catalog_table_names = (
|
|
||||||
"catalog_columns",
|
|
||||||
"catalog_foreign_keys",
|
|
||||||
"catalog_indexes",
|
|
||||||
"catalog_views",
|
|
||||||
"catalog_tables",
|
|
||||||
"catalog_databases",
|
|
||||||
)
|
|
||||||
# Delete stale entries for databases that are no longer attached
|
# Delete stale entries for databases that are no longer attached
|
||||||
catalog_database_names = set(current_schema_versions.keys())
|
stale_databases = set(current_schema_versions.keys()) - set(
|
||||||
for table in catalog_table_names[:-1]:
|
self.databases.keys()
|
||||||
catalog_database_names.update(
|
)
|
||||||
row["database_name"]
|
for stale_db_name in stale_databases:
|
||||||
for row in await internal_db.execute(
|
await internal_db.execute_write(
|
||||||
"select distinct database_name from {}".format(table)
|
"DELETE FROM catalog_databases WHERE database_name = ?",
|
||||||
)
|
[stale_db_name],
|
||||||
if row["database_name"] is not None
|
|
||||||
)
|
)
|
||||||
stale_databases = catalog_database_names - set(self.databases.keys())
|
|
||||||
if stale_databases:
|
|
||||||
|
|
||||||
def delete_stale_database_catalog(conn):
|
|
||||||
for stale_db_name in stale_databases:
|
|
||||||
for table in catalog_table_names:
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM {} WHERE database_name = ?".format(table),
|
|
||||||
[stale_db_name],
|
|
||||||
)
|
|
||||||
|
|
||||||
await internal_db.execute_write_fn(delete_stale_database_catalog)
|
|
||||||
for database_name, db in self.databases.items():
|
for database_name, db in self.databases.items():
|
||||||
schema_version = (await db.execute("PRAGMA schema_version")).first()[0]
|
schema_version = (await db.execute("PRAGMA schema_version")).first()[0]
|
||||||
# Compare schema versions to see if we should skip it
|
# Compare schema versions to see if we should skip it
|
||||||
|
|
@ -751,7 +710,6 @@ class Datasette:
|
||||||
await await_me_maybe(hook)
|
await await_me_maybe(hook)
|
||||||
# Ensure internal tables and metadata are populated before startup hooks
|
# Ensure internal tables and metadata are populated before startup hooks
|
||||||
await self._refresh_schemas()
|
await self._refresh_schemas()
|
||||||
await self._save_queries_from_config()
|
|
||||||
# Load column_types from config into internal DB
|
# Load column_types from config into internal DB
|
||||||
await self._apply_column_types_config()
|
await self._apply_column_types_config()
|
||||||
for hook in pm.hook.startup(datasette=self):
|
for hook in pm.hook.startup(datasette=self):
|
||||||
|
|
@ -1028,180 +986,6 @@ class Datasette:
|
||||||
[database_name, resource_name, column_name, key, value],
|
[database_name, resource_name, column_name, key, value],
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None:
|
|
||||||
return stored_queries.query_row_to_stored_query(row)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _query_options_json(options):
|
|
||||||
return stored_queries.query_options_json(options)
|
|
||||||
|
|
||||||
async def add_query(
|
|
||||||
self,
|
|
||||||
database: str,
|
|
||||||
name: str,
|
|
||||||
sql: str,
|
|
||||||
*,
|
|
||||||
title: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
description_html: str | None = None,
|
|
||||||
hide_sql: bool = False,
|
|
||||||
fragment: str | None = None,
|
|
||||||
parameters: Iterable[str] | None = None,
|
|
||||||
is_write: bool = False,
|
|
||||||
is_private: bool = False,
|
|
||||||
is_trusted: bool = False,
|
|
||||||
source: str = "plugin",
|
|
||||||
owner_id: str | None = None,
|
|
||||||
on_success_message: str | None = None,
|
|
||||||
on_success_message_sql: str | None = None,
|
|
||||||
on_success_redirect: str | None = None,
|
|
||||||
on_error_message: str | None = None,
|
|
||||||
on_error_redirect: str | None = None,
|
|
||||||
replace: bool = True,
|
|
||||||
) -> None:
|
|
||||||
return await stored_queries.add_query(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
name,
|
|
||||||
sql,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
description_html=description_html,
|
|
||||||
hide_sql=hide_sql,
|
|
||||||
fragment=fragment,
|
|
||||||
parameters=parameters,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
on_success_message=on_success_message,
|
|
||||||
on_success_message_sql=on_success_message_sql,
|
|
||||||
on_success_redirect=on_success_redirect,
|
|
||||||
on_error_message=on_error_message,
|
|
||||||
on_error_redirect=on_error_redirect,
|
|
||||||
replace=replace,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update_query(
|
|
||||||
self,
|
|
||||||
database: str,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
sql=stored_queries.UNCHANGED,
|
|
||||||
title=stored_queries.UNCHANGED,
|
|
||||||
description=stored_queries.UNCHANGED,
|
|
||||||
description_html=stored_queries.UNCHANGED,
|
|
||||||
hide_sql=stored_queries.UNCHANGED,
|
|
||||||
fragment=stored_queries.UNCHANGED,
|
|
||||||
parameters=stored_queries.UNCHANGED,
|
|
||||||
is_write=stored_queries.UNCHANGED,
|
|
||||||
is_private=stored_queries.UNCHANGED,
|
|
||||||
is_trusted=stored_queries.UNCHANGED,
|
|
||||||
source=stored_queries.UNCHANGED,
|
|
||||||
owner_id=stored_queries.UNCHANGED,
|
|
||||||
on_success_message=stored_queries.UNCHANGED,
|
|
||||||
on_success_message_sql=stored_queries.UNCHANGED,
|
|
||||||
on_success_redirect=stored_queries.UNCHANGED,
|
|
||||||
on_error_message=stored_queries.UNCHANGED,
|
|
||||||
on_error_redirect=stored_queries.UNCHANGED,
|
|
||||||
) -> None:
|
|
||||||
return await stored_queries.update_query(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
name,
|
|
||||||
sql=sql,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
description_html=description_html,
|
|
||||||
hide_sql=hide_sql,
|
|
||||||
fragment=fragment,
|
|
||||||
parameters=parameters,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
on_success_message=on_success_message,
|
|
||||||
on_success_message_sql=on_success_message_sql,
|
|
||||||
on_success_redirect=on_success_redirect,
|
|
||||||
on_error_message=on_error_message,
|
|
||||||
on_error_redirect=on_error_redirect,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def remove_query(
|
|
||||||
self, database: str, name: str, source: str | None = None
|
|
||||||
) -> None:
|
|
||||||
return await stored_queries.remove_query(self, database, name, source=source)
|
|
||||||
|
|
||||||
async def get_query(
|
|
||||||
self, database: str, name: str
|
|
||||||
) -> stored_queries.StoredQuery | None:
|
|
||||||
return await stored_queries.get_query(self, database, name)
|
|
||||||
|
|
||||||
async def count_queries(
|
|
||||||
self,
|
|
||||||
database: str | None = None,
|
|
||||||
*,
|
|
||||||
actor: dict[str, Any] | None = None,
|
|
||||||
q: str | None = None,
|
|
||||||
is_write: bool | None = None,
|
|
||||||
is_private: bool | None = None,
|
|
||||||
is_trusted: bool | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
owner_id: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
return await stored_queries.count_queries(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
actor=actor,
|
|
||||||
q=q,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def list_queries(
|
|
||||||
self,
|
|
||||||
database: str | None = None,
|
|
||||||
*,
|
|
||||||
actor: dict[str, Any] | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
cursor: str | None = None,
|
|
||||||
q: str | None = None,
|
|
||||||
is_write: bool | None = None,
|
|
||||||
is_private: bool | None = None,
|
|
||||||
is_trusted: bool | None = None,
|
|
||||||
source: str | None = None,
|
|
||||||
owner_id: str | None = None,
|
|
||||||
include_private: bool = False,
|
|
||||||
) -> stored_queries.StoredQueryPage:
|
|
||||||
return await stored_queries.list_queries(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
actor=actor,
|
|
||||||
limit=limit,
|
|
||||||
cursor=cursor,
|
|
||||||
q=q,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
is_trusted=is_trusted,
|
|
||||||
source=source,
|
|
||||||
owner_id=owner_id,
|
|
||||||
include_private=include_private,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def ensure_query_write_permissions(
|
|
||||||
self, database, sql, *, actor=None, params=None, analysis=None
|
|
||||||
):
|
|
||||||
# Raise Forbidden or QueryWriteRejected if SQL should not run
|
|
||||||
return await write_sql.ensure_query_write_permissions(
|
|
||||||
self, database, sql, actor=actor, params=params, analysis=analysis
|
|
||||||
)
|
|
||||||
|
|
||||||
# Column types API
|
# Column types API
|
||||||
|
|
||||||
async def _get_resource_column_details(self, database: str, resource: str):
|
async def _get_resource_column_details(self, database: str, resource: str):
|
||||||
|
|
@ -1414,24 +1198,36 @@ class Datasette:
|
||||||
|
|
||||||
return db_plugin_config
|
return db_plugin_config
|
||||||
|
|
||||||
def static_hash(self, filename):
|
|
||||||
if not hasattr(self, "_static_hashes"):
|
|
||||||
self._static_hashes = {}
|
|
||||||
path = os.path.join(str(app_root), "datasette/static", filename)
|
|
||||||
signature = (os.path.getmtime(path), os.path.getsize(path))
|
|
||||||
cached = self._static_hashes.get(filename)
|
|
||||||
if cached and cached["signature"] == signature:
|
|
||||||
return cached["hash"]
|
|
||||||
with open(path) as fp:
|
|
||||||
static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6]
|
|
||||||
self._static_hashes[filename] = {
|
|
||||||
"signature": signature,
|
|
||||||
"hash": static_hash,
|
|
||||||
}
|
|
||||||
return static_hash
|
|
||||||
|
|
||||||
def app_css_hash(self):
|
def app_css_hash(self):
|
||||||
return self.static_hash("app.css")
|
if not hasattr(self, "_app_css_hash"):
|
||||||
|
with open(os.path.join(str(app_root), "datasette/static/app.css")) as fp:
|
||||||
|
self._app_css_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[
|
||||||
|
:6
|
||||||
|
]
|
||||||
|
return self._app_css_hash
|
||||||
|
|
||||||
|
async def get_canned_queries(self, database_name, actor):
|
||||||
|
queries = {}
|
||||||
|
for more_queries in pm.hook.canned_queries(
|
||||||
|
datasette=self,
|
||||||
|
database=database_name,
|
||||||
|
actor=actor,
|
||||||
|
):
|
||||||
|
more_queries = await await_me_maybe(more_queries)
|
||||||
|
queries.update(more_queries or {})
|
||||||
|
# Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
|
||||||
|
for key in queries:
|
||||||
|
if not isinstance(queries[key], dict):
|
||||||
|
queries[key] = {"sql": queries[key]}
|
||||||
|
# Also make sure "name" is available:
|
||||||
|
queries[key]["name"] = key
|
||||||
|
return queries
|
||||||
|
|
||||||
|
async def get_canned_query(self, database_name, query_name, actor):
|
||||||
|
queries = await self.get_canned_queries(database_name, actor)
|
||||||
|
query = queries.get(query_name)
|
||||||
|
if query:
|
||||||
|
return query
|
||||||
|
|
||||||
def _prepare_connection(self, conn, database):
|
def _prepare_connection(self, conn, database):
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
@ -2405,12 +2201,8 @@ class Datasette:
|
||||||
r"/-/api$",
|
r"/-/api$",
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
JumpView.as_view(self),
|
TablesView.as_view(self),
|
||||||
r"/-/jump(\.(?P<format>json))?$",
|
r"/-/tables(\.(?P<format>json))?$",
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
GlobalQueryListView.as_view(self),
|
|
||||||
r"/-/queries(\.(?P<format>json))?$",
|
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
InstanceSchemaView.as_view(self),
|
InstanceSchemaView.as_view(self),
|
||||||
|
|
@ -2457,50 +2249,14 @@ class Datasette:
|
||||||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||||
)
|
)
|
||||||
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
||||||
add_route(
|
|
||||||
QueryListView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryCreateAnalyzeView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/queries/analyze$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryStoreView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/queries/store$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
ExecuteWriteAnalyzeView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/execute-write/analyze$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
ExecuteWriteView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/execute-write$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
DatabaseSchemaView.as_view(self),
|
DatabaseSchemaView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
QueryParametersView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(QueryView, self),
|
wrap_view(QueryView, self),
|
||||||
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
||||||
)
|
)
|
||||||
add_route(
|
|
||||||
QueryDefinitionView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryUpdateView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
|
|
||||||
)
|
|
||||||
add_route(
|
|
||||||
QueryDeleteView.as_view(self),
|
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
|
|
||||||
)
|
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(table_view, self),
|
wrap_view(table_view, self),
|
||||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ from .app import (
|
||||||
SQLITE_LIMIT_ATTACHED,
|
SQLITE_LIMIT_ATTACHED,
|
||||||
pm,
|
pm,
|
||||||
)
|
)
|
||||||
from .inspect import inspect_tables
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
LoadExtension,
|
LoadExtension,
|
||||||
StartupError,
|
StartupError,
|
||||||
|
|
@ -155,14 +154,14 @@ async def inspect_(files, sqlite_extensions):
|
||||||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||||
data = {}
|
data = {}
|
||||||
for name, database in app.databases.items():
|
for name, database in app.databases.items():
|
||||||
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
|
counts = await database.table_counts(limit=3600 * 1000)
|
||||||
data[name] = {
|
data[name] = {
|
||||||
"hash": database.hash,
|
"hash": database.hash,
|
||||||
"size": database.size,
|
"size": database.size,
|
||||||
"file": database.path,
|
"file": database.path,
|
||||||
"tables": {
|
"tables": {
|
||||||
table_name: {"count": table["count"]}
|
table_name: {"count": table_count}
|
||||||
for table_name, table in tables.items()
|
for table_name, table_count in counts.items()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from collections import namedtuple
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import janus
|
||||||
import queue
|
import queue
|
||||||
import sqlite_utils
|
import sqlite_utils
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -25,8 +26,7 @@ from .utils import (
|
||||||
table_columns,
|
table_columns,
|
||||||
table_column_details,
|
table_column_details,
|
||||||
)
|
)
|
||||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
from .utils.sqlite import sqlite_version
|
||||||
from .utils.sqlite import sqlite_hidden_table_names
|
|
||||||
from .inspect import inspect_hash
|
from .inspect import inspect_hash
|
||||||
|
|
||||||
connections = threading.local()
|
connections = threading.local()
|
||||||
|
|
@ -84,8 +84,6 @@ class Database:
|
||||||
self._write_thread = None
|
self._write_thread = None
|
||||||
self._write_queue = None
|
self._write_queue = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._pending_execute_futures = set()
|
|
||||||
self._pending_execute_futures_lock = threading.Lock()
|
|
||||||
# These are used when in non-threaded mode:
|
# These are used when in non-threaded mode:
|
||||||
self._read_connection = None
|
self._read_connection = None
|
||||||
self._write_connection = None
|
self._write_connection = None
|
||||||
|
|
@ -100,10 +98,6 @@ class Database:
|
||||||
"Database {!r} has been closed".format(self.name)
|
"Database {!r} has been closed".format(self.name)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _remove_pending_execute_future(self, future):
|
|
||||||
with self._pending_execute_futures_lock:
|
|
||||||
self._pending_execute_futures.discard(future)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cached_table_counts(self):
|
def cached_table_counts(self):
|
||||||
if self._cached_table_counts is not None:
|
if self._cached_table_counts is not None:
|
||||||
|
|
@ -176,11 +170,7 @@ class Database:
|
||||||
"""
|
"""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
with self._pending_execute_futures_lock:
|
self._closed = True
|
||||||
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
|
# Shut down the write thread, if any, via a sentinel. The thread
|
||||||
# drains any writes already queued before the sentinel and then
|
# drains any writes already queued before the sentinel and then
|
||||||
# closes its own write connection and returns.
|
# closes its own write connection and returns.
|
||||||
|
|
@ -195,11 +185,6 @@ class Database:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
for future in pending_execute_futures:
|
|
||||||
try:
|
|
||||||
future.result()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Close anything still tracked in _all_file_connections
|
# Close anything still tracked in _all_file_connections
|
||||||
for connection in self._all_file_connections:
|
for connection in self._all_file_connections:
|
||||||
try:
|
try:
|
||||||
|
|
@ -302,13 +287,6 @@ class Database:
|
||||||
# Threaded mode - send to write thread
|
# Threaded mode - send to write thread
|
||||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||||
|
|
||||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
|
||||||
self._check_not_closed()
|
|
||||||
|
|
||||||
return await self.execute_isolated_fn(
|
|
||||||
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
pending_events = []
|
pending_events = []
|
||||||
|
|
@ -337,16 +315,13 @@ class Database:
|
||||||
else:
|
else:
|
||||||
# For non-blocking writes, spawn a background task to
|
# For non-blocking writes, spawn a background task to
|
||||||
# dispatch events after the write thread completes
|
# dispatch events after the write thread completes
|
||||||
task_id, reply_future = result
|
task_id, reply_queue = result
|
||||||
|
|
||||||
async def _dispatch_events_after_write():
|
async def _dispatch_events_after_write():
|
||||||
try:
|
write_result = await reply_queue.async_q.get()
|
||||||
await reply_future
|
if not isinstance(write_result, Exception):
|
||||||
except Exception:
|
for event in pending_events:
|
||||||
# if the write failed, don't emit success events
|
await self.ds.track_event(event)
|
||||||
return
|
|
||||||
for event in pending_events:
|
|
||||||
await self.ds.track_event(event)
|
|
||||||
|
|
||||||
asyncio.ensure_future(_dispatch_events_after_write())
|
asyncio.ensure_future(_dispatch_events_after_write())
|
||||||
result = task_id
|
result = task_id
|
||||||
|
|
@ -400,15 +375,18 @@ class Database:
|
||||||
)
|
)
|
||||||
self._write_thread.start()
|
self._write_thread.start()
|
||||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||||
loop = asyncio.get_running_loop()
|
reply_queue = janus.Queue()
|
||||||
reply_future = loop.create_future()
|
|
||||||
self._write_queue.put(
|
self._write_queue.put(
|
||||||
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
|
WriteTask(fn, task_id, reply_queue, isolated_connection, transaction)
|
||||||
)
|
)
|
||||||
if block:
|
if block:
|
||||||
return await reply_future
|
result = await reply_queue.async_q.get()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
else:
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
return task_id, reply_future
|
return task_id, reply_queue
|
||||||
|
|
||||||
def _execute_writes(self):
|
def _execute_writes(self):
|
||||||
# Infinite looping thread that protects the single write connection
|
# Infinite looping thread that protects the single write connection
|
||||||
|
|
@ -429,37 +407,36 @@ class Database:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
exception = None
|
|
||||||
result = None
|
|
||||||
if conn_exception is not None:
|
if conn_exception is not None:
|
||||||
exception = conn_exception
|
result = conn_exception
|
||||||
elif task.isolated_connection:
|
|
||||||
isolated_connection = self.connect(write=True)
|
|
||||||
try:
|
|
||||||
result = task.fn(isolated_connection)
|
|
||||||
except Exception as e:
|
|
||||||
sys.stderr.write("{}\n".format(e))
|
|
||||||
sys.stderr.flush()
|
|
||||||
exception = e
|
|
||||||
finally:
|
|
||||||
isolated_connection.close()
|
|
||||||
try:
|
|
||||||
self._all_file_connections.remove(isolated_connection)
|
|
||||||
except ValueError:
|
|
||||||
# Was probably a memory connection
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
try:
|
if task.isolated_connection:
|
||||||
if task.transaction:
|
isolated_connection = self.connect(write=True)
|
||||||
with conn:
|
try:
|
||||||
|
result = task.fn(isolated_connection)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("{}\n".format(e))
|
||||||
|
sys.stderr.flush()
|
||||||
|
result = e
|
||||||
|
finally:
|
||||||
|
isolated_connection.close()
|
||||||
|
try:
|
||||||
|
self._all_file_connections.remove(isolated_connection)
|
||||||
|
except ValueError:
|
||||||
|
# Was probably a memory connection
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if task.transaction:
|
||||||
|
with conn:
|
||||||
|
result = task.fn(conn)
|
||||||
|
else:
|
||||||
result = task.fn(conn)
|
result = task.fn(conn)
|
||||||
else:
|
except Exception as e:
|
||||||
result = task.fn(conn)
|
sys.stderr.write("{}\n".format(e))
|
||||||
except Exception as e:
|
sys.stderr.flush()
|
||||||
sys.stderr.write("{}\n".format(e))
|
result = e
|
||||||
sys.stderr.flush()
|
task.reply_queue.sync_q.put(result)
|
||||||
exception = e
|
|
||||||
_deliver_write_result(task, result, exception)
|
|
||||||
|
|
||||||
async def execute_fn(self, fn):
|
async def execute_fn(self, fn):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
|
|
@ -479,12 +456,9 @@ class Database:
|
||||||
setattr(connections, self._thread_local_id, conn)
|
setattr(connections, self._thread_local_id, conn)
|
||||||
return fn(conn)
|
return fn(conn)
|
||||||
|
|
||||||
with self._pending_execute_futures_lock:
|
return await asyncio.get_event_loop().run_in_executor(
|
||||||
self._check_not_closed()
|
self.ds.executor, in_thread
|
||||||
future = self.ds.executor.submit(in_thread)
|
)
|
||||||
self._pending_execute_futures.add(future)
|
|
||||||
future.add_done_callback(self._remove_pending_execute_future)
|
|
||||||
return await asyncio.wrap_future(future)
|
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
|
|
@ -702,7 +676,83 @@ class Database:
|
||||||
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
||||||
]
|
]
|
||||||
|
|
||||||
hidden_tables += await self.execute_fn(sqlite_hidden_table_names)
|
if sqlite_version()[1] >= 37:
|
||||||
|
hidden_tables += [x[0] for x in await self.execute("""
|
||||||
|
with shadow_tables as (
|
||||||
|
select name
|
||||||
|
from pragma_table_list
|
||||||
|
where [type] = 'shadow'
|
||||||
|
order by name
|
||||||
|
),
|
||||||
|
core_tables as (
|
||||||
|
select name
|
||||||
|
from sqlite_master
|
||||||
|
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||||
|
OR substr(name, 1, 1) == '_'
|
||||||
|
),
|
||||||
|
combined as (
|
||||||
|
select name from shadow_tables
|
||||||
|
union all
|
||||||
|
select name from core_tables
|
||||||
|
)
|
||||||
|
select name from combined order by 1
|
||||||
|
""")]
|
||||||
|
else:
|
||||||
|
hidden_tables += [x[0] for x in await self.execute("""
|
||||||
|
WITH base AS (
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||||
|
OR substr(name, 1, 1) == '_'
|
||||||
|
),
|
||||||
|
fts_suffixes AS (
|
||||||
|
SELECT column1 AS suffix
|
||||||
|
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
|
||||||
|
),
|
||||||
|
fts5_names AS (
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
||||||
|
),
|
||||||
|
fts5_shadow_tables AS (
|
||||||
|
SELECT
|
||||||
|
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
|
||||||
|
FROM fts5_names
|
||||||
|
JOIN fts_suffixes
|
||||||
|
),
|
||||||
|
fts3_suffixes AS (
|
||||||
|
SELECT column1 AS suffix
|
||||||
|
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
|
||||||
|
),
|
||||||
|
fts3_names AS (
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
|
||||||
|
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
|
||||||
|
),
|
||||||
|
fts3_shadow_tables AS (
|
||||||
|
SELECT
|
||||||
|
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
|
||||||
|
FROM fts3_names
|
||||||
|
JOIN fts3_suffixes
|
||||||
|
),
|
||||||
|
final AS (
|
||||||
|
SELECT name FROM base
|
||||||
|
UNION ALL
|
||||||
|
SELECT name FROM fts5_shadow_tables
|
||||||
|
UNION ALL
|
||||||
|
SELECT name FROM fts3_shadow_tables
|
||||||
|
)
|
||||||
|
SELECT name FROM final ORDER BY 1
|
||||||
|
""")]
|
||||||
|
# Also hide any FTS tables that have a content= argument
|
||||||
|
hidden_tables += [x[0] for x in await self.execute("""
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE sql LIKE '%VIRTUAL TABLE%'
|
||||||
|
AND sql LIKE '%USING FTS%'
|
||||||
|
AND sql LIKE '%content=%'
|
||||||
|
""")]
|
||||||
|
|
||||||
has_spatialite = await self.execute_fn(detect_spatialite)
|
has_spatialite = await self.execute_fn(detect_spatialite)
|
||||||
if has_spatialite:
|
if has_spatialite:
|
||||||
|
|
@ -824,45 +874,16 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||||
|
|
||||||
|
|
||||||
class WriteTask:
|
class WriteTask:
|
||||||
__slots__ = (
|
__slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction")
|
||||||
"fn",
|
|
||||||
"task_id",
|
|
||||||
"loop",
|
|
||||||
"reply_future",
|
|
||||||
"isolated_connection",
|
|
||||||
"transaction",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):
|
||||||
self, fn, task_id, loop, reply_future, isolated_connection, transaction
|
|
||||||
):
|
|
||||||
self.fn = fn
|
self.fn = fn
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self.loop = loop
|
self.reply_queue = reply_queue
|
||||||
self.reply_future = reply_future
|
|
||||||
self.isolated_connection = isolated_connection
|
self.isolated_connection = isolated_connection
|
||||||
self.transaction = transaction
|
self.transaction = transaction
|
||||||
|
|
||||||
|
|
||||||
def _deliver_write_result(task, result, exception):
|
|
||||||
# Called from the write thread. Delivers the result back to the
|
|
||||||
# awaiting coroutine on its event loop via call_soon_threadsafe.
|
|
||||||
def _set():
|
|
||||||
if task.reply_future.done():
|
|
||||||
# Awaiter was cancelled; nothing to do.
|
|
||||||
return
|
|
||||||
if exception is not None:
|
|
||||||
task.reply_future.set_exception(exception)
|
|
||||||
else:
|
|
||||||
task.reply_future.set_result(result)
|
|
||||||
|
|
||||||
try:
|
|
||||||
task.loop.call_soon_threadsafe(_set)
|
|
||||||
except RuntimeError:
|
|
||||||
# Event loop has been closed; the awaiter is gone.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class QueryInterrupted(Exception):
|
class QueryInterrupted(Exception):
|
||||||
def __init__(self, e, sql, params):
|
def __init__(self, e, sql, params):
|
||||||
self.e = e
|
self.e = e
|
||||||
|
|
|
||||||
|
|
@ -48,26 +48,12 @@ def register_actions():
|
||||||
resource_class=DatabaseResource,
|
resource_class=DatabaseResource,
|
||||||
also_requires="view-database",
|
also_requires="view-database",
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="execute-write-sql",
|
|
||||||
abbr="ews",
|
|
||||||
description="Execute writable SQL queries",
|
|
||||||
resource_class=DatabaseResource,
|
|
||||||
also_requires="view-database",
|
|
||||||
),
|
|
||||||
Action(
|
Action(
|
||||||
name="create-table",
|
name="create-table",
|
||||||
abbr="ct",
|
abbr="ct",
|
||||||
description="Create tables",
|
description="Create tables",
|
||||||
resource_class=DatabaseResource,
|
resource_class=DatabaseResource,
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="store-query",
|
|
||||||
abbr="sq",
|
|
||||||
description="Create stored queries",
|
|
||||||
resource_class=DatabaseResource,
|
|
||||||
also_requires="execute-sql",
|
|
||||||
),
|
|
||||||
# Table-level actions (child-level)
|
# Table-level actions (child-level)
|
||||||
Action(
|
Action(
|
||||||
name="view-table",
|
name="view-table",
|
||||||
|
|
@ -118,16 +104,4 @@ def register_actions():
|
||||||
description="View named query results",
|
description="View named query results",
|
||||||
resource_class=QueryResource,
|
resource_class=QueryResource,
|
||||||
),
|
),
|
||||||
Action(
|
|
||||||
name="update-query",
|
|
||||||
abbr="uq",
|
|
||||||
description="Update stored queries",
|
|
||||||
resource_class=QueryResource,
|
|
||||||
),
|
|
||||||
Action(
|
|
||||||
name="delete-query",
|
|
||||||
abbr="dq",
|
|
||||||
description="Delete stored queries",
|
|
||||||
resource_class=QueryResource,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.jump import JumpSQL
|
|
||||||
|
|
||||||
DEBUG_MENU_ITEMS = (
|
|
||||||
(
|
|
||||||
"/-/databases",
|
|
||||||
"Databases",
|
|
||||||
"List of databases known to this Datasette instance.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/plugins",
|
|
||||||
"Installed plugins",
|
|
||||||
"Review loaded plugins, their versions and their registered hooks.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/versions",
|
|
||||||
"Version info",
|
|
||||||
"Check the Python, SQLite and dependency versions used by this server.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/settings",
|
|
||||||
"Settings",
|
|
||||||
"Inspect the active Datasette settings and configuration values.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/permissions",
|
|
||||||
"Debug permissions",
|
|
||||||
"Test permission checks for actors, actions and resources.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/messages",
|
|
||||||
"Debug messages",
|
|
||||||
"Try out temporary flash messages shown to users.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/allow-debug",
|
|
||||||
"Debug allow rules",
|
|
||||||
"Explore how allow blocks match actors against permission rules.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/threads",
|
|
||||||
"Debug threads",
|
|
||||||
"Inspect worker threads and database tasks.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/actor",
|
|
||||||
"Debug actor",
|
|
||||||
"View the actor object for the current signed-in user.",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/-/patterns",
|
|
||||||
"Pattern portfolio",
|
|
||||||
"Browse Datasette UI patterns.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def jump_items_sql(datasette, actor, request):
|
|
||||||
async def inner():
|
|
||||||
if not await datasette.allowed(action="debug-menu", actor=actor):
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
JumpSQL.menu_item(
|
|
||||||
label=label,
|
|
||||||
url=datasette.urls.path(path),
|
|
||||||
description=description,
|
|
||||||
search_text=f"debug {label} {description}",
|
|
||||||
item_type="debug",
|
|
||||||
)
|
|
||||||
for path, label, description in DEBUG_MENU_ITEMS
|
|
||||||
]
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
@ -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
|
|
||||||
41
datasette/default_menu_links.py
Normal file
41
datasette/default_menu_links.py
Normal 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
|
||||||
|
|
@ -17,6 +17,13 @@ UNION/INTERSECT operations. The order of evaluation is:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datasette.app import Datasette
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
# Re-export all hooks and public utilities
|
# Re-export all hooks and public utilities
|
||||||
from .restrictions import (
|
from .restrictions import (
|
||||||
actor_restrictions_sql as actor_restrictions_sql,
|
actor_restrictions_sql as actor_restrictions_sql,
|
||||||
|
|
@ -26,9 +33,16 @@ from .restrictions import (
|
||||||
from .root import root_user_permissions_sql as root_user_permissions_sql
|
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||||
from .config import config_permissions_sql as config_permissions_sql
|
from .config import config_permissions_sql as config_permissions_sql
|
||||||
from .defaults import (
|
from .defaults import (
|
||||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
|
||||||
default_allow_sql_check as default_allow_sql_check,
|
default_allow_sql_check as default_allow_sql_check,
|
||||||
default_action_permissions_sql as default_action_permissions_sql,
|
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,
|
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(datasette: "Datasette", database: str, actor) -> dict:
|
||||||
|
"""Return canned queries defined in datasette.yaml configuration."""
|
||||||
|
queries = (
|
||||||
|
((datasette.config or {}).get("databases") or {}).get(database) or {}
|
||||||
|
).get("queries") or {}
|
||||||
|
return queries
|
||||||
|
|
|
||||||
|
|
@ -67,48 +67,3 @@ async def default_action_permissions_sql(
|
||||||
return PermissionSQL.allow(reason=reason)
|
return PermissionSQL.allow(reason=reason)
|
||||||
|
|
||||||
return None
|
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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ class Facet:
|
||||||
self.ds = ds
|
self.ds = ds
|
||||||
self.request = request
|
self.request = request
|
||||||
self.database = database
|
self.database = database
|
||||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
# For foreign key expansion. Can be None for e.g. canned SQL queries:
|
||||||
self.table = table
|
self.table = table
|
||||||
self.sql = sql or f"select * from [{table}]"
|
self.sql = sql or f"select * from [{table}]"
|
||||||
self.params = params or []
|
self.params = params or []
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -137,6 +137,11 @@ def permission_resources_sql(datasette, actor, action):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def canned_queries(datasette, database, actor):
|
||||||
|
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def register_magic_parameters(datasette):
|
def register_magic_parameters(datasette):
|
||||||
"""Return a list of (name, function) magic parameter functions"""
|
"""Return a list of (name, function) magic parameter functions"""
|
||||||
|
|
@ -152,11 +157,6 @@ def menu_links(datasette, actor, request):
|
||||||
"""Links for the navigation menu"""
|
"""Links for the navigation menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
|
||||||
def jump_items_sql(datasette, actor, request):
|
|
||||||
"""SQL fragments for extra items in the jump menu"""
|
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def row_actions(datasette, actor, request, database, table, row):
|
def row_actions(datasette, actor, request, database, table, row):
|
||||||
"""Links for the row actions menu"""
|
"""Links for the row actions menu"""
|
||||||
|
|
@ -174,7 +174,7 @@ def view_actions(datasette, actor, database, view, request):
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||||
"""Links for the query and stored query actions menu"""
|
"""Links for the query and canned query actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
|
|
@ -228,8 +228,8 @@ def top_query(datasette, request, database, sql):
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def top_stored_query(datasette, request, database, query_name):
|
def top_canned_query(datasette, request, database, query_name):
|
||||||
"""HTML to include at the top of the stored query page"""
|
"""HTML to include at the top of the canned query page"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -58,16 +58,6 @@ class Resource(ABC):
|
||||||
self.child = child
|
self.child = child
|
||||||
self._private = None # Sentinel to track if private was set
|
self._private = None # Sentinel to track if private was set
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return "/".join(
|
|
||||||
str(part) for part in (self.parent, self.child) if part is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "{}(parent={!r}, child={!r})".format(
|
|
||||||
self.__class__.__name__, self.parent, self.child
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def private(self) -> bool:
|
def private(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,7 @@ DEFAULT_PLUGINS = (
|
||||||
"datasette.default_column_types",
|
"datasette.default_column_types",
|
||||||
"datasette.default_magic_parameters",
|
"datasette.default_magic_parameters",
|
||||||
"datasette.blob_renderer",
|
"datasette.blob_renderer",
|
||||||
"datasette.default_debug_menu",
|
"datasette.default_menu_links",
|
||||||
"datasette.default_jump_items",
|
|
||||||
"datasette.default_database_actions",
|
|
||||||
"datasette.handle_exception",
|
"datasette.handle_exception",
|
||||||
"datasette.forbidden",
|
"datasette.forbidden",
|
||||||
"datasette.events",
|
"datasette.events",
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class TableResource(Resource):
|
||||||
|
|
||||||
|
|
||||||
class QueryResource(Resource):
|
class QueryResource(Resource):
|
||||||
"""A stored query in a database."""
|
"""A canned query in a database."""
|
||||||
|
|
||||||
name = "query"
|
name = "query"
|
||||||
parent_class = DatabaseResource
|
parent_class = DatabaseResource
|
||||||
|
|
@ -51,8 +51,42 @@ class QueryResource(Resource):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||||
return """
|
from datasette.plugins import pm
|
||||||
SELECT q.database_name AS parent, q.name AS child
|
from datasette.utils import await_me_maybe
|
||||||
FROM queries q
|
|
||||||
JOIN catalog_databases cd ON cd.database_name = q.database_name
|
# Get all databases from catalog
|
||||||
"""
|
db = datasette.get_internal_database()
|
||||||
|
result = await db.execute("SELECT database_name FROM catalog_databases")
|
||||||
|
databases = [row[0] for row in result.rows]
|
||||||
|
|
||||||
|
# Gather canned queries for this actor from all databases.
|
||||||
|
# This keeps allowed_resources("view-query", actor=...) consistent with
|
||||||
|
# actor-specific canned_queries() implementations.
|
||||||
|
query_pairs = []
|
||||||
|
for database_name in databases:
|
||||||
|
# Call the hook to get queries (including from config via default plugin)
|
||||||
|
for queries_result in pm.hook.canned_queries(
|
||||||
|
datasette=datasette,
|
||||||
|
database=database_name,
|
||||||
|
actor=actor,
|
||||||
|
):
|
||||||
|
queries = await await_me_maybe(queries_result)
|
||||||
|
if queries:
|
||||||
|
for query_name in queries.keys():
|
||||||
|
query_pairs.append((database_name, query_name))
|
||||||
|
|
||||||
|
# Build SQL
|
||||||
|
if not query_pairs:
|
||||||
|
return "SELECT NULL AS parent, NULL AS child WHERE 0"
|
||||||
|
|
||||||
|
# Generate UNION ALL query
|
||||||
|
selects = []
|
||||||
|
for db_name, query_name in query_pairs:
|
||||||
|
# Escape single quotes by doubling them
|
||||||
|
db_escaped = db_name.replace("'", "''")
|
||||||
|
query_escaped = query_name.replace("'", "''")
|
||||||
|
selects.append(
|
||||||
|
f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child"
|
||||||
|
)
|
||||||
|
|
||||||
|
return " UNION ALL ".join(selects)
|
||||||
|
|
|
||||||
|
|
@ -362,32 +362,6 @@ form.nav-menu-logout {
|
||||||
.nav-menu-inner a {
|
.nav-menu-inner a {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.nav-menu-inner button.button-as-link {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.nav-menu-inner .keyboard-shortcut {
|
|
||||||
float: right;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-width: 1.4em;
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
padding: 0 0.35em;
|
|
||||||
border: 1px solid rgba(255,255,244,0.6);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: rgba(255,255,244,0.12);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.nav-menu-inner .keyboard-shortcut {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table/database actions menu */
|
/* Table/database actions menu */
|
||||||
.page-action-menu {
|
.page-action-menu {
|
||||||
|
|
@ -844,8 +818,7 @@ dialog.mobile-column-actions-dialog::backdrop {
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-column-actions-dialog .list-wrap {
|
.mobile-column-actions-dialog .list-wrap {
|
||||||
flex: 1 1 auto;
|
flex: 1;
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -1409,15 +1382,11 @@ svg.dropdown-menu-icon {
|
||||||
border-bottom: 5px solid #666;
|
border-bottom: 5px solid #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stored-query-edit-sql {
|
.canned-query-edit-sql {
|
||||||
padding-left: 0.5em;
|
padding-left: 0.5em;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 1px;
|
top: 1px;
|
||||||
}
|
}
|
||||||
.save-query {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.45em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blob-download {
|
.blob-download {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
||||||
|
|
@ -82,19 +82,6 @@ const datasetteManager = {
|
||||||
return columnActions;
|
return columnActions;
|
||||||
},
|
},
|
||||||
|
|
||||||
makeJumpSections: (context) => {
|
|
||||||
let jumpSections = [];
|
|
||||||
|
|
||||||
datasetteManager.plugins.forEach((plugin) => {
|
|
||||||
if (plugin.makeJumpSections) {
|
|
||||||
const sections = plugin.makeJumpSections(context) || [];
|
|
||||||
jumpSections.push(...sections);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return jumpSections;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In MVP, each plugin can only have 1 instance.
|
* In MVP, each plugin can only have 1 instance.
|
||||||
* In future, panels could be repeated. We omit that for now since so many plugins depend on
|
* In future, panels could be repeated. We omit that for now since so many plugins depend on
|
||||||
|
|
@ -205,6 +192,7 @@ const initializeDatasette = () => {
|
||||||
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
||||||
|
|
||||||
window.__DATASETTE__ = datasetteManager;
|
window.__DATASETTE__ = datasetteManager;
|
||||||
|
console.debug("Datasette Manager Created!");
|
||||||
|
|
||||||
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
||||||
detail: datasetteManager,
|
detail: datasetteManager,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,10 @@
|
||||||
let navigationSearchInstanceCounter = 0;
|
|
||||||
|
|
||||||
class NavigationSearch extends HTMLElement {
|
class NavigationSearch extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.instanceId = ++navigationSearchInstanceCounter;
|
|
||||||
this.inputId = `navigation-search-input-${this.instanceId}`;
|
|
||||||
this.instructionsId = `navigation-search-instructions-${this.instanceId}`;
|
|
||||||
this.listboxId = `navigation-search-results-${this.instanceId}`;
|
|
||||||
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
|
|
||||||
this.statusId = `navigation-search-status-${this.instanceId}`;
|
|
||||||
this.titleId = `navigation-search-title-${this.instanceId}`;
|
|
||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
this.matches = [];
|
this.matches = [];
|
||||||
this.renderedMatches = [];
|
|
||||||
this.debounceTimer = null;
|
this.debounceTimer = null;
|
||||||
this.restoreFocusTarget = null;
|
|
||||||
this.shouldRestoreFocus = true;
|
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
|
|
@ -66,20 +54,16 @@ class NavigationSearch extends HTMLElement {
|
||||||
.search-container {
|
.search-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input-wrapper {
|
.search-input-wrapper {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: 2px solid #e5e7eb;
|
border: 2px solid #e5e7eb;
|
||||||
|
|
@ -93,36 +77,12 @@ class NavigationSearch extends HTMLElement {
|
||||||
border-color: #2563eb;
|
border-color: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-search {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: #4b5563;
|
|
||||||
cursor: pointer;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
height: 2.75rem;
|
|
||||||
line-height: 1;
|
|
||||||
width: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-search:hover,
|
|
||||||
.close-search:focus {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
border-color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-container {
|
.results-container {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: calc(80vh - 180px);
|
height: calc(80vh - 180px);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results-list:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item {
|
.result-item {
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -141,81 +101,16 @@ class NavigationSearch extends HTMLElement {
|
||||||
background-color: #dbeafe;
|
background-color: #dbeafe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item > div {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-start-content {
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
padding: 0.5rem 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-start-content:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-name {
|
.result-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-type {
|
|
||||||
color: #4b5563;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-url {
|
.result-url {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-description {
|
|
||||||
color: #374151;
|
|
||||||
display: -webkit-box;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
overflow: hidden;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-heading {
|
|
||||||
color: #4b5563;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0;
|
|
||||||
padding: 0.5rem 1rem 0.25rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-actions {
|
|
||||||
padding: 0.25rem 1rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-recent {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
color: #2563eb;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-recent:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-results {
|
.no-results {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -241,18 +136,6 @@ class NavigationSearch extends HTMLElement {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
border: 0;
|
|
||||||
clip: rect(0 0 0 0);
|
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0;
|
|
||||||
position: absolute;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile optimizations */
|
/* Mobile optimizations */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
dialog {
|
dialog {
|
||||||
|
|
@ -280,29 +163,19 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
<dialog>
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
|
||||||
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
|
||||||
<div id="${this.statusId}" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
|
||||||
<div class="search-input-wrapper">
|
<div class="search-input-wrapper">
|
||||||
<input
|
<input
|
||||||
id="${this.inputId}"
|
|
||||||
type="text"
|
type="text"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="Jump to..."
|
placeholder="Search..."
|
||||||
aria-label="Jump to"
|
aria-label="Search navigation"
|
||||||
aria-describedby="${this.instructionsId}"
|
|
||||||
role="combobox"
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="${this.listboxId}"
|
|
||||||
aria-expanded="false"
|
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
>
|
>
|
||||||
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="results-container"></div>
|
<div class="results-container" role="listbox"></div>
|
||||||
<div class="hint-text">
|
<div class="hint-text">
|
||||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||||
<span><kbd>Enter</kbd> Select</span>
|
<span><kbd>Enter</kbd> Select</span>
|
||||||
|
|
@ -316,7 +189,6 @@ class NavigationSearch extends HTMLElement {
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
const dialog = this.shadowRoot.querySelector("dialog");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
const input = this.shadowRoot.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const closeButton = this.shadowRoot.querySelector(".close-search");
|
|
||||||
const resultsContainer =
|
const resultsContainer =
|
||||||
this.shadowRoot.querySelector(".results-container");
|
this.shadowRoot.querySelector(".results-container");
|
||||||
|
|
||||||
|
|
@ -328,17 +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 event
|
||||||
input.addEventListener("input", (e) => {
|
input.addEventListener("input", (e) => {
|
||||||
this.handleSearch(e.target.value);
|
this.handleSearch(e.target.value);
|
||||||
|
|
@ -360,19 +221,8 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
closeButton.addEventListener("click", () => {
|
|
||||||
this.closeMenu();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click on result item
|
// Click on result item
|
||||||
resultsContainer.addEventListener("click", (e) => {
|
resultsContainer.addEventListener("click", (e) => {
|
||||||
const clearRecent = e.target.closest("[data-clear-recent-items]");
|
|
||||||
if (clearRecent) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.clearRecentItems();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = e.target.closest(".result-item");
|
const item = e.target.closest(".result-item");
|
||||||
if (item) {
|
if (item) {
|
||||||
const index = parseInt(item.dataset.index);
|
const index = parseInt(item.dataset.index);
|
||||||
|
|
@ -387,15 +237,6 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dialog.addEventListener("cancel", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
this.closeMenu();
|
|
||||||
});
|
|
||||||
|
|
||||||
dialog.addEventListener("close", () => {
|
|
||||||
this.onMenuClosed();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
this.loadInitialData();
|
this.loadInitialData();
|
||||||
}
|
}
|
||||||
|
|
@ -410,106 +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() {
|
loadInitialData() {
|
||||||
const itemsAttr = this.getAttribute("items");
|
const itemsAttr = this.getAttribute("items");
|
||||||
if (itemsAttr) {
|
if (itemsAttr) {
|
||||||
|
|
@ -526,11 +267,6 @@ class NavigationSearch extends HTMLElement {
|
||||||
|
|
||||||
handleSearch(query) {
|
handleSearch(query) {
|
||||||
clearTimeout(this.debounceTimer);
|
clearTimeout(this.debounceTimer);
|
||||||
if (query.trim()) {
|
|
||||||
this.setStatus("Searching...");
|
|
||||||
} else {
|
|
||||||
this.setStatus("");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.debounceTimer = setTimeout(() => {
|
this.debounceTimer = setTimeout(() => {
|
||||||
const url = this.getAttribute("url");
|
const url = this.getAttribute("url");
|
||||||
|
|
@ -553,262 +289,65 @@ class NavigationSearch extends HTMLElement {
|
||||||
this.matches = data.matches || [];
|
this.matches = data.matches || [];
|
||||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||||
this.renderResults();
|
this.renderResults();
|
||||||
if (query.trim()) {
|
|
||||||
this.setStatus(this.resultsStatus(this.matches.length, data.truncated));
|
|
||||||
} else {
|
|
||||||
this.setStatus("");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch search results:", e);
|
console.error("Failed to fetch search results:", e);
|
||||||
this.matches = [];
|
this.matches = [];
|
||||||
this.renderResults();
|
this.renderResults();
|
||||||
this.setStatus("Search failed.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filterLocalItems(query) {
|
filterLocalItems(query) {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
this.matches = this.allItems || [];
|
this.matches = [];
|
||||||
} else {
|
} else {
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
this.matches = (this.allItems || []).filter(
|
this.matches = (this.allItems || []).filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.name.toLowerCase().includes(lowerQuery) ||
|
item.name.toLowerCase().includes(lowerQuery) ||
|
||||||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
|
|
||||||
item.url.toLowerCase().includes(lowerQuery),
|
item.url.toLowerCase().includes(lowerQuery),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||||
this.renderResults();
|
this.renderResults();
|
||||||
if (query.trim()) {
|
|
||||||
this.setStatus(this.resultsStatus(this.matches.length, false));
|
|
||||||
} else {
|
|
||||||
this.setStatus("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recentItemsStorageKey() {
|
|
||||||
return "datasette.navigationSearch.recentItems";
|
|
||||||
}
|
|
||||||
|
|
||||||
loadRecentItems() {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(this.recentItemsStorageKey());
|
|
||||||
if (!raw) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
.filter((item) => item && item.name && item.url)
|
|
||||||
.map((item) => ({
|
|
||||||
name: String(item.name),
|
|
||||||
display_name: item.display_name ? String(item.display_name) : "",
|
|
||||||
url: String(item.url),
|
|
||||||
type: item.type ? String(item.type) : "",
|
|
||||||
description: item.description ? String(item.description) : "",
|
|
||||||
}))
|
|
||||||
.slice(0, 5);
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveRecentItem(match) {
|
|
||||||
if (
|
|
||||||
typeof localStorage === "undefined" ||
|
|
||||||
!match ||
|
|
||||||
!match.name ||
|
|
||||||
!match.url
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const item = {
|
|
||||||
name: String(match.name),
|
|
||||||
display_name: match.display_name ? String(match.display_name) : "",
|
|
||||||
url: String(match.url),
|
|
||||||
type: match.type ? String(match.type) : "",
|
|
||||||
description: match.description ? String(match.description) : "",
|
|
||||||
};
|
|
||||||
const recentItems = this.loadRecentItems().filter(
|
|
||||||
(recentItem) => recentItem.url !== item.url,
|
|
||||||
);
|
|
||||||
localStorage.setItem(
|
|
||||||
this.recentItemsStorageKey(),
|
|
||||||
JSON.stringify([item, ...recentItems].slice(0, 5)),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// localStorage may be unavailable, full, or disabled.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearRecentItems() {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(this.recentItemsStorageKey());
|
|
||||||
} catch (e) {
|
|
||||||
localStorage.setItem(this.recentItemsStorageKey(), "[]");
|
|
||||||
}
|
|
||||||
this.renderResults();
|
|
||||||
this.setStatus("Recent items cleared.");
|
|
||||||
}
|
|
||||||
|
|
||||||
jumpSections() {
|
|
||||||
const manager = window.__DATASETTE__;
|
|
||||||
if (!manager || typeof manager.makeJumpSections !== "function") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const sections = manager.makeJumpSections({
|
|
||||||
navigationSearch: this,
|
|
||||||
});
|
|
||||||
return Array.isArray(sections)
|
|
||||||
? sections.filter(
|
|
||||||
(section) => section && typeof section.render === "function",
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
jumpSectionsHtml(jumpSections) {
|
|
||||||
return jumpSections
|
|
||||||
.map((section, index) => {
|
|
||||||
const id = section.id
|
|
||||||
? ` data-jump-section-id="${this.escapeHtml(section.id)}"`
|
|
||||||
: "";
|
|
||||||
return `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
renderJumpSections(container, jumpSections) {
|
|
||||||
jumpSections.forEach((section, index) => {
|
|
||||||
const node = container.querySelector(
|
|
||||||
`[data-jump-section-index="${index}"]`,
|
|
||||||
);
|
|
||||||
if (!node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
section.render(node, {
|
|
||||||
navigationSearch: this,
|
|
||||||
container,
|
|
||||||
input: this.shadowRoot.querySelector(".search-input"),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resultItemHtml(match, index) {
|
|
||||||
const displayName = match.display_name || match.name;
|
|
||||||
const label =
|
|
||||||
match.display_name && match.display_name !== match.name
|
|
||||||
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
|
|
||||||
: "";
|
|
||||||
const type = match.type
|
|
||||||
? `<div class="result-type">${this.escapeHtml(match.type)}</div>`
|
|
||||||
: "";
|
|
||||||
const description = match.description
|
|
||||||
? `<div class="result-description">${this.escapeHtml(
|
|
||||||
match.description,
|
|
||||||
)}</div>`
|
|
||||||
: "";
|
|
||||||
return `
|
|
||||||
<div
|
|
||||||
id="${this.resultOptionId(index)}"
|
|
||||||
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
|
|
||||||
data-index="${index}"
|
|
||||||
role="option"
|
|
||||||
aria-selected="${index === this.selectedIndex}"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
${type}
|
|
||||||
<div class="result-name">${this.escapeHtml(displayName)}</div>
|
|
||||||
${label}
|
|
||||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
|
||||||
${description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderResults() {
|
renderResults() {
|
||||||
const container = this.shadowRoot.querySelector(".results-container");
|
const container = this.shadowRoot.querySelector(".results-container");
|
||||||
const input = this.shadowRoot.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
const showStartContent = !input.value.trim();
|
|
||||||
const jumpSections = showStartContent ? this.jumpSections() : [];
|
|
||||||
const startBlock = showStartContent
|
|
||||||
? this.jumpSectionsHtml(jumpSections)
|
|
||||||
: "";
|
|
||||||
const recentItems = showStartContent ? this.loadRecentItems() : [];
|
|
||||||
const defaultMatches = showStartContent ? [] : this.matches;
|
|
||||||
const renderedMatches = [...recentItems, ...defaultMatches];
|
|
||||||
this.renderedMatches = renderedMatches;
|
|
||||||
const emptyListbox = `<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results"></div>`;
|
|
||||||
|
|
||||||
if (renderedMatches.length) {
|
if (this.matches.length === 0) {
|
||||||
if (
|
const message = input.value.trim()
|
||||||
this.selectedIndex < 0 ||
|
? "No results found"
|
||||||
this.selectedIndex >= renderedMatches.length
|
: "Start typing to search...";
|
||||||
) {
|
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||||
this.selectedIndex = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.selectedIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderedMatches.length === 0) {
|
|
||||||
if (startBlock) {
|
|
||||||
container.innerHTML = startBlock + emptyListbox;
|
|
||||||
this.renderJumpSections(container, jumpSections);
|
|
||||||
} else if (showStartContent) {
|
|
||||||
container.innerHTML = emptyListbox;
|
|
||||||
} else {
|
|
||||||
const message = input.value.trim()
|
|
||||||
? "No results found"
|
|
||||||
: "Start typing to search...";
|
|
||||||
container.innerHTML = `${emptyListbox}<div class="no-results">${message}</div>`;
|
|
||||||
}
|
|
||||||
this.updateComboboxState();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentHeading = recentItems.length
|
container.innerHTML = this.matches
|
||||||
? `<div class="results-heading" id="${this.recentHeadingId}">Recent</div>`
|
.map(
|
||||||
: "";
|
(match, index) => `
|
||||||
const recentGroup = recentItems.length
|
<div
|
||||||
? `<div role="group" aria-labelledby="${this.recentHeadingId}">${recentItems
|
class="result-item ${
|
||||||
.map((match, index) => this.resultItemHtml(match, index))
|
index === this.selectedIndex ? "selected" : ""
|
||||||
.join("")}</div>`
|
}"
|
||||||
: "";
|
data-index="${index}"
|
||||||
const recentActions = recentItems.length
|
role="option"
|
||||||
? `<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
|
aria-selected="${index === this.selectedIndex}"
|
||||||
: "";
|
>
|
||||||
const defaultHtml = defaultMatches
|
<div>
|
||||||
.map((match, index) =>
|
<div class="result-name">${this.escapeHtml(
|
||||||
this.resultItemHtml(match, recentItems.length + index),
|
match.name,
|
||||||
|
)}</div>
|
||||||
|
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
container.innerHTML =
|
|
||||||
startBlock +
|
|
||||||
recentHeading +
|
|
||||||
`<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results">${recentGroup}${defaultHtml}</div>` +
|
|
||||||
recentActions;
|
|
||||||
this.renderJumpSections(container, jumpSections);
|
|
||||||
this.updateComboboxState();
|
|
||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
if (this.selectedIndex >= 0) {
|
if (this.selectedIndex >= 0) {
|
||||||
const selectedItem = container.querySelector(
|
const selectedItem = container.children[this.selectedIndex];
|
||||||
`.result-item[data-index="${this.selectedIndex}"]`,
|
|
||||||
);
|
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
selectedItem.scrollIntoView({ block: "nearest" });
|
selectedItem.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
|
|
@ -816,27 +355,22 @@ class NavigationSearch extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
moveSelection(direction) {
|
moveSelection(direction) {
|
||||||
const matches = this.renderedMatches || this.matches;
|
|
||||||
const newIndex = this.selectedIndex + direction;
|
const newIndex = this.selectedIndex + direction;
|
||||||
if (newIndex >= 0 && newIndex < matches.length) {
|
if (newIndex >= 0 && newIndex < this.matches.length) {
|
||||||
this.selectedIndex = newIndex;
|
this.selectedIndex = newIndex;
|
||||||
this.renderResults();
|
this.renderResults();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectCurrentItem() {
|
selectCurrentItem() {
|
||||||
const matches = this.renderedMatches || this.matches;
|
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
|
||||||
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
|
|
||||||
this.selectItem(this.selectedIndex);
|
this.selectItem(this.selectedIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectItem(index) {
|
selectItem(index) {
|
||||||
const matches = this.renderedMatches || this.matches;
|
const match = this.matches[index];
|
||||||
const match = matches[index];
|
|
||||||
if (match) {
|
if (match) {
|
||||||
this.saveRecentItem(match);
|
|
||||||
|
|
||||||
// Dispatch custom event
|
// Dispatch custom event
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("select", {
|
new CustomEvent("select", {
|
||||||
|
|
@ -849,59 +383,32 @@ class NavigationSearch extends HTMLElement {
|
||||||
// Navigate to URL
|
// Navigate to URL
|
||||||
window.location.href = match.url;
|
window.location.href = match.url;
|
||||||
|
|
||||||
this.closeMenu({ restoreFocus: false });
|
this.closeMenu();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openMenu(trigger) {
|
openMenu() {
|
||||||
const dialog = this.shadowRoot.querySelector("dialog");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
const input = this.shadowRoot.querySelector(".search-input");
|
const input = this.shadowRoot.querySelector(".search-input");
|
||||||
|
|
||||||
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
|
dialog.showModal();
|
||||||
this.shouldRestoreFocus = true;
|
|
||||||
if (!dialog.open) {
|
|
||||||
dialog.showModal();
|
|
||||||
}
|
|
||||||
this.setNavigationTriggersExpanded(true);
|
|
||||||
input.value = "";
|
input.value = "";
|
||||||
input.focus();
|
input.focus();
|
||||||
|
|
||||||
// Reset state, then populate the default jump list.
|
// Reset state - start with no items shown
|
||||||
this.matches = [];
|
this.matches = [];
|
||||||
this.selectedIndex = -1;
|
this.selectedIndex = -1;
|
||||||
this.renderResults();
|
this.renderResults();
|
||||||
this.setStatus("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeMenu(options = {}) {
|
closeMenu() {
|
||||||
const dialog = this.shadowRoot.querySelector("dialog");
|
const dialog = this.shadowRoot.querySelector("dialog");
|
||||||
this.shouldRestoreFocus = options.restoreFocus !== false;
|
dialog.close();
|
||||||
if (dialog.open) {
|
|
||||||
dialog.close();
|
|
||||||
} else {
|
|
||||||
this.onMenuClosed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMenuClosed() {
|
|
||||||
const input = this.shadowRoot.querySelector(".search-input");
|
|
||||||
this.setElementAttribute(input, "aria-expanded", "false");
|
|
||||||
this.removeElementAttribute(input, "aria-activedescendant");
|
|
||||||
this.setNavigationTriggersExpanded(false);
|
|
||||||
this.setStatus("");
|
|
||||||
if (
|
|
||||||
this.shouldRestoreFocus &&
|
|
||||||
this.restoreFocusTarget &&
|
|
||||||
typeof this.restoreFocusTarget.focus === "function"
|
|
||||||
) {
|
|
||||||
this.restoreFocusTarget.focus();
|
|
||||||
}
|
|
||||||
this.restoreFocusTarget = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.textContent = text == null ? "" : text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% if action_links %}
|
{% if action_links %}
|
||||||
<div class="page-action-menu">
|
<div class="page-action-menu">
|
||||||
<details class="actions-menu-links details-menu">
|
<details class="actions-menu-links details-menu">
|
||||||
<summary aria-haspopup="menu" aria-expanded="false">
|
<summary>
|
||||||
<div class="icon-text">
|
<div class="icon-text">
|
||||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<title id="actions-menu-links-title">{{ action_title }}</title>
|
<title id="actions-menu-links-title">{{ action_title }}</title>
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
</summary>
|
</summary>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<div class="hook"></div>
|
<div class="hook"></div>
|
||||||
<ul role="menu">
|
<ul>
|
||||||
{% for link in action_links %}
|
{% for link in action_links %}
|
||||||
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
<li><a href="{{ link.href }}">{{ link.label }}
|
||||||
{% if link.description %}
|
{% if link.description %}
|
||||||
<p class="dropdown-description">{{ link.description }}</p>
|
<p class="dropdown-description">{{ link.description }}</p>
|
||||||
{% endif %}</a>
|
{% endif %}</a>
|
||||||
|
|
|
||||||
|
|
@ -13,50 +13,4 @@ document.body.addEventListener('click', (ev) => {
|
||||||
(details) => details.open && details != detailsClickedWithin
|
(details) => details.open && details != detailsClickedWithin
|
||||||
).forEach(details => details.open = false);
|
).forEach(details => details.open = false);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Sync aria-expanded and add keyboard navigation for details-menu elements */
|
|
||||||
document.querySelectorAll('details.details-menu').forEach(function(details) {
|
|
||||||
var summary = details.querySelector('summary');
|
|
||||||
details.addEventListener('toggle', function() {
|
|
||||||
if (summary) {
|
|
||||||
summary.setAttribute('aria-expanded', details.open ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
if (details.open) {
|
|
||||||
/* Focus first menu item when menu opens */
|
|
||||||
var firstItem = details.querySelector('[role="menuitem"]');
|
|
||||||
if (firstItem) { firstItem.focus(); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener('keydown', function(ev) {
|
|
||||||
/* Keyboard navigation for open details-menu elements */
|
|
||||||
var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]'));
|
|
||||||
if (!openDetails.length) { return; }
|
|
||||||
|
|
||||||
if (ev.key === 'Escape') {
|
|
||||||
openDetails.forEach(function(details) {
|
|
||||||
details.open = false;
|
|
||||||
var summary = details.querySelector('summary');
|
|
||||||
if (summary) { summary.focus(); }
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
||||||
var focused = document.activeElement;
|
|
||||||
openDetails.forEach(function(details) {
|
|
||||||
var items = Array.from(details.querySelectorAll('[role="menuitem"]'));
|
|
||||||
if (!items.length) { return; }
|
|
||||||
var idx = items.indexOf(focused);
|
|
||||||
if (idx === -1) { return; }
|
|
||||||
ev.preventDefault();
|
|
||||||
if (ev.key === 'ArrowDown') {
|
|
||||||
items[(idx + 1) % items.length].focus();
|
|
||||||
} else {
|
|
||||||
items[(idx - 1 + items.length) % items.length].focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
<script>
|
|
||||||
window.datasetteSqlParameters = (() => {
|
|
||||||
if (
|
|
||||||
window.datasetteSqlParameters &&
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh
|
|
||||||
) {
|
|
||||||
return window.datasetteSqlParameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentSql(form) {
|
|
||||||
if (window.editor) {
|
|
||||||
return window.editor.state.doc.toString();
|
|
||||||
}
|
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
|
|
||||||
return sqlInput ? sqlInput.value : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function controlState(control) {
|
|
||||||
return {
|
|
||||||
value: control.value,
|
|
||||||
expanded: control.tagName.toLowerCase() === "textarea",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncParameterState(manager) {
|
|
||||||
manager.parameterState = new Map();
|
|
||||||
manager.section
|
|
||||||
.querySelectorAll("[data-parameter-control]")
|
|
||||||
.forEach((control) => {
|
|
||||||
manager.parameterState.set(control.name, controlState(control));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createControl(parameter, id, state) {
|
|
||||||
const control = document.createElement(state.expanded ? "textarea" : "input");
|
|
||||||
control.id = id;
|
|
||||||
control.name = parameter;
|
|
||||||
control.value = state.value;
|
|
||||||
control.setAttribute("data-parameter-control", "");
|
|
||||||
if (state.expanded) {
|
|
||||||
control.rows = 5;
|
|
||||||
} else {
|
|
||||||
control.type = "text";
|
|
||||||
}
|
|
||||||
return control;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceParameterControl(
|
|
||||||
manager,
|
|
||||||
control,
|
|
||||||
button,
|
|
||||||
expand,
|
|
||||||
value,
|
|
||||||
selectionStart
|
|
||||||
) {
|
|
||||||
const replacement = createControl(control.name, control.id, {
|
|
||||||
value: value === undefined ? control.value : value,
|
|
||||||
expanded: expand,
|
|
||||||
});
|
|
||||||
button.textContent = expand ? "Collapse" : "Expand";
|
|
||||||
button.setAttribute("aria-expanded", expand ? "true" : "false");
|
|
||||||
control.replaceWith(replacement);
|
|
||||||
replacement.focus();
|
|
||||||
if (selectionStart !== undefined && replacement.setSelectionRange) {
|
|
||||||
replacement.setSelectionRange(selectionStart, selectionStart);
|
|
||||||
}
|
|
||||||
manager.parameterState.set(replacement.name, controlState(replacement));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderParameters(manager, parameters) {
|
|
||||||
syncParameterState(manager);
|
|
||||||
const previousState = manager.parameterState;
|
|
||||||
const nextState = new Map();
|
|
||||||
manager.section.replaceChildren();
|
|
||||||
if (!parameters.length) {
|
|
||||||
manager.parameterState = nextState;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = document.createElement("h2");
|
|
||||||
heading.textContent = "Parameters";
|
|
||||||
manager.section.appendChild(heading);
|
|
||||||
|
|
||||||
parameters.forEach((parameter, index) => {
|
|
||||||
const id = `qp${index + 1}`;
|
|
||||||
const state = previousState.get(parameter) || {
|
|
||||||
value: "",
|
|
||||||
expanded: false,
|
|
||||||
};
|
|
||||||
if (!manager.allowExpand) {
|
|
||||||
state.expanded = false;
|
|
||||||
}
|
|
||||||
nextState.set(parameter, state);
|
|
||||||
|
|
||||||
const row = document.createElement("p");
|
|
||||||
row.className = "sql-parameter-row";
|
|
||||||
|
|
||||||
const label = document.createElement("label");
|
|
||||||
label.htmlFor = id;
|
|
||||||
label.textContent = parameter;
|
|
||||||
|
|
||||||
const control = createControl(parameter, id, state);
|
|
||||||
|
|
||||||
row.append(label, control);
|
|
||||||
if (manager.allowExpand) {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "sql-parameter-toggle";
|
|
||||||
button.setAttribute("data-parameter-toggle", "");
|
|
||||||
button.setAttribute("aria-controls", id);
|
|
||||||
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
|
|
||||||
button.textContent = state.expanded ? "Collapse" : "Expand";
|
|
||||||
row.append(" ", button);
|
|
||||||
}
|
|
||||||
manager.section.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.parameterState = nextState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindParameterControls(manager) {
|
|
||||||
manager.form.addEventListener("input", (event) => {
|
|
||||||
const control = event.target;
|
|
||||||
if (!control.matches || !control.matches("[data-parameter-control]")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
manager.parameterState.set(control.name, controlState(control));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!manager.allowExpand) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.form.addEventListener("click", (event) => {
|
|
||||||
const button = event.target.closest
|
|
||||||
? event.target.closest("[data-parameter-toggle]")
|
|
||||||
: null;
|
|
||||||
if (!button || !manager.form.contains(button)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const control = document.getElementById(button.getAttribute("aria-controls"));
|
|
||||||
if (!control) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const expanded = control.tagName.toLowerCase() === "textarea";
|
|
||||||
replaceParameterControl(manager, control, button, !expanded);
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.form.addEventListener("paste", (event) => {
|
|
||||||
const control = event.target;
|
|
||||||
if (
|
|
||||||
!(control instanceof HTMLInputElement) ||
|
|
||||||
!control.matches("[data-parameter-control]")
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
|
|
||||||
if (!/[\r\n]/.test(pasted)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const button = document.querySelector(
|
|
||||||
`[data-parameter-toggle][aria-controls="${control.id}"]`
|
|
||||||
);
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
const selectionStart = control.selectionStart ?? control.value.length;
|
|
||||||
const selectionEnd = control.selectionEnd ?? selectionStart;
|
|
||||||
const value =
|
|
||||||
control.value.slice(0, selectionStart) +
|
|
||||||
pasted +
|
|
||||||
control.value.slice(selectionEnd);
|
|
||||||
replaceParameterControl(
|
|
||||||
manager,
|
|
||||||
control,
|
|
||||||
button,
|
|
||||||
true,
|
|
||||||
value,
|
|
||||||
selectionStart + pasted.length
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEditorChanges(form, callback) {
|
|
||||||
const editorElement = form.querySelector(".cm-content");
|
|
||||||
if (editorElement) {
|
|
||||||
editorElement.addEventListener("input", callback);
|
|
||||||
}
|
|
||||||
if (!window.editor) {
|
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor");
|
|
||||||
if (sqlInput) {
|
|
||||||
sqlInput.addEventListener("input", callback);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.editor.datasetteSqlParameterCallbacks) {
|
|
||||||
const editor = window.editor;
|
|
||||||
const originalDispatch = editor.dispatch.bind(editor);
|
|
||||||
editor.datasetteSqlParameterCallbacks = [];
|
|
||||||
editor.dispatch = (...transactions) => {
|
|
||||||
const before = editor.state.doc.toString();
|
|
||||||
originalDispatch(...transactions);
|
|
||||||
if (editor.state.doc.toString() !== before) {
|
|
||||||
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
window.editor.datasetteSqlParameterCallbacks.push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSqlParameterRefresh(options) {
|
|
||||||
const form =
|
|
||||||
options.form || document.querySelector("form.sql.core[data-parameters-url]");
|
|
||||||
if (!form) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const shouldRenderParameters = options.renderParameters !== false;
|
|
||||||
const section =
|
|
||||||
options.section || form.querySelector("[data-sql-parameters-section]");
|
|
||||||
if (shouldRenderParameters && !section) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const manager = {
|
|
||||||
form,
|
|
||||||
section,
|
|
||||||
allowExpand:
|
|
||||||
options.allowExpand === undefined
|
|
||||||
? section
|
|
||||||
? section.dataset.allowExpand === "1"
|
|
||||||
: false
|
|
||||||
: options.allowExpand,
|
|
||||||
parameterState: new Map(),
|
|
||||||
};
|
|
||||||
if (section) {
|
|
||||||
bindParameterControls(manager);
|
|
||||||
syncParameterState(manager);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = options.url || form.dataset.parametersUrl;
|
|
||||||
let refreshTimer = null;
|
|
||||||
let refreshSequence = 0;
|
|
||||||
|
|
||||||
async function refreshParameters() {
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sequence = ++refreshSequence;
|
|
||||||
try {
|
|
||||||
const requestUrl = new URL(url, window.location.href);
|
|
||||||
requestUrl.searchParams.set("sql", currentSql(form));
|
|
||||||
const response = await fetch(requestUrl, {
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (sequence !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error((data.errors || [response.statusText]).join("; "));
|
|
||||||
}
|
|
||||||
if (shouldRenderParameters) {
|
|
||||||
renderParameters(manager, data.parameters || []);
|
|
||||||
}
|
|
||||||
if (options.onData) {
|
|
||||||
options.onData(data, manager);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (sequence !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.onError) {
|
|
||||||
options.onError(error, manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleRefresh() {
|
|
||||||
clearTimeout(refreshTimer);
|
|
||||||
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
|
|
||||||
}
|
|
||||||
|
|
||||||
bindEditorChanges(form, scheduleRefresh);
|
|
||||||
return {
|
|
||||||
currentSql: () => currentSql(form),
|
|
||||||
refreshParameters,
|
|
||||||
renderParameters: (parameters) => renderParameters(manager, parameters),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { setupSqlParameterRefresh };
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
|
|
||||||
{% if parameter_names %}
|
|
||||||
<h2>Parameters</h2>
|
|
||||||
{% for parameter in parameter_names %}
|
|
||||||
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
|
|
||||||
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control>{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
|
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
|
||||||
<div class="above-table-panel"> </div>
|
<div class="above-table-panel"> </div>
|
||||||
{% if display_columns %}
|
{% if display_rows %}
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table class="rows-and-columns">
|
<table class="rows-and-columns">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -31,7 +31,6 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% else %}
|
||||||
{% if not display_rows %}
|
|
||||||
<p class="zero-results">0 records</p>
|
<p class="zero-results">0 records</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
<body class="{% block body_class %}{% endblock %}">
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
<div class="not-footer">
|
<div class="not-footer">
|
||||||
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||||
{% set links = menu_links() %}
|
{% set links = menu_links() %}{% if links or show_logout %}
|
||||||
<details class="nav-menu details-menu">
|
<details class="nav-menu details-menu">
|
||||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
@ -29,18 +29,19 @@
|
||||||
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
|
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
|
||||||
</svg></summary>
|
</svg></summary>
|
||||||
<div class="nav-menu-inner">
|
<div class="nav-menu-inner">
|
||||||
|
{% if links %}
|
||||||
<ul>
|
<ul>
|
||||||
<li><button type="button" class="button-as-link" data-navigation-search-open aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="/">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true" title="Keyboard shortcut: press / to open Jump to">/</kbd></button></li>
|
|
||||||
{% for link in links %}
|
{% for link in links %}
|
||||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
{% endif %}
|
||||||
{% if show_logout %}
|
{% if show_logout %}
|
||||||
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
|
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
|
||||||
<button class="button-as-link">Log out</button>
|
<button class="button-as-link">Log out</button>
|
||||||
</form>{% endif %}
|
</form>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>{% endif %}
|
||||||
{% if actor %}
|
{% if actor %}
|
||||||
<div class="actor">
|
<div class="actor">
|
||||||
<strong>{{ display_actor(actor) }}</strong>
|
<strong>{{ display_actor(actor) }}</strong>
|
||||||
|
|
@ -71,6 +72,6 @@
|
||||||
|
|
||||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||||
<navigation-search url="/-/jump"></navigation-search>
|
<navigation-search url="/-/tables"></navigation-search>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
{% include "_codemirror.html" %}
|
{% include "_codemirror.html" %}
|
||||||
{% include "_sql_parameter_styles.html" %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||||
|
|
@ -26,13 +25,9 @@
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
{% if allow_execute_sql %}
|
{% if allow_execute_sql %}
|
||||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get">
|
||||||
<h3>Custom SQL query</h3>
|
<h3>Custom SQL query</h3>
|
||||||
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||||
{% set parameter_names = [] %}
|
|
||||||
{% set parameter_values = {} %}
|
|
||||||
{% set sql_parameters_allow_expand = false %}
|
|
||||||
{% include "_sql_parameters.html" %}
|
|
||||||
<p>
|
<p>
|
||||||
<button id="sql-format" type="button" hidden>Format SQL</button>
|
<button id="sql-format" type="button" hidden>Format SQL</button>
|
||||||
<input type="submit" value="Run SQL">
|
<input type="submit" value="Run SQL">
|
||||||
|
|
@ -58,9 +53,6 @@
|
||||||
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if queries_more %}
|
|
||||||
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if tables %}
|
{% if tables %}
|
||||||
|
|
@ -95,11 +87,5 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
{% include "_codemirror_foot.html" %}
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,288 +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 %}
|
|
||||||
|
|
||||||
<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">You don't currently have permission to insert, edit or delete from any tables.</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 %}
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
</style>
|
</style>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "_codemirror.html" %}
|
{% include "_codemirror.html" %}
|
||||||
{% include "_sql_parameter_styles.html" %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %}
|
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block crumbs %}
|
{% block crumbs %}
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
{{ crumbs.nav(request=request, database=database) }}
|
||||||
|
|
@ -25,19 +24,19 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if stored_query_write and db_is_immutable %}
|
{% if canned_query_write and db_is_immutable %}
|
||||||
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||||
{% set action_links, action_title = query_actions(), "Query actions" %}
|
{% set action_links, action_title = query_actions(), "Query actions" %}
|
||||||
{% include "_action_menu.html" %}
|
{% include "_action_menu.html" %}
|
||||||
|
|
||||||
{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %}
|
{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
<form class="sql core" action="{{ urls.database(database) }}{% if stored_query %}/{{ stored_query }}{% endif %}" method="{% if stored_query_write %}post{% else %}get{% endif %}" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
<form class="sql core" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_write %}post{% else %}get{% endif %}">
|
||||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
|
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
|
||||||
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
||||||
{% endif %}</h3>
|
{% endif %}</h3>
|
||||||
|
|
@ -46,28 +45,29 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not hide_sql %}
|
{% if not hide_sql %}
|
||||||
{% if editable and allow_execute_sql %}
|
{% if editable and allow_execute_sql %}
|
||||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
<p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
||||||
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
>{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not stored_query %}
|
{% if not canned_query %}
|
||||||
<input type="hidden" name="sql"
|
<input type="hidden" name="sql"
|
||||||
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
||||||
>
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set parameter_names = named_parameter_values.keys()|list %}
|
{% if named_parameter_values %}
|
||||||
{% set parameter_values = named_parameter_values %}
|
<h3>Query parameters</h3>
|
||||||
{% set sql_parameters_allow_expand = false %}
|
{% for name, value in named_parameter_values.items() %}
|
||||||
{% include "_sql_parameters.html" %}
|
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
<p>
|
<p>
|
||||||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
||||||
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}>
|
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
|
||||||
{{ show_hide_hidden }}
|
{{ show_hide_hidden }}
|
||||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %}
|
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||||
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
|
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -90,17 +90,11 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not stored_query_write and not error %}
|
{% if not canned_query_write and not error %}
|
||||||
<p class="zero-results">0 results</p>
|
<p class="zero-results">0 results</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
{% include "_codemirror_foot.html" %}
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Create query{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{- super() -}}
|
|
||||||
{% include "_codemirror.html" %}
|
|
||||||
{% include "_execute_write_analysis_styles.html" %}
|
|
||||||
<style>
|
|
||||||
.query-create-page {
|
|
||||||
max-width: 64rem;
|
|
||||||
}
|
|
||||||
.query-create-form {
|
|
||||||
--query-create-label-width: clamp(7rem, 18vw, 10rem);
|
|
||||||
--query-create-column-gap: 0.8rem;
|
|
||||||
--query-create-control-width: minmax(16rem, 1fr);
|
|
||||||
}
|
|
||||||
.query-create-fields {
|
|
||||||
margin: 0 0 0.85rem;
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
.query-create-field {
|
|
||||||
align-items: start;
|
|
||||||
column-gap: var(--query-create-column-gap);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
|
||||||
margin: 0 0 0.65rem;
|
|
||||||
}
|
|
||||||
.query-create-field label {
|
|
||||||
padding-top: 0.55rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.query-create-field input[type=text],
|
|
||||||
.query-create-field textarea {
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
form.sql .query-create-field textarea {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-create-url-control {
|
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
grid-template-columns: max-content minmax(12rem, 1fr);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-create-url-prefix {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-family: var(--font-monospace, monospace);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.query-create-url-control input[type=text] {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.query-create-field textarea {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
font-family: Helvetica, sans-serif;
|
|
||||||
font-size: 1em;
|
|
||||||
min-height: 5rem;
|
|
||||||
padding: 9px 4px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
form.sql .query-create-sql {
|
|
||||||
column-gap: var(--query-create-column-gap);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
|
||||||
margin: 0.9rem 0 0.75rem;
|
|
||||||
max-width: 52rem;
|
|
||||||
}
|
|
||||||
.query-create-sql .cm-editor,
|
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
|
||||||
grid-column: 2;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-create-options {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.8rem 1.4rem;
|
|
||||||
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
|
||||||
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
|
|
||||||
}
|
|
||||||
.query-create-options label {
|
|
||||||
align-items: center;
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.query-create-options input[type=checkbox] {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.query-create-option-note,
|
|
||||||
.query-create-analysis-note {
|
|
||||||
color: #4f5b6d;
|
|
||||||
flex-basis: 100%;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.query-create-option-note {
|
|
||||||
margin: -0.45rem 0 0;
|
|
||||||
}
|
|
||||||
.query-create-analysis-note {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.query-create-analysis {
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
|
||||||
.query-create-submit {
|
|
||||||
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
|
||||||
margin-bottom: 0.9rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.query-create-form {
|
|
||||||
--query-create-label-width: 1fr;
|
|
||||||
--query-create-column-gap: 0;
|
|
||||||
}
|
|
||||||
.query-create-field {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
row-gap: 0.25rem;
|
|
||||||
}
|
|
||||||
.query-create-field label {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
form.sql .query-create-sql {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.query-create-sql .cm-editor,
|
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
.query-create-options,
|
|
||||||
.query-create-submit {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="query-create-page">
|
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
|
|
||||||
|
|
||||||
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/store" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
|
|
||||||
<div class="query-create-fields">
|
|
||||||
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
|
|
||||||
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
|
|
||||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
|
||||||
|
|
||||||
<p class="query-create-options">
|
|
||||||
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
|
|
||||||
<input type="hidden" name="is_private" value="0">
|
|
||||||
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
|
|
||||||
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
|
|
||||||
</p>
|
|
||||||
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
|
|
||||||
|
|
||||||
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
|
|
||||||
{% if has_sql %}
|
|
||||||
<h2>Query operations</h2>
|
|
||||||
{% if analysis_error %}
|
|
||||||
<p class="message-error">{{ analysis_error }}</p>
|
|
||||||
{% elif analysis_rows %}
|
|
||||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Operation</th>
|
|
||||||
<th scope="col">Database</th>
|
|
||||||
<th scope="col">Table</th>
|
|
||||||
<th scope="col">Required permission</th>
|
|
||||||
<th scope="col">Allowed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in analysis_rows %}
|
|
||||||
<tr>
|
|
||||||
<td><code>{{ row.operation }}</code></td>
|
|
||||||
<td><code>{{ row.database }}</code></td>
|
|
||||||
<td><code>{{ row.table }}</code></td>
|
|
||||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
|
|
||||||
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{% else %}
|
|
||||||
<p>Analysis will show each affected table and required permission.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
|
||||||
{% include "_sql_parameter_scripts.html" %}
|
|
||||||
{% include "_execute_write_analysis_scripts.html" %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const titleInput = document.querySelector("#query-title");
|
|
||||||
const urlInput = document.querySelector("#query-url-slug");
|
|
||||||
let urlEdited = Boolean(urlInput && urlInput.value);
|
|
||||||
|
|
||||||
function slugify(value) {
|
|
||||||
return value
|
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9_-]+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (titleInput && urlInput) {
|
|
||||||
titleInput.addEventListener("input", () => {
|
|
||||||
if (!urlEdited) {
|
|
||||||
urlInput.value = slugify(titleInput.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
urlInput.addEventListener("input", () => {
|
|
||||||
urlEdited = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const form = document.querySelector("form.sql.core");
|
|
||||||
const analysisSection = document.querySelector("#query-create-analysis-section");
|
|
||||||
const submitButton = form
|
|
||||||
? form.querySelector("[data-query-create-submit]")
|
|
||||||
: null;
|
|
||||||
const analysisNote = form
|
|
||||||
? form.querySelector("[data-query-create-analysis-note]")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
function updateAnalysisNote(data) {
|
|
||||||
if (!analysisNote) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.analysis_error) {
|
|
||||||
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
|
|
||||||
} else if (data.has_sql === false) {
|
|
||||||
analysisNote.textContent = "Enter SQL to analyze this query.";
|
|
||||||
} else if (data.analysis_is_write) {
|
|
||||||
analysisNote.textContent = "This query updates data in the database.";
|
|
||||||
} else {
|
|
||||||
analysisNote.textContent = "This is a read-only query.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
|
||||||
form,
|
|
||||||
url: form.dataset.analyzeUrl,
|
|
||||||
renderParameters: false,
|
|
||||||
onData(data) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = data.save_disabled;
|
|
||||||
}
|
|
||||||
updateAnalysisNote(data);
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
|
||||||
analysis_error: error.message,
|
|
||||||
analysis_rows: [],
|
|
||||||
});
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = true;
|
|
||||||
}
|
|
||||||
updateAnalysisNote({ analysis_error: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{{- super() -}}
|
|
||||||
<style>
|
|
||||||
.query-list-page {
|
|
||||||
max-width: 64rem;
|
|
||||||
}
|
|
||||||
.query-list-filters {
|
|
||||||
margin: 0.5rem 0 0.75rem;
|
|
||||||
}
|
|
||||||
.query-list-search {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.query-list-search label {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.query-list-search input[type=search] {
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex: 1 1 18rem;
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
.query-list-search button[type=submit] {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
height: 2rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 0.35rem 0.65rem;
|
|
||||||
}
|
|
||||||
.query-list-facets {
|
|
||||||
align-items: flex-start;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem 1.6rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.query-list-facet {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.query-list-facet h2 {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
}
|
|
||||||
.query-list-facet ul {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.query-list-facet-link,
|
|
||||||
.query-list-facet-link:link,
|
|
||||||
.query-list-facet-link:visited,
|
|
||||||
.query-list-facet-link:hover,
|
|
||||||
.query-list-facet-link:focus,
|
|
||||||
.query-list-facet-link:active {
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid #c8d1dc;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: #39445a;
|
|
||||||
display: inline-flex;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
gap: 0.4rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
padding: 0.35rem 0.55rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.query-list-facet-link:hover {
|
|
||||||
border-color: #7ca5c8;
|
|
||||||
color: #1f5d85;
|
|
||||||
}
|
|
||||||
.query-list-facet-link-active {
|
|
||||||
background-color: #edf6fb;
|
|
||||||
border-color: #6d9fc0;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-list-facet-disabled {
|
|
||||||
color: #7b8794;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.query-list-facet-count {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.query-list-results {
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0.25rem 0 1rem;
|
|
||||||
min-width: 42rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.query-list-results th,
|
|
||||||
.query-list-results td {
|
|
||||||
border-bottom: 1px solid #d7dde5;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.query-list-results th {
|
|
||||||
background-color: #edf6fb;
|
|
||||||
border-top: 1px solid #d7dde5;
|
|
||||||
color: #39445a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-list-results tbody tr:nth-child(even) {
|
|
||||||
background-color: rgba(39, 104, 144, 0.05);
|
|
||||||
}
|
|
||||||
.query-list-results a.query-list-title {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.query-list-description {
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin: 0.15rem 0 0;
|
|
||||||
}
|
|
||||||
.query-list-owner {
|
|
||||||
color: #39445a;
|
|
||||||
font-family: var(--font-monospace, monospace);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.query-list-flags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
.query-list-pill {
|
|
||||||
background-color: #eef1f5;
|
|
||||||
border: 1px solid #d7dde5;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: #39445a;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.25rem 0.4rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.query-list-pill-write {
|
|
||||||
background-color: #fff4db;
|
|
||||||
border-color: #e2b64e;
|
|
||||||
}
|
|
||||||
.query-list-pill-public {
|
|
||||||
background-color: #e7f5ec;
|
|
||||||
border-color: #9ecfab;
|
|
||||||
color: #267a3e;
|
|
||||||
}
|
|
||||||
.query-list-pill-private {
|
|
||||||
background-color: #f7edf0;
|
|
||||||
border-color: #dbb8c1;
|
|
||||||
}
|
|
||||||
.query-list-pill-trusted {
|
|
||||||
background-color: #e7f5ec;
|
|
||||||
border-color: #9ecfab;
|
|
||||||
color: #267a3e;
|
|
||||||
}
|
|
||||||
.query-list-empty {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
.query-list-footnotes {
|
|
||||||
border-top: 1px solid #d7dde5;
|
|
||||||
color: #4f5b6d;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
margin: 0.35rem 0 1rem;
|
|
||||||
padding-top: 0.55rem;
|
|
||||||
}
|
|
||||||
.query-list-footnotes p {
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
.query-list-footnotes .query-list-pill {
|
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
.query-list-pagination a {
|
|
||||||
border: 1px solid #007bff;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
}
|
|
||||||
.query-list-pagination-bottom {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.query-list-search input[type=search] {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
|
|
||||||
|
|
||||||
{% block crumbs %}
|
|
||||||
{{ crumbs.nav(request=request, database=database) }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="query-list-page">
|
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
|
|
||||||
|
|
||||||
<form class="query-list-filters core" action="{{ query_list_path }}" method="get">
|
|
||||||
<p class="query-list-search">
|
|
||||||
<label for="query-search">Search</label>
|
|
||||||
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
|
|
||||||
{% if filters.is_write %}<input type="hidden" name="is_write" value="{{ filters.is_write }}">{% endif %}
|
|
||||||
{% if filters.is_private %}<input type="hidden" name="is_private" value="{{ filters.is_private }}">{% endif %}
|
|
||||||
{% if filters.source %}<input type="hidden" name="source" value="{{ filters.source }}">{% endif %}
|
|
||||||
{% if filters.owner_id %}<input type="hidden" name="owner_id" value="{{ filters.owner_id }}">{% endif %}
|
|
||||||
<button type="submit">Search</button>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<nav class="query-list-facets" aria-label="Query filters">
|
|
||||||
{% for facet in facets %}
|
|
||||||
<section class="query-list-facet">
|
|
||||||
<h2>{{ facet.title }}</h2>
|
|
||||||
<ul>
|
|
||||||
{% for item in facet["items"] %}
|
|
||||||
<li>{% if item.href %}<a class="query-list-facet-link{% if item.active %} query-list-facet-link-active{% endif %}" href="{{ item.href }}"{% if item.active %} aria-current="true"{% endif %}>{% else %}<span class="query-list-facet-link query-list-facet-disabled">{% endif %}<span>{{ item.label }}</span><span class="query-list-facet-count">{{ item.count }}</span>{% if item.href %}</a>{% else %}</span>{% endif %}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{% endfor %}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{% if queries %}
|
|
||||||
<div class="table-wrapper"><table class="query-list-results">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{% if show_database %}<th scope="col">Database</th>{% endif %}
|
|
||||||
<th scope="col">Query</th>
|
|
||||||
<th scope="col">Owner</th>
|
|
||||||
<th scope="col">Flags</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for query in queries %}
|
|
||||||
<tr>
|
|
||||||
{% if show_database %}
|
|
||||||
<td><a class="query-list-database" href="{{ urls.database(query.database) }}">{{ query.database }}</a></td>
|
|
||||||
{% endif %}
|
|
||||||
<td>
|
|
||||||
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
|
|
||||||
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="query-list-owner">{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}<span class="query-list-empty">-</span>{% endif %}</td>
|
|
||||||
<td>
|
|
||||||
<span class="query-list-flags">
|
|
||||||
{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}
|
|
||||||
{% if query.is_private %}<span class="query-list-pill query-list-pill-private">Private</span>{% endif %}
|
|
||||||
{% if query.is_trusted %}<span class="query-list-pill query-list-pill-trusted">Trusted</span>{% endif %}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
{% if show_private_note or show_trusted_note %}
|
|
||||||
<div class="query-list-footnotes">
|
|
||||||
{% if show_private_note %}<p><span class="query-list-pill query-list-pill-private">Private</span>Only the owning actor can view this query.</p>{% endif %}
|
|
||||||
{% if show_trusted_note %}<p><span class="query-list-pill query-list-pill-trusted">Trusted</span>Execution skips the usual SQL and write permission checks after view-query allows access.</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<p>No queries found.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if next_url %}
|
|
||||||
<nav class="query-list-pagination query-list-pagination-bottom" aria-label="Query pagination"><a href="{{ next_url }}">Next page</a></nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -141,6 +141,7 @@
|
||||||
{% if all_columns %}
|
{% if all_columns %}
|
||||||
<column-chooser></column-chooser>
|
<column-chooser></column-chooser>
|
||||||
<button class="choose-columns-mobile small-screen-only" onclick="openColumnChooser()">Choose columns</button>
|
<button class="choose-columns-mobile small-screen-only" onclick="openColumnChooser()">Choose columns</button>
|
||||||
|
{% if display_rows %}
|
||||||
<button type="button" class="column-actions-mobile small-screen-only">
|
<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">
|
<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>
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
|
@ -148,6 +149,7 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span>Column actions</span>
|
<span>Column actions</span>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }};
|
window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -52,38 +52,6 @@ class TokenRestrictions:
|
||||||
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
||||||
return self
|
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:
|
class TokenHandler:
|
||||||
"""
|
"""
|
||||||
|
|
@ -136,12 +104,31 @@ class SignedTokenHandler(TokenHandler):
|
||||||
|
|
||||||
token = {"a": actor_id, "t": int(time.time())}
|
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:
|
if expires_after:
|
||||||
token["d"] = expires_after
|
token["d"] = expires_after
|
||||||
if restrictions is not None:
|
if restrictions and (
|
||||||
abbreviated = restrictions.abbreviated(datasette)
|
restrictions.all or restrictions.database or restrictions.resource
|
||||||
if abbreviated is not None:
|
):
|
||||||
token["_r"] = abbreviated
|
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"))
|
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
||||||
|
|
||||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||||
|
|
|
||||||
|
|
@ -155,15 +155,9 @@ Column = namedtuple(
|
||||||
functions_marked_as_documented = []
|
functions_marked_as_documented = []
|
||||||
|
|
||||||
|
|
||||||
def documented(fn=None, *, label=None):
|
def documented(fn):
|
||||||
def decorate(fn):
|
functions_marked_as_documented.append(fn)
|
||||||
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
|
return fn
|
||||||
functions_marked_as_documented.append(fn)
|
|
||||||
return fn
|
|
||||||
|
|
||||||
if fn is None:
|
|
||||||
return decorate
|
|
||||||
return decorate(fn)
|
|
||||||
|
|
||||||
|
|
||||||
@documented
|
@documented
|
||||||
|
|
|
||||||
|
|
@ -241,14 +241,6 @@ async def _build_single_action_sql(
|
||||||
"),",
|
"),",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
query_parts.extend(
|
|
||||||
[
|
|
||||||
"anon_rules AS (",
|
|
||||||
" SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0",
|
|
||||||
"),",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Continue with the cascading logic
|
# Continue with the cascading logic
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
|
|
|
||||||
|
|
@ -112,28 +112,6 @@ async def initialize_metadata_tables(db):
|
||||||
config TEXT,
|
config TEXT,
|
||||||
PRIMARY KEY (database_name, resource_name, column_name)
|
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);
|
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import re
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
using_pysqlite3 = False
|
using_pysqlite3 = False
|
||||||
try:
|
try:
|
||||||
import pysqlite3 as sqlite3
|
import pysqlite3 as sqlite3
|
||||||
|
|
@ -13,18 +10,6 @@ if hasattr(sqlite3, "enable_callback_tracebacks"):
|
||||||
sqlite3.enable_callback_tracebacks(True)
|
sqlite3.enable_callback_tracebacks(True)
|
||||||
|
|
||||||
_cached_sqlite_version = None
|
_cached_sqlite_version = None
|
||||||
SQLiteTableType = Literal["table", "view", "virtual", "shadow"]
|
|
||||||
_VIRTUAL_TABLE_MODULE_RE = re.compile(
|
|
||||||
r"\bCREATE\s+VIRTUAL\s+TABLE\b.*?\bUSING\s+([^\s(]+)",
|
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
_VIRTUAL_TABLE_SHADOW_SUFFIXES = {
|
|
||||||
"fts3": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
|
||||||
"fts4": ("_content", "_segdir", "_segments", "_stat", "_docsize"),
|
|
||||||
"fts5": ("_data", "_idx", "_docsize", "_content", "_config"),
|
|
||||||
"rtree": ("_node", "_parent", "_rowid"),
|
|
||||||
"rtree_i32": ("_node", "_parent", "_rowid"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_version():
|
def sqlite_version():
|
||||||
|
|
@ -51,131 +36,5 @@ def supports_table_xinfo():
|
||||||
return sqlite_version() >= (3, 26, 0)
|
return sqlite_version() >= (3, 26, 0)
|
||||||
|
|
||||||
|
|
||||||
def supports_table_list():
|
|
||||||
return sqlite_version() >= (3, 37, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def supports_generated_columns():
|
def supports_generated_columns():
|
||||||
return sqlite_version() >= (3, 31, 0)
|
return sqlite_version() >= (3, 31, 0)
|
||||||
|
|
||||||
|
|
||||||
def sqlite_table_type(
|
|
||||||
conn,
|
|
||||||
table: str,
|
|
||||||
*,
|
|
||||||
schema: str | None = "main",
|
|
||||||
) -> SQLiteTableType | None:
|
|
||||||
if supports_table_list():
|
|
||||||
try:
|
|
||||||
query = "select type from pragma_table_list where name = ?"
|
|
||||||
params: tuple[str, ...] = (table,)
|
|
||||||
if schema is not None:
|
|
||||||
query += " and schema = ?"
|
|
||||||
params = (table, schema)
|
|
||||||
row = conn.execute(query, params).fetchone()
|
|
||||||
if row is not None and row[0] in {"table", "view", "virtual", "shadow"}:
|
|
||||||
return row[0]
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
pass
|
|
||||||
return _sqlite_table_type_from_schema(conn, table, schema=schema)
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]:
|
|
||||||
schema_table = _sqlite_schema_table(schema)
|
|
||||||
try:
|
|
||||||
rows = conn.execute(
|
|
||||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
|
||||||
).fetchall()
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return []
|
|
||||||
hidden_tables = []
|
|
||||||
content_fts_tables = []
|
|
||||||
for name, sql in rows:
|
|
||||||
if (
|
|
||||||
name in {"sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"}
|
|
||||||
or name.startswith("_")
|
|
||||||
or sqlite_table_type(conn, name, schema=schema) == "shadow"
|
|
||||||
):
|
|
||||||
hidden_tables.append(name)
|
|
||||||
elif _is_fts_content_virtual_table(sql):
|
|
||||||
content_fts_tables.append(name)
|
|
||||||
return sorted(hidden_tables) + content_fts_tables
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_table_type_from_schema(
|
|
||||||
conn,
|
|
||||||
table: str,
|
|
||||||
*,
|
|
||||||
schema: str | None = "main",
|
|
||||||
) -> SQLiteTableType | None:
|
|
||||||
schema_table = _sqlite_schema_table(schema)
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"select type, sql from {} where name = ?".format(schema_table),
|
|
||||||
(table,),
|
|
||||||
).fetchone()
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return None
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
object_type, sql = row
|
|
||||||
if object_type == "view":
|
|
||||||
return "view"
|
|
||||||
if object_type != "table":
|
|
||||||
return None
|
|
||||||
if _virtual_table_module(sql) is not None:
|
|
||||||
return "virtual"
|
|
||||||
if _is_known_shadow_table(conn, table, schema=schema):
|
|
||||||
return "shadow"
|
|
||||||
return "table"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_known_shadow_table(
|
|
||||||
conn,
|
|
||||||
table: str,
|
|
||||||
*,
|
|
||||||
schema: str | None = "main",
|
|
||||||
) -> bool:
|
|
||||||
schema_table = _sqlite_schema_table(schema)
|
|
||||||
try:
|
|
||||||
rows = conn.execute(
|
|
||||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
|
||||||
).fetchall()
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return False
|
|
||||||
for virtual_table, sql in rows:
|
|
||||||
module = _virtual_table_module(sql)
|
|
||||||
if module is None:
|
|
||||||
continue
|
|
||||||
for suffix in _VIRTUAL_TABLE_SHADOW_SUFFIXES.get(module, ()):
|
|
||||||
if table == virtual_table + suffix:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_schema_table(schema: str | None) -> str:
|
|
||||||
if schema is None or schema == "main":
|
|
||||||
return "sqlite_master"
|
|
||||||
if schema == "temp":
|
|
||||||
return "sqlite_temp_master"
|
|
||||||
return "{}.sqlite_master".format(_quote_identifier(schema))
|
|
||||||
|
|
||||||
|
|
||||||
def _quote_identifier(value: str) -> str:
|
|
||||||
return '"{}"'.format(value.replace('"', '""'))
|
|
||||||
|
|
||||||
|
|
||||||
def _virtual_table_module(sql: str | None) -> str | None:
|
|
||||||
if not sql:
|
|
||||||
return None
|
|
||||||
match = _VIRTUAL_TABLE_MODULE_RE.search(sql)
|
|
||||||
if match is None:
|
|
||||||
return None
|
|
||||||
return match.group(1).strip("\"'[]`").lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fts_content_virtual_table(sql: str | None) -> bool:
|
|
||||||
return (
|
|
||||||
_virtual_table_module(sql) in {"fts3", "fts4", "fts5"}
|
|
||||||
and "content=" in sql.lower()
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
__version__ = "1.0a31"
|
__version__ = "1.0a28"
|
||||||
__version_info__ = tuple(__version__.split("."))
|
__version_info__ = tuple(__version__.split("."))
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import textwrap
|
||||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.resources import DatabaseResource, QueryResource
|
from datasette.resources import DatabaseResource, QueryResource
|
||||||
from datasette.stored_queries import stored_query_to_dict
|
|
||||||
from datasette.write_sql import QueryWriteRejected
|
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
|
|
@ -37,7 +35,6 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
||||||
from .base import BaseView, DatasetteError, View, _error, stream_csv
|
from .base import BaseView, DatasetteError, View, _error, stream_csv
|
||||||
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
|
||||||
from . import Context
|
from . import Context
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,10 +61,8 @@ class DatabaseView(View):
|
||||||
if request.url_vars.get("format"):
|
if request.url_vars.get("format"):
|
||||||
redirect_url += "." + request.url_vars.get("format")
|
redirect_url += "." + request.url_vars.get("format")
|
||||||
redirect_url += "?" + request.query_string
|
redirect_url += "?" + request.query_string
|
||||||
response = Response.redirect(redirect_url)
|
return Response.redirect(redirect_url)
|
||||||
if datasette.cors:
|
return await QueryView()(request, datasette)
|
||||||
add_cors_headers(response.headers)
|
|
||||||
return response
|
|
||||||
|
|
||||||
if format_ not in ("html", "json"):
|
if format_ not in ("html", "json"):
|
||||||
raise NotFound("Invalid format: {}".format(format_))
|
raise NotFound("Invalid format: {}".format(format_))
|
||||||
|
|
@ -95,20 +90,25 @@ class DatabaseView(View):
|
||||||
|
|
||||||
tables = await get_tables(datasette, request, db, allowed_dict)
|
tables = await get_tables(datasette, request, db, allowed_dict)
|
||||||
|
|
||||||
queries_page = await datasette.list_queries(
|
# Get allowed queries using the new permission system
|
||||||
database,
|
allowed_query_page = await datasette.allowed_resources(
|
||||||
actor=request.actor,
|
"view-query",
|
||||||
limit=5,
|
request.actor,
|
||||||
include_private=True,
|
parent=database,
|
||||||
)
|
include_is_private=True,
|
||||||
stored_queries = queries_page.queries
|
limit=1000,
|
||||||
queries_more = queries_page.has_more
|
|
||||||
queries_count = (
|
|
||||||
await datasette.count_queries(database, actor=request.actor)
|
|
||||||
if queries_more
|
|
||||||
else len(stored_queries)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build canned_queries list by looking up each allowed query
|
||||||
|
all_queries = await datasette.get_canned_queries(database, request.actor)
|
||||||
|
canned_queries = []
|
||||||
|
for query_resource in allowed_query_page.resources:
|
||||||
|
query_name = query_resource.child
|
||||||
|
if query_name in all_queries:
|
||||||
|
canned_queries.append(
|
||||||
|
dict(all_queries[query_name], private=query_resource.private)
|
||||||
|
)
|
||||||
|
|
||||||
async def database_actions():
|
async def database_actions():
|
||||||
links = []
|
links = []
|
||||||
for hook in pm.hook.database_actions(
|
for hook in pm.hook.database_actions(
|
||||||
|
|
@ -138,9 +138,7 @@ class DatabaseView(View):
|
||||||
"tables": tables,
|
"tables": tables,
|
||||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
"hidden_count": len([t for t in tables if t["hidden"]]),
|
||||||
"views": sql_views,
|
"views": sql_views,
|
||||||
"queries": [stored_query_to_dict(query) for query in stored_queries],
|
"queries": canned_queries,
|
||||||
"queries_more": queries_more,
|
|
||||||
"queries_count": queries_count,
|
|
||||||
"allow_execute_sql": allow_execute_sql,
|
"allow_execute_sql": allow_execute_sql,
|
||||||
"table_columns": (
|
"table_columns": (
|
||||||
await _table_columns(datasette, database) if allow_execute_sql else {}
|
await _table_columns(datasette, database) if allow_execute_sql else {}
|
||||||
|
|
@ -173,9 +171,7 @@ class DatabaseView(View):
|
||||||
tables=tables,
|
tables=tables,
|
||||||
hidden_count=len([t for t in tables if t["hidden"]]),
|
hidden_count=len([t for t in tables if t["hidden"]]),
|
||||||
views=sql_views,
|
views=sql_views,
|
||||||
queries=stored_queries,
|
queries=canned_queries,
|
||||||
queries_more=queries_more,
|
|
||||||
queries_count=queries_count,
|
|
||||||
allow_execute_sql=allow_execute_sql,
|
allow_execute_sql=allow_execute_sql,
|
||||||
table_columns=(
|
table_columns=(
|
||||||
await _table_columns(datasette, database)
|
await _table_columns(datasette, database)
|
||||||
|
|
@ -223,11 +219,7 @@ class DatabaseContext(Context):
|
||||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||||
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
||||||
views: list = field(metadata={"help": "List of view objects in the database"})
|
views: list = field(metadata={"help": "List of view objects in the database"})
|
||||||
queries: list = field(metadata={"help": "List of stored query objects"})
|
queries: list = field(metadata={"help": "List of canned query objects"})
|
||||||
queries_more: bool = field(
|
|
||||||
metadata={"help": "Boolean indicating if more stored queries are available"}
|
|
||||||
)
|
|
||||||
queries_count: int = field(metadata={"help": "Count of visible stored queries"})
|
|
||||||
allow_execute_sql: bool = field(
|
allow_execute_sql: bool = field(
|
||||||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||||
)
|
)
|
||||||
|
|
@ -272,8 +264,8 @@ class QueryContext(Context):
|
||||||
query: dict = field(
|
query: dict = field(
|
||||||
metadata={"help": "The SQL query object containing the `sql` string"}
|
metadata={"help": "The SQL query object containing the `sql` string"}
|
||||||
)
|
)
|
||||||
stored_query: str = field(
|
canned_query: str = field(
|
||||||
metadata={"help": "The name of the stored query if this is a stored query"}
|
metadata={"help": "The name of the canned query if this is a canned query"}
|
||||||
)
|
)
|
||||||
private: bool = field(
|
private: bool = field(
|
||||||
metadata={"help": "Boolean indicating if this is a private database"}
|
metadata={"help": "Boolean indicating if this is a private database"}
|
||||||
|
|
@ -281,13 +273,13 @@ class QueryContext(Context):
|
||||||
# urls: dict = field(
|
# urls: dict = field(
|
||||||
# metadata={"help": "Object containing URL helpers like `database()`"}
|
# metadata={"help": "Object containing URL helpers like `database()`"}
|
||||||
# )
|
# )
|
||||||
stored_query_write: bool = field(
|
canned_query_write: bool = field(
|
||||||
metadata={
|
metadata={
|
||||||
"help": "Boolean indicating if this is a stored query that allows writes"
|
"help": "Boolean indicating if this is a canned query that allows writes"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
metadata: dict = field(
|
metadata: dict = field(
|
||||||
metadata={"help": "Metadata about the database or the stored query"}
|
metadata={"help": "Metadata about the database or the canned query"}
|
||||||
)
|
)
|
||||||
db_is_immutable: bool = field(
|
db_is_immutable: bool = field(
|
||||||
metadata={"help": "Boolean indicating if this database is immutable"}
|
metadata={"help": "Boolean indicating if this database is immutable"}
|
||||||
|
|
@ -308,15 +300,12 @@ class QueryContext(Context):
|
||||||
allow_execute_sql: bool = field(
|
allow_execute_sql: bool = field(
|
||||||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||||
)
|
)
|
||||||
save_query_url: str = field(
|
|
||||||
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
|
||||||
)
|
|
||||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||||
named_parameter_values: dict = field(
|
named_parameter_values: dict = field(
|
||||||
metadata={"help": "Dictionary of parameter names/values"}
|
metadata={"help": "Dictionary of parameter names/values"}
|
||||||
)
|
)
|
||||||
edit_sql_url: str = field(
|
edit_sql_url: str = field(
|
||||||
metadata={"help": "URL to edit the SQL for a stored query"}
|
metadata={"help": "URL to edit the SQL for a canned query"}
|
||||||
)
|
)
|
||||||
display_rows: list = field(metadata={"help": "List of result rows to display"})
|
display_rows: list = field(metadata={"help": "List of result rows to display"})
|
||||||
columns: list = field(metadata={"help": "List of column names"})
|
columns: list = field(metadata={"help": "List of column names"})
|
||||||
|
|
@ -340,8 +329,8 @@ class QueryContext(Context):
|
||||||
top_query: callable = field(
|
top_query: callable = field(
|
||||||
metadata={"help": "Callable to render the top_query slot"}
|
metadata={"help": "Callable to render the top_query slot"}
|
||||||
)
|
)
|
||||||
top_stored_query: callable = field(
|
top_canned_query: callable = field(
|
||||||
metadata={"help": "Callable to render the top_stored_query slot"}
|
metadata={"help": "Callable to render the top_canned_query slot"}
|
||||||
)
|
)
|
||||||
query_actions: callable = field(
|
query_actions: callable = field(
|
||||||
metadata={
|
metadata={
|
||||||
|
|
@ -432,47 +421,21 @@ class QueryView(View):
|
||||||
|
|
||||||
db = await datasette.resolve_database(request)
|
db = await datasette.resolve_database(request)
|
||||||
|
|
||||||
# We must be a stored query
|
# We must be a canned query
|
||||||
table_found = False
|
table_found = False
|
||||||
try:
|
try:
|
||||||
await datasette.resolve_table(request)
|
await datasette.resolve_table(request)
|
||||||
table_found = True
|
table_found = True
|
||||||
except TableNotFound as table_not_found:
|
except TableNotFound as table_not_found:
|
||||||
stored_query = await datasette.get_query(
|
canned_query = await datasette.get_canned_query(
|
||||||
table_not_found.database_name, table_not_found.table
|
table_not_found.database_name, table_not_found.table, request.actor
|
||||||
)
|
)
|
||||||
if stored_query is None:
|
if canned_query is None:
|
||||||
raise
|
raise
|
||||||
if table_found:
|
if table_found:
|
||||||
# That should not have happened
|
# That should not have happened
|
||||||
raise DatasetteError("Unexpected table found on POST", status=404)
|
raise DatasetteError("Unexpected table found on POST", status=404)
|
||||||
|
|
||||||
if not await datasette.allowed(
|
|
||||||
action="view-query",
|
|
||||||
resource=QueryResource(database=db.name, query=stored_query.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
raise Forbidden("You do not have permission to view this query")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _ensure_stored_query_execution_permissions(
|
|
||||||
datasette, db, stored_query, request.actor
|
|
||||||
)
|
|
||||||
except QueryWriteRejected as ex:
|
|
||||||
if request.headers.get("accept") == "application/json" or request.args.get(
|
|
||||||
"_json"
|
|
||||||
):
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"ok": False,
|
|
||||||
"message": ex.message,
|
|
||||||
"redirect": None,
|
|
||||||
},
|
|
||||||
status=403,
|
|
||||||
)
|
|
||||||
datasette.add_message(request, ex.message, datasette.ERROR)
|
|
||||||
return Response.redirect(stored_query.on_error_redirect or request.path)
|
|
||||||
|
|
||||||
# If database is immutable, return an error
|
# If database is immutable, return an error
|
||||||
if not db.is_mutable:
|
if not db.is_mutable:
|
||||||
raise Forbidden("Database is immutable")
|
raise Forbidden("Database is immutable")
|
||||||
|
|
@ -497,18 +460,20 @@ class QueryView(View):
|
||||||
or request.args.get("_json")
|
or request.args.get("_json")
|
||||||
or params.get("_json")
|
or params.get("_json")
|
||||||
)
|
)
|
||||||
params_for_query = MagicParameters(stored_query.sql, params, request, datasette)
|
params_for_query = MagicParameters(
|
||||||
|
canned_query["sql"], params, request, datasette
|
||||||
|
)
|
||||||
await params_for_query.execute_params()
|
await params_for_query.execute_params()
|
||||||
ok = None
|
ok = None
|
||||||
redirect_url = None
|
redirect_url = None
|
||||||
try:
|
try:
|
||||||
cursor = await db.execute_write(
|
cursor = await db.execute_write(
|
||||||
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
|
# success message can come from on_success_message or on_success_message_sql
|
||||||
message = None
|
message = None
|
||||||
message_type = datasette.INFO
|
message_type = datasette.INFO
|
||||||
on_success_message_sql = stored_query.on_success_message_sql
|
on_success_message_sql = canned_query.get("on_success_message_sql")
|
||||||
if on_success_message_sql:
|
if on_success_message_sql:
|
||||||
try:
|
try:
|
||||||
message_result = (
|
message_result = (
|
||||||
|
|
@ -520,19 +485,18 @@ class QueryView(View):
|
||||||
message = "Error running on_success_message_sql: {}".format(ex)
|
message = "Error running on_success_message_sql: {}".format(ex)
|
||||||
message_type = datasette.ERROR
|
message_type = datasette.ERROR
|
||||||
if not message:
|
if not message:
|
||||||
message = (
|
message = canned_query.get(
|
||||||
stored_query.on_success_message
|
"on_success_message"
|
||||||
or "Query executed, {} row{} affected".format(
|
) or "Query executed, {} row{} affected".format(
|
||||||
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
redirect_url = stored_query.on_success_redirect
|
redirect_url = canned_query.get("on_success_redirect")
|
||||||
ok = True
|
ok = True
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
message = stored_query.on_error_message or str(ex)
|
message = canned_query.get("on_error_message") or str(ex)
|
||||||
message_type = datasette.ERROR
|
message_type = datasette.ERROR
|
||||||
redirect_url = stored_query.on_error_redirect
|
redirect_url = canned_query.get("on_error_redirect")
|
||||||
ok = False
|
ok = False
|
||||||
if should_return_json:
|
if should_return_json:
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|
@ -565,35 +529,31 @@ class QueryView(View):
|
||||||
# Create lookup dict for quick access
|
# Create lookup dict for quick access
|
||||||
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
|
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
|
||||||
|
|
||||||
# Are we a stored query?
|
# Are we a canned query?
|
||||||
stored_query = None
|
canned_query = None
|
||||||
stored_query_write = False
|
canned_query_write = False
|
||||||
if "table" in request.url_vars:
|
if "table" in request.url_vars:
|
||||||
try:
|
try:
|
||||||
await datasette.resolve_table(request)
|
await datasette.resolve_table(request)
|
||||||
except TableNotFound as table_not_found:
|
except TableNotFound as table_not_found:
|
||||||
# Was this actually a stored query?
|
# Was this actually a canned query?
|
||||||
stored_query = await datasette.get_query(
|
canned_query = await datasette.get_canned_query(
|
||||||
table_not_found.database_name, table_not_found.table
|
table_not_found.database_name, table_not_found.table, request.actor
|
||||||
)
|
)
|
||||||
if stored_query is None:
|
if canned_query is None:
|
||||||
raise
|
raise
|
||||||
stored_query_write = stored_query.is_write
|
canned_query_write = bool(canned_query.get("write"))
|
||||||
|
|
||||||
private = False
|
private = False
|
||||||
if stored_query:
|
if canned_query:
|
||||||
# Respect stored query permissions
|
# Respect canned query permissions
|
||||||
visible, private = await datasette.check_visibility(
|
visible, private = await datasette.check_visibility(
|
||||||
request.actor,
|
request.actor,
|
||||||
action="view-query",
|
action="view-query",
|
||||||
resource=QueryResource(database=database, query=stored_query.name),
|
resource=QueryResource(database=database, query=canned_query["name"]),
|
||||||
)
|
)
|
||||||
if not visible:
|
if not visible:
|
||||||
raise Forbidden("You do not have permission to view this query")
|
raise Forbidden("You do not have permission to view this query")
|
||||||
if not stored_query_write:
|
|
||||||
await _ensure_stored_query_execution_permissions(
|
|
||||||
datasette, db, stored_query, request.actor
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await datasette.ensure_permission(
|
await datasette.ensure_permission(
|
||||||
|
|
@ -606,16 +566,16 @@ class QueryView(View):
|
||||||
params = {key: request.args.get(key) for key in request.args}
|
params = {key: request.args.get(key) for key in request.args}
|
||||||
sql = None
|
sql = None
|
||||||
|
|
||||||
if stored_query:
|
if canned_query:
|
||||||
sql = stored_query.sql
|
sql = canned_query["sql"]
|
||||||
elif "sql" in params:
|
elif "sql" in params:
|
||||||
sql = params.pop("sql")
|
sql = params.pop("sql")
|
||||||
|
|
||||||
# Extract any :named parameters
|
# Extract any :named parameters
|
||||||
named_parameters = []
|
named_parameters = []
|
||||||
if stored_query and stored_query.parameters:
|
if canned_query and canned_query.get("params"):
|
||||||
named_parameters = stored_query.parameters
|
named_parameters = canned_query["params"]
|
||||||
if not named_parameters and sql:
|
if not named_parameters:
|
||||||
named_parameters = derive_named_parameters(sql)
|
named_parameters = derive_named_parameters(sql)
|
||||||
named_parameter_values = {
|
named_parameter_values = {
|
||||||
named_parameter: params.get(named_parameter) or ""
|
named_parameter: params.get(named_parameter) or ""
|
||||||
|
|
@ -640,13 +600,13 @@ class QueryView(View):
|
||||||
|
|
||||||
params_for_query = params
|
params_for_query = params
|
||||||
|
|
||||||
if sql and not stored_query_write:
|
if not canned_query_write:
|
||||||
try:
|
try:
|
||||||
if not stored_query:
|
if not canned_query:
|
||||||
# For regular queries we only allow SELECT, plus other rules
|
# For regular queries we only allow SELECT, plus other rules
|
||||||
validate_sql_select(sql)
|
validate_sql_select(sql)
|
||||||
else:
|
else:
|
||||||
# Stored queries can run magic parameters
|
# Canned queries can run magic parameters
|
||||||
params_for_query = MagicParameters(sql, params, request, datasette)
|
params_for_query = MagicParameters(sql, params, request, datasette)
|
||||||
await params_for_query.execute_params()
|
await params_for_query.execute_params()
|
||||||
results = await datasette.execute(
|
results = await datasette.execute(
|
||||||
|
|
@ -684,8 +644,6 @@ class QueryView(View):
|
||||||
|
|
||||||
# Handle formats from plugins
|
# Handle formats from plugins
|
||||||
if format_ == "csv":
|
if format_ == "csv":
|
||||||
if not sql:
|
|
||||||
raise DatasetteError("?sql= is required", status=400)
|
|
||||||
|
|
||||||
async def fetch_data_for_csv(request, _next=None):
|
async def fetch_data_for_csv(request, _next=None):
|
||||||
results = await db.execute(sql, params, truncate=True)
|
results = await db.execute(sql, params, truncate=True)
|
||||||
|
|
@ -702,7 +660,7 @@ class QueryView(View):
|
||||||
columns=columns,
|
columns=columns,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
sql=sql,
|
sql=sql,
|
||||||
query_name=stored_query.name if stored_query else None,
|
query_name=canned_query["name"] if canned_query else None,
|
||||||
database=database,
|
database=database,
|
||||||
table=None,
|
table=None,
|
||||||
request=request,
|
request=request,
|
||||||
|
|
@ -734,10 +692,10 @@ class QueryView(View):
|
||||||
elif format_ == "html":
|
elif format_ == "html":
|
||||||
headers = {}
|
headers = {}
|
||||||
templates = [f"query-{to_css_class(database)}.html", "query.html"]
|
templates = [f"query-{to_css_class(database)}.html", "query.html"]
|
||||||
if stored_query:
|
if canned_query:
|
||||||
templates.insert(
|
templates.insert(
|
||||||
0,
|
0,
|
||||||
f"query-{to_css_class(database)}-{to_css_class(stored_query.name)}.html",
|
f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html",
|
||||||
)
|
)
|
||||||
|
|
||||||
environment = datasette.get_jinja_environment(request)
|
environment = datasette.get_jinja_environment(request)
|
||||||
|
|
@ -755,9 +713,6 @@ class QueryView(View):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
metadata = await datasette.get_database_metadata(database)
|
metadata = await datasette.get_database_metadata(database)
|
||||||
if stored_query:
|
|
||||||
metadata = stored_query_to_dict(stored_query)
|
|
||||||
metadata.pop("source", None)
|
|
||||||
|
|
||||||
renderers = {}
|
renderers = {}
|
||||||
for key, (_, can_render) in datasette.renderers.items():
|
for key, (_, can_render) in datasette.renderers.items():
|
||||||
|
|
@ -784,14 +739,9 @@ class QueryView(View):
|
||||||
resource=DatabaseResource(database=database),
|
resource=DatabaseResource(database=database),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
allow_store_query = await datasette.allowed(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(database=database),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
show_hide_hidden = ""
|
show_hide_hidden = ""
|
||||||
if stored_query and stored_query.hide_sql:
|
if canned_query and canned_query.get("hide_sql"):
|
||||||
if bool(params.get("_show_sql")):
|
if bool(params.get("_show_sql")):
|
||||||
show_hide_link = path_with_removed_args(request, {"_show_sql"})
|
show_hide_link = path_with_removed_args(request, {"_show_sql"})
|
||||||
show_hide_text = "hide"
|
show_hide_text = "hide"
|
||||||
|
|
@ -819,38 +769,24 @@ class QueryView(View):
|
||||||
# - No magic parameters, so no :_ in the SQL string
|
# - No magic parameters, so no :_ in the SQL string
|
||||||
edit_sql_url = None
|
edit_sql_url = None
|
||||||
is_validated_sql = False
|
is_validated_sql = False
|
||||||
if sql:
|
try:
|
||||||
try:
|
validate_sql_select(sql)
|
||||||
validate_sql_select(sql)
|
is_validated_sql = True
|
||||||
is_validated_sql = True
|
except InvalidSql:
|
||||||
except InvalidSql:
|
pass
|
||||||
pass
|
if allow_execute_sql and is_validated_sql and ":_" not in sql:
|
||||||
if allow_execute_sql and is_validated_sql and ":_" not in sql:
|
edit_sql_url = (
|
||||||
edit_sql_url = (
|
|
||||||
datasette.urls.database(database)
|
|
||||||
+ "/-/query"
|
|
||||||
+ "?"
|
|
||||||
+ urlencode(
|
|
||||||
{
|
|
||||||
**{
|
|
||||||
"sql": sql,
|
|
||||||
},
|
|
||||||
**named_parameter_values,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
save_query_url = None
|
|
||||||
if (
|
|
||||||
not stored_query
|
|
||||||
and allow_execute_sql
|
|
||||||
and allow_store_query
|
|
||||||
and is_validated_sql
|
|
||||||
and ":_" not in sql
|
|
||||||
):
|
|
||||||
save_query_url = (
|
|
||||||
datasette.urls.database(database)
|
datasette.urls.database(database)
|
||||||
+ "/-/queries/store?"
|
+ "/-/query"
|
||||||
+ urlencode({"sql": sql})
|
+ "?"
|
||||||
|
+ urlencode(
|
||||||
|
{
|
||||||
|
**{
|
||||||
|
"sql": sql,
|
||||||
|
},
|
||||||
|
**named_parameter_values,
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def query_actions():
|
async def query_actions():
|
||||||
|
|
@ -859,7 +795,7 @@ class QueryView(View):
|
||||||
datasette=datasette,
|
datasette=datasette,
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
database=database,
|
database=database,
|
||||||
query_name=stored_query.name if stored_query else None,
|
query_name=canned_query["name"] if canned_query else None,
|
||||||
request=request,
|
request=request,
|
||||||
sql=sql,
|
sql=sql,
|
||||||
params=params,
|
params=params,
|
||||||
|
|
@ -879,17 +815,16 @@ class QueryView(View):
|
||||||
"sql": sql,
|
"sql": sql,
|
||||||
"params": params,
|
"params": params,
|
||||||
},
|
},
|
||||||
stored_query=stored_query.name if stored_query else None,
|
canned_query=canned_query["name"] if canned_query else None,
|
||||||
private=private,
|
private=private,
|
||||||
stored_query_write=stored_query_write,
|
canned_query_write=canned_query_write,
|
||||||
db_is_immutable=not db.is_mutable,
|
db_is_immutable=not db.is_mutable,
|
||||||
error=query_error,
|
error=query_error,
|
||||||
hide_sql=hide_sql,
|
hide_sql=hide_sql,
|
||||||
show_hide_link=datasette.urls.path(show_hide_link),
|
show_hide_link=datasette.urls.path(show_hide_link),
|
||||||
show_hide_text=show_hide_text,
|
show_hide_text=show_hide_text,
|
||||||
editable=not stored_query,
|
editable=not canned_query,
|
||||||
allow_execute_sql=allow_execute_sql,
|
allow_execute_sql=allow_execute_sql,
|
||||||
save_query_url=save_query_url,
|
|
||||||
tables=await get_tables(datasette, request, db, allowed_dict),
|
tables=await get_tables(datasette, request, db, allowed_dict),
|
||||||
named_parameter_values=named_parameter_values,
|
named_parameter_values=named_parameter_values,
|
||||||
edit_sql_url=edit_sql_url,
|
edit_sql_url=edit_sql_url,
|
||||||
|
|
@ -909,7 +844,7 @@ class QueryView(View):
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
show_hide_hidden=markupsafe.Markup(show_hide_hidden),
|
show_hide_hidden=markupsafe.Markup(show_hide_hidden),
|
||||||
metadata=metadata,
|
metadata=canned_query or metadata,
|
||||||
alternate_url_json=alternate_url_json,
|
alternate_url_json=alternate_url_json,
|
||||||
select_templates=[
|
select_templates=[
|
||||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||||
|
|
@ -918,12 +853,12 @@ class QueryView(View):
|
||||||
top_query=make_slot_function(
|
top_query=make_slot_function(
|
||||||
"top_query", datasette, request, database=database, sql=sql
|
"top_query", datasette, request, database=database, sql=sql
|
||||||
),
|
),
|
||||||
top_stored_query=make_slot_function(
|
top_canned_query=make_slot_function(
|
||||||
"top_stored_query",
|
"top_canned_query",
|
||||||
datasette,
|
datasette,
|
||||||
request,
|
request,
|
||||||
database=database,
|
database=database,
|
||||||
query_name=stored_query.name if stored_query else None,
|
query_name=canned_query["name"] if canned_query else None,
|
||||||
),
|
),
|
||||||
query_actions=query_actions,
|
query_actions=query_actions,
|
||||||
),
|
),
|
||||||
|
|
@ -1236,6 +1171,22 @@ class TableCreateView(BaseView):
|
||||||
return Response.json(details, status=201)
|
return Response.json(details, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_columns(datasette, database_name):
|
||||||
|
internal_db = datasette.get_internal_database()
|
||||||
|
result = await internal_db.execute(
|
||||||
|
"select table_name, name from catalog_columns where database_name = ?",
|
||||||
|
[database_name],
|
||||||
|
)
|
||||||
|
table_columns = {}
|
||||||
|
for row in result.rows:
|
||||||
|
table_columns.setdefault(row["table_name"], []).append(row["name"])
|
||||||
|
# Add views
|
||||||
|
db = datasette.get_database(database_name)
|
||||||
|
for view_name in await db.view_names():
|
||||||
|
table_columns[view_name] = []
|
||||||
|
return table_columns
|
||||||
|
|
||||||
|
|
||||||
async def display_rows(datasette, database, request, rows, columns):
|
async def display_rows(datasette, database, request, rows, columns):
|
||||||
display_rows = []
|
display_rows = []
|
||||||
truncate_cells = datasette.setting("truncate_cells_html")
|
truncate_cells = datasette.setting("truncate_cells_html")
|
||||||
|
|
|
||||||
|
|
@ -1,439 +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 .query_helpers import (
|
|
||||||
QueryValidationError,
|
|
||||||
_analysis_is_write,
|
|
||||||
_analysis_rows,
|
|
||||||
_analysis_rows_with_permissions,
|
|
||||||
_block_framing,
|
|
||||||
_coerce_execute_write_payload,
|
|
||||||
_derived_query_parameters,
|
|
||||||
_execute_write_analysis_data,
|
|
||||||
_execute_write_disabled_reason,
|
|
||||||
_inserted_row_url,
|
|
||||||
_json_or_form_payload,
|
|
||||||
_prepare_execute_write,
|
|
||||||
_table_columns,
|
|
||||||
_wants_json,
|
|
||||||
)
|
|
||||||
|
|
||||||
WRITE_TEMPLATE_LABELS = {
|
|
||||||
"insert": "Insert row",
|
|
||||||
"update": "Update rows",
|
|
||||||
"delete": "Delete rows",
|
|
||||||
}
|
|
||||||
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
|
|
||||||
|
|
||||||
|
|
||||||
def _parameter_names(columns):
|
|
||||||
seen = set()
|
|
||||||
names = {}
|
|
||||||
for column in columns:
|
|
||||||
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
|
||||||
base = base.strip("_") or "value"
|
|
||||||
if base[0].isdigit():
|
|
||||||
base = "p_{}".format(base)
|
|
||||||
name = base
|
|
||||||
index = 2
|
|
||||||
while name in seen:
|
|
||||||
name = "{}_{}".format(base, index)
|
|
||||||
index += 1
|
|
||||||
seen.add(name)
|
|
||||||
names[column] = name
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def _quote_identifier(identifier):
|
|
||||||
return '"{}"'.format(identifier.replace('"', '""'))
|
|
||||||
|
|
||||||
|
|
||||||
def _preferred_where_column(table, columns):
|
|
||||||
lower_table_id = "{}_id".format(table.lower())
|
|
||||||
return (
|
|
||||||
next((column for column in columns if column.lower() == "id"), None)
|
|
||||||
or next(
|
|
||||||
(column for column in columns if column.lower() == lower_table_id), None
|
|
||||||
)
|
|
||||||
or columns[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_incrementing_primary_key(columns):
|
|
||||||
primary_keys = [column for column in columns if column.is_pk]
|
|
||||||
if len(primary_keys) != 1:
|
|
||||||
return None
|
|
||||||
primary_key = primary_keys[0]
|
|
||||||
if primary_key.type and primary_key.type.lower() == "integer":
|
|
||||||
return primary_key.name
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_template_sql(table, columns):
|
|
||||||
column_names = [column.name for column in columns]
|
|
||||||
auto_pk = _auto_incrementing_primary_key(columns)
|
|
||||||
insert_columns = [column for column in column_names if column != auto_pk]
|
|
||||||
if not insert_columns:
|
|
||||||
return "insert into {}\ndefault values".format(_quote_identifier(table))
|
|
||||||
names = _parameter_names(insert_columns)
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"insert into {} (".format(_quote_identifier(table)),
|
|
||||||
",\n".join(
|
|
||||||
" {}".format(_quote_identifier(column)) for column in insert_columns
|
|
||||||
),
|
|
||||||
")",
|
|
||||||
"values (",
|
|
||||||
",\n".join(" :{}".format(names[column]) for column in insert_columns),
|
|
||||||
")",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_template_sql(table, columns):
|
|
||||||
column_names = [column.name for column in columns]
|
|
||||||
names = _parameter_names(column_names)
|
|
||||||
where_column = _preferred_where_column(table, column_names)
|
|
||||||
set_columns = [column for column in column_names if column != where_column]
|
|
||||||
if not set_columns:
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"update {}".format(_quote_identifier(table)),
|
|
||||||
"set {} = :new_{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
"where {} = :{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"update {}".format(_quote_identifier(table)),
|
|
||||||
"set "
|
|
||||||
+ ",\n".join(
|
|
||||||
"{}{} = :{}".format(
|
|
||||||
" " if index else "",
|
|
||||||
_quote_identifier(column),
|
|
||||||
names[column],
|
|
||||||
)
|
|
||||||
for index, column in enumerate(set_columns)
|
|
||||||
),
|
|
||||||
"where {} = :{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_template_sql(table, columns):
|
|
||||||
column_names = [column.name for column in columns]
|
|
||||||
names = _parameter_names(column_names)
|
|
||||||
where_column = _preferred_where_column(table, column_names)
|
|
||||||
return "\n".join(
|
|
||||||
(
|
|
||||||
"delete from {}".format(_quote_identifier(table)),
|
|
||||||
"where {} = :{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _template_sqls_for_table(table, columns):
|
|
||||||
return {
|
|
||||||
"insert": _insert_template_sql(table, columns),
|
|
||||||
"update": _update_template_sql(table, columns),
|
|
||||||
"delete": _delete_template_sql(table, columns),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _template_sql_allowed(datasette, db, sql, actor):
|
|
||||||
params = {parameter: "" for parameter in _derived_query_parameters(sql)}
|
|
||||||
try:
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return False
|
|
||||||
if not _analysis_is_write(analysis):
|
|
||||||
return False
|
|
||||||
analysis_rows = await _analysis_rows_with_permissions(datasette, analysis, actor)
|
|
||||||
return _execute_write_disabled_reason(sql, None, analysis_rows) is None
|
|
||||||
|
|
||||||
|
|
||||||
async def _write_template_tables(
|
|
||||||
datasette, db, table_columns, hidden_table_names, actor
|
|
||||||
):
|
|
||||||
write_template_tables = {}
|
|
||||||
for table in table_columns:
|
|
||||||
if table in hidden_table_names or not table_columns[table]:
|
|
||||||
continue
|
|
||||||
column_details = [
|
|
||||||
column
|
|
||||||
for column in await db.table_column_details(table)
|
|
||||||
if not column.hidden
|
|
||||||
]
|
|
||||||
if not column_details:
|
|
||||||
continue
|
|
||||||
templates = {}
|
|
||||||
for operation, sql in _template_sqls_for_table(table, column_details).items():
|
|
||||||
if await _template_sql_allowed(datasette, db, sql, actor):
|
|
||||||
templates[operation] = sql
|
|
||||||
if templates:
|
|
||||||
write_template_tables[table] = {
|
|
||||||
"templates": templates,
|
|
||||||
}
|
|
||||||
return write_template_tables
|
|
||||||
|
|
||||||
|
|
||||||
def _write_template_operations(write_template_tables):
|
|
||||||
operations = []
|
|
||||||
for operation in WRITE_TEMPLATE_OPERATIONS:
|
|
||||||
if any(
|
|
||||||
operation in table["templates"] for table in write_template_tables.values()
|
|
||||||
):
|
|
||||||
operations.append(
|
|
||||||
{
|
|
||||||
"name": operation,
|
|
||||||
"label": WRITE_TEMPLATE_LABELS[operation],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return operations
|
|
||||||
|
|
||||||
|
|
||||||
class ExecuteWriteView(BaseView):
|
|
||||||
name = "execute-write"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def _render_form(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
*,
|
|
||||||
sql="",
|
|
||||||
parameter_values=None,
|
|
||||||
analysis=None,
|
|
||||||
analysis_error=None,
|
|
||||||
execution_message=None,
|
|
||||||
execution_links=None,
|
|
||||||
execution_ok=None,
|
|
||||||
status=200,
|
|
||||||
):
|
|
||||||
parameter_values = parameter_values or {}
|
|
||||||
execution_links = execution_links 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_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,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor = await db.execute_write(sql, params, request=request)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
message = str(ex)
|
|
||||||
if _wants_json(request, is_json, data):
|
|
||||||
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(request, is_json, data):
|
|
||||||
return _block_framing(
|
|
||||||
Response.json(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"message": message,
|
|
||||||
"rowcount": cursor.rowcount,
|
|
||||||
"analysis": _analysis_rows(analysis),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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 []
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,605 +0,0 @@
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
from datasette.resources import DatabaseResource
|
|
||||||
from datasette.stored_queries import (
|
|
||||||
StoredQuery,
|
|
||||||
)
|
|
||||||
from datasette.write_sql import (
|
|
||||||
IgnoreWriteSqlOperation,
|
|
||||||
QueryWriteRejected,
|
|
||||||
RequireWriteSqlPermissions,
|
|
||||||
decision_for_write_sql_operation,
|
|
||||||
operation_is_write,
|
|
||||||
)
|
|
||||||
from datasette.utils import (
|
|
||||||
named_parameters as derive_named_parameters,
|
|
||||||
escape_sqlite,
|
|
||||||
path_from_row_pks,
|
|
||||||
sqlite3,
|
|
||||||
validate_sql_select,
|
|
||||||
InvalidSql,
|
|
||||||
)
|
|
||||||
from datasette.utils.asgi import Forbidden
|
|
||||||
from datasette.utils.sql_analysis import Operation, SQLAnalysis
|
|
||||||
|
|
||||||
_query_name_re = re.compile(r"^[^/\.\n]+$")
|
|
||||||
|
|
||||||
_query_fields = {
|
|
||||||
"sql",
|
|
||||||
"title",
|
|
||||||
"description",
|
|
||||||
"hide_sql",
|
|
||||||
"fragment",
|
|
||||||
"parameters",
|
|
||||||
"params",
|
|
||||||
"is_private",
|
|
||||||
"on_success_message",
|
|
||||||
"on_success_redirect",
|
|
||||||
"on_error_message",
|
|
||||||
"on_error_redirect",
|
|
||||||
}
|
|
||||||
|
|
||||||
_query_create_fields = _query_fields | {"name", "mode", "csrftoken"}
|
|
||||||
_query_update_fields = _query_fields
|
|
||||||
_query_write_fields = {
|
|
||||||
"on_success_message",
|
|
||||||
"on_success_redirect",
|
|
||||||
"on_error_message",
|
|
||||||
"on_error_redirect",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class QueryValidationError(Exception):
|
|
||||||
def __init__(self, message, status=400, *, flash=False):
|
|
||||||
self.message = message
|
|
||||||
self.status = status
|
|
||||||
self.flash = flash
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_id(actor):
|
|
||||||
if isinstance(actor, dict):
|
|
||||||
return actor.get("id")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(value):
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if value is None:
|
|
||||||
return False
|
|
||||||
if isinstance(value, int):
|
|
||||||
return bool(value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value.lower() in {"1", "true", "t", "yes", "on"}
|
|
||||||
return bool(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_optional_bool(value, name):
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, int):
|
|
||||||
return bool(value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
lowered = value.lower()
|
|
||||||
if lowered in {"1", "true", "t", "yes", "on"}:
|
|
||||||
return True
|
|
||||||
if lowered in {"0", "false", "f", "no", "off"}:
|
|
||||||
return False
|
|
||||||
raise QueryValidationError("{} must be 0 or 1".format(name))
|
|
||||||
|
|
||||||
|
|
||||||
def _query_list_limit(value, default=50):
|
|
||||||
if value in (None, ""):
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return min(max(1, int(value)), 1000)
|
|
||||||
except ValueError as ex:
|
|
||||||
raise QueryValidationError("_size must be an integer") from ex
|
|
||||||
|
|
||||||
|
|
||||||
def _derived_query_parameters(sql):
|
|
||||||
parameters = []
|
|
||||||
seen = set()
|
|
||||||
for parameter in derive_named_parameters(sql):
|
|
||||||
if parameter.startswith("_"):
|
|
||||||
raise QueryValidationError("Magic parameters are not allowed")
|
|
||||||
if parameter not in seen:
|
|
||||||
parameters.append(parameter)
|
|
||||||
seen.add(parameter)
|
|
||||||
return parameters
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_query_parameters(value, derived):
|
|
||||||
if value is None:
|
|
||||||
return derived
|
|
||||||
if isinstance(value, str):
|
|
||||||
parameters = [
|
|
||||||
parameter.strip()
|
|
||||||
for parameter in re.split(r"[\s,]+", value)
|
|
||||||
if parameter.strip()
|
|
||||||
]
|
|
||||||
elif isinstance(value, list):
|
|
||||||
parameters = value
|
|
||||||
else:
|
|
||||||
raise QueryValidationError("parameters must be a list of strings")
|
|
||||||
if not all(isinstance(parameter, str) for parameter in parameters):
|
|
||||||
raise QueryValidationError("parameters must be a list of strings")
|
|
||||||
if any(parameter.startswith("_") for parameter in parameters):
|
|
||||||
raise QueryValidationError("Magic parameters are not allowed")
|
|
||||||
if set(parameters) != set(derived):
|
|
||||||
raise QueryValidationError("parameters must match SQL named parameters")
|
|
||||||
return parameters
|
|
||||||
|
|
||||||
|
|
||||||
def _analysis_is_write(analysis: SQLAnalysis) -> bool:
|
|
||||||
return any(operation_is_write(operation) for operation in analysis.operations)
|
|
||||||
|
|
||||||
|
|
||||||
def _block_framing(response):
|
|
||||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
|
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _wants_json(request, is_json, data):
|
|
||||||
return (
|
|
||||||
is_json
|
|
||||||
or request.headers.get("accept") == "application/json"
|
|
||||||
or (isinstance(data, dict) and data.get("_json"))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _query_create_form_error_message(message):
|
|
||||||
return {
|
|
||||||
"Query name is required": "URL is required",
|
|
||||||
"Invalid query name": "Invalid URL",
|
|
||||||
"Query name conflicts with a table or view": (
|
|
||||||
"URL conflicts with an existing table or view"
|
|
||||||
),
|
|
||||||
"Query already exists": "A query already exists at that URL",
|
|
||||||
}.get(message, message)
|
|
||||||
|
|
||||||
|
|
||||||
async def _json_or_form_payload(request):
|
|
||||||
content_type = request.headers.get("content-type", "")
|
|
||||||
if content_type.startswith("application/json"):
|
|
||||||
body = await request.post_body()
|
|
||||||
try:
|
|
||||||
return json.loads(body or b"{}"), True
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise QueryValidationError("Invalid JSON: {}".format(e))
|
|
||||||
return await request.post_vars(), False
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_query_name(db, name, *, existing=False):
|
|
||||||
if not name or not isinstance(name, str):
|
|
||||||
raise QueryValidationError("Query name is required")
|
|
||||||
if not _query_name_re.match(name):
|
|
||||||
raise QueryValidationError("Invalid query name")
|
|
||||||
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
|
|
||||||
raise QueryValidationError("Query name conflicts with a table or view")
|
|
||||||
|
|
||||||
|
|
||||||
async def _analyze_user_query(datasette, db, sql, *, actor):
|
|
||||||
if not sql or not isinstance(sql, str):
|
|
||||||
raise QueryValidationError("SQL is required")
|
|
||||||
derived = _derived_query_parameters(sql)
|
|
||||||
params = {parameter: "" for parameter in derived}
|
|
||||||
try:
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
|
||||||
|
|
||||||
is_write = _analysis_is_write(analysis)
|
|
||||||
if is_write:
|
|
||||||
try:
|
|
||||||
await datasette.ensure_query_write_permissions(
|
|
||||||
db.name, sql, actor=actor, analysis=analysis
|
|
||||||
)
|
|
||||||
except QueryWriteRejected as ex:
|
|
||||||
raise QueryValidationError(ex.message, status=403, flash=True) from ex
|
|
||||||
except Forbidden as ex:
|
|
||||||
raise QueryValidationError(str(ex), status=403) from ex
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
validate_sql_select(sql)
|
|
||||||
except InvalidSql as ex:
|
|
||||||
raise QueryValidationError(str(ex)) from ex
|
|
||||||
return is_write, derived, analysis
|
|
||||||
|
|
||||||
|
|
||||||
def _display_operations(analysis: SQLAnalysis) -> list[Operation]:
|
|
||||||
operations = []
|
|
||||||
for operation in analysis.operations:
|
|
||||||
if isinstance(
|
|
||||||
decision_for_write_sql_operation(operation), IgnoreWriteSqlOperation
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
operations.append(operation)
|
|
||||||
return operations
|
|
||||||
|
|
||||||
|
|
||||||
def _analysis_rows(analysis: SQLAnalysis) -> list[dict[str, object]]:
|
|
||||||
rows = []
|
|
||||||
for operation in _display_operations(analysis):
|
|
||||||
decision = decision_for_write_sql_operation(operation)
|
|
||||||
required_permission = (
|
|
||||||
", ".join(permission.action for permission in decision.permissions)
|
|
||||||
if isinstance(decision, RequireWriteSqlPermissions)
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"operation": operation.operation,
|
|
||||||
"database": operation.database,
|
|
||||||
"table": operation.table or operation.target,
|
|
||||||
"required_permission": required_permission,
|
|
||||||
"source": operation.source,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
async def _analysis_rows_with_permissions(
|
|
||||||
datasette, analysis: SQLAnalysis, actor
|
|
||||||
) -> list[dict[str, object]]:
|
|
||||||
rows = _analysis_rows(analysis)
|
|
||||||
is_write = _analysis_is_write(analysis)
|
|
||||||
for row, operation in zip(rows, _display_operations(analysis)):
|
|
||||||
decision = decision_for_write_sql_operation(operation)
|
|
||||||
if isinstance(decision, RequireWriteSqlPermissions):
|
|
||||||
row["allowed"] = True
|
|
||||||
for permission in decision.permissions:
|
|
||||||
if not await datasette.allowed(
|
|
||||||
action=permission.action,
|
|
||||||
resource=permission.resource,
|
|
||||||
actor=actor,
|
|
||||||
):
|
|
||||||
row["allowed"] = False
|
|
||||||
break
|
|
||||||
elif is_write:
|
|
||||||
row["allowed"] = False
|
|
||||||
else:
|
|
||||||
row["allowed"] = None
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _execute_write_disabled_reason(sql, analysis_error, analysis_rows):
|
|
||||||
if not (sql and sql.strip()):
|
|
||||||
return "Enter writable SQL before executing."
|
|
||||||
if analysis_error:
|
|
||||||
return analysis_error
|
|
||||||
if any(row.get("allowed") is False for row in analysis_rows):
|
|
||||||
return "You do not have permission for every operation listed above."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_execute_write_payload(data, is_json):
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise QueryValidationError("JSON must be a dictionary")
|
|
||||||
if is_json:
|
|
||||||
invalid_keys = set(data) - {"sql", "params"}
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
|
||||||
)
|
|
||||||
params = data.get("params") or {}
|
|
||||||
else:
|
|
||||||
params = {
|
|
||||||
key: value
|
|
||||||
for key, value in data.items()
|
|
||||||
if key not in {"sql", "csrftoken", "_json"}
|
|
||||||
}
|
|
||||||
if not isinstance(params, dict):
|
|
||||||
raise QueryValidationError("params must be a dictionary")
|
|
||||||
return data.get("sql"), params
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_execute_write(datasette, db, sql, params, actor):
|
|
||||||
if not sql or not isinstance(sql, str):
|
|
||||||
raise QueryValidationError("SQL is required")
|
|
||||||
parameter_names = _derived_query_parameters(sql)
|
|
||||||
extra_params = set(params) - set(parameter_names)
|
|
||||||
if extra_params:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Unknown parameters: {}".format(", ".join(sorted(extra_params)))
|
|
||||||
)
|
|
||||||
params = {name: params.get(name, "") for name in parameter_names}
|
|
||||||
try:
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
|
||||||
if not _analysis_is_write(analysis):
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Use /-/query for read-only SQL; this endpoint only executes writes"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await datasette.ensure_query_write_permissions(
|
|
||||||
db.name, sql, actor=actor, analysis=analysis
|
|
||||||
)
|
|
||||||
except QueryWriteRejected as ex:
|
|
||||||
raise QueryValidationError(ex.message, status=403, flash=True) from ex
|
|
||||||
except Forbidden as ex:
|
|
||||||
raise QueryValidationError(str(ex), status=403) from ex
|
|
||||||
return parameter_names, params, analysis
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_stored_query_execution_permissions(
|
|
||||||
datasette, db, query: StoredQuery, actor
|
|
||||||
):
|
|
||||||
if query.is_trusted:
|
|
||||||
return
|
|
||||||
if query.is_write:
|
|
||||||
await datasette.ensure_permission(
|
|
||||||
action="execute-write-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor)
|
|
||||||
else:
|
|
||||||
await datasette.ensure_permission(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_write_analysis_data(datasette, db, sql, actor):
|
|
||||||
parameter_names = []
|
|
||||||
analysis_rows = []
|
|
||||||
analysis_error = None
|
|
||||||
if sql:
|
|
||||||
try:
|
|
||||||
parameter_names = _derived_query_parameters(sql)
|
|
||||||
params = {parameter: "" for parameter in parameter_names}
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
if _analysis_is_write(analysis):
|
|
||||||
analysis_rows = await _analysis_rows_with_permissions(
|
|
||||||
datasette, analysis, actor
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
analysis_error = (
|
|
||||||
"Use /-/query for read-only SQL; "
|
|
||||||
"this endpoint only executes writes"
|
|
||||||
)
|
|
||||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
|
||||||
analysis_error = getattr(ex, "message", str(ex))
|
|
||||||
execute_disabled_reason = _execute_write_disabled_reason(
|
|
||||||
sql, analysis_error, analysis_rows
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": analysis_error is None,
|
|
||||||
"parameters": parameter_names,
|
|
||||||
"analysis_error": analysis_error,
|
|
||||||
"analysis_rows": analysis_rows,
|
|
||||||
"execute_disabled": bool(execute_disabled_reason),
|
|
||||||
"execute_disabled_reason": execute_disabled_reason,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_create_analysis_data(datasette, db, sql, actor):
|
|
||||||
has_sql = bool(sql and sql.strip())
|
|
||||||
parameter_names = []
|
|
||||||
analysis_rows = []
|
|
||||||
analysis_error = None
|
|
||||||
analysis: SQLAnalysis | None = None
|
|
||||||
if has_sql:
|
|
||||||
try:
|
|
||||||
parameter_names = _derived_query_parameters(sql)
|
|
||||||
params = {parameter: "" for parameter in parameter_names}
|
|
||||||
analysis = await db.analyze_sql(sql, params)
|
|
||||||
analysis_rows = await _analysis_rows_with_permissions(
|
|
||||||
datasette, analysis, actor
|
|
||||||
)
|
|
||||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
|
||||||
analysis_error = getattr(ex, "message", str(ex))
|
|
||||||
return {
|
|
||||||
"ok": analysis_error is None,
|
|
||||||
"parameters": parameter_names,
|
|
||||||
"analysis_error": analysis_error,
|
|
||||||
"analysis_rows": analysis_rows,
|
|
||||||
"has_sql": has_sql,
|
|
||||||
"analysis_is_write": _analysis_is_write(analysis) if analysis else False,
|
|
||||||
"save_disabled": bool(
|
|
||||||
(not has_sql)
|
|
||||||
or analysis_error
|
|
||||||
or any(row["allowed"] is False for row in analysis_rows)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_create_form_context(
|
|
||||||
datasette,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
*,
|
|
||||||
sql="",
|
|
||||||
name="",
|
|
||||||
title="",
|
|
||||||
description="",
|
|
||||||
is_private=True,
|
|
||||||
):
|
|
||||||
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
|
|
||||||
return {
|
|
||||||
"database": db.name,
|
|
||||||
"database_color": db.color,
|
|
||||||
"sql": sql,
|
|
||||||
"name": name,
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"is_private": is_private,
|
|
||||||
**analysis_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _inserted_row_url(datasette, db, analysis, cursor):
|
|
||||||
if cursor.rowcount != 1:
|
|
||||||
return None
|
|
||||||
lastrowid = getattr(cursor, "lastrowid", None)
|
|
||||||
if lastrowid is None:
|
|
||||||
return None
|
|
||||||
direct_inserts = [
|
|
||||||
operation
|
|
||||||
for operation in analysis.operations
|
|
||||||
if operation.operation == "insert"
|
|
||||||
and operation.target_type == "table"
|
|
||||||
and not operation.internal
|
|
||||||
and operation.source is None
|
|
||||||
and operation.database == db.name
|
|
||||||
]
|
|
||||||
if len(direct_inserts) != 1:
|
|
||||||
return None
|
|
||||||
table = direct_inserts[0].table
|
|
||||||
if table is None:
|
|
||||||
return None
|
|
||||||
pks = await db.primary_keys(table)
|
|
||||||
use_rowid = not pks
|
|
||||||
select = (
|
|
||||||
"rowid"
|
|
||||||
if use_rowid
|
|
||||||
else ", ".join(escape_sqlite(primary_key) for primary_key in pks)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
result = await db.execute(
|
|
||||||
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
|
|
||||||
[lastrowid],
|
|
||||||
)
|
|
||||||
except sqlite3.DatabaseError:
|
|
||||||
return None
|
|
||||||
row = result.first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
row_path = path_from_row_pks(row, pks, use_rowid)
|
|
||||||
return datasette.urls.row(db.name, table, row_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_query_data_types(data):
|
|
||||||
typed = dict(data)
|
|
||||||
for key in ("hide_sql", "is_private"):
|
|
||||||
if key in typed:
|
|
||||||
typed[key] = _as_bool(typed[key])
|
|
||||||
return typed
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_query_create(datasette, request, db, data):
|
|
||||||
invalid_keys = set(data) - _query_create_fields
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
|
||||||
)
|
|
||||||
|
|
||||||
data = _apply_query_data_types(data)
|
|
||||||
name = data.get("name")
|
|
||||||
await _check_query_name(db, name)
|
|
||||||
if await datasette.get_query(db.name, name) is not None:
|
|
||||||
raise QueryValidationError("Query already exists")
|
|
||||||
|
|
||||||
is_write, derived, analysis = await _analyze_user_query(
|
|
||||||
datasette,
|
|
||||||
db,
|
|
||||||
data.get("sql"),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
if not is_write and any(data.get(field) for field in _query_write_fields):
|
|
||||||
raise QueryValidationError("Writable query fields require writable SQL")
|
|
||||||
|
|
||||||
parameters = _coerce_query_parameters(
|
|
||||||
data.get("parameters", data.get("params")),
|
|
||||||
derived,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"sql": data["sql"],
|
|
||||||
"title": data.get("title"),
|
|
||||||
"description": data.get("description"),
|
|
||||||
"hide_sql": _as_bool(data.get("hide_sql")),
|
|
||||||
"fragment": data.get("fragment"),
|
|
||||||
"parameters": parameters,
|
|
||||||
"is_write": is_write,
|
|
||||||
"is_private": _as_bool(data.get("is_private", True)),
|
|
||||||
"is_trusted": False,
|
|
||||||
"source": "user",
|
|
||||||
"owner_id": _actor_id(request.actor),
|
|
||||||
"on_success_message": data.get("on_success_message"),
|
|
||||||
"on_success_redirect": data.get("on_success_redirect"),
|
|
||||||
"on_error_message": data.get("on_error_message"),
|
|
||||||
"on_error_redirect": data.get("on_error_redirect"),
|
|
||||||
"analysis": analysis,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update):
|
|
||||||
invalid_keys = set(update) - _query_update_fields
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(sorted(invalid_keys)))
|
|
||||||
)
|
|
||||||
|
|
||||||
update = _apply_query_data_types(update)
|
|
||||||
sql = update.get("sql", existing.sql)
|
|
||||||
query_is_write = existing.is_write
|
|
||||||
derived = _derived_query_parameters(sql)
|
|
||||||
parameters = None
|
|
||||||
|
|
||||||
if "sql" in update:
|
|
||||||
query_is_write, derived, _ = await _analyze_user_query(
|
|
||||||
datasette,
|
|
||||||
db,
|
|
||||||
sql,
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "parameters" in update or "params" in update:
|
|
||||||
parameters = _coerce_query_parameters(
|
|
||||||
update.get("parameters", update.get("params")),
|
|
||||||
derived,
|
|
||||||
)
|
|
||||||
elif "sql" in update:
|
|
||||||
parameters = derived
|
|
||||||
|
|
||||||
if not query_is_write and any(update.get(field) for field in _query_write_fields):
|
|
||||||
raise QueryValidationError("Writable query fields require writable SQL")
|
|
||||||
|
|
||||||
field_values = {
|
|
||||||
"sql": sql,
|
|
||||||
"title": update.get("title"),
|
|
||||||
"description": update.get("description"),
|
|
||||||
"hide_sql": update.get("hide_sql"),
|
|
||||||
"fragment": update.get("fragment"),
|
|
||||||
"parameters": parameters,
|
|
||||||
"is_write": query_is_write,
|
|
||||||
"is_private": update.get("is_private"),
|
|
||||||
"on_success_message": update.get("on_success_message"),
|
|
||||||
"on_success_redirect": update.get("on_success_redirect"),
|
|
||||||
"on_error_message": update.get("on_error_message"),
|
|
||||||
"on_error_redirect": update.get("on_error_redirect"),
|
|
||||||
}
|
|
||||||
update_kwargs = {}
|
|
||||||
for field_name, value in field_values.items():
|
|
||||||
if field_name in update:
|
|
||||||
update_kwargs[field_name] = value
|
|
||||||
if parameters is not None:
|
|
||||||
update_kwargs["parameters"] = parameters
|
|
||||||
if "sql" in update:
|
|
||||||
update_kwargs["is_write"] = query_is_write
|
|
||||||
return update_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
async def _table_columns(datasette, database_name):
|
|
||||||
internal_db = datasette.get_internal_database()
|
|
||||||
result = await internal_db.execute(
|
|
||||||
"select table_name, name from catalog_columns where database_name = ?",
|
|
||||||
[database_name],
|
|
||||||
)
|
|
||||||
table_columns = {}
|
|
||||||
for row in result.rows:
|
|
||||||
table_columns.setdefault(row["table_name"], []).append(row["name"])
|
|
||||||
# Add views
|
|
||||||
db = datasette.get_database(database_name)
|
|
||||||
for view_name in await db.view_names():
|
|
||||||
table_columns[view_name] = []
|
|
||||||
return table_columns
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datasette.jump import JumpSQL, namespace_sql_params
|
|
||||||
from datasette.plugins import pm
|
|
||||||
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
from datasette.resources import DatabaseResource, TableResource
|
||||||
from datasette.utils.asgi import Response, Forbidden
|
from datasette.utils.asgi import Response, Forbidden
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
actor_matches_allow,
|
actor_matches_allow,
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
|
||||||
tilde_encode,
|
tilde_encode,
|
||||||
tilde_decode,
|
tilde_decode,
|
||||||
)
|
)
|
||||||
|
|
@ -67,7 +64,7 @@ class JsonDataView(BaseView):
|
||||||
context = {
|
context = {
|
||||||
"filename": self.filename,
|
"filename": self.filename,
|
||||||
"data": data,
|
"data": data,
|
||||||
"data_json": json.dumps(data, indent=2, default=repr),
|
"data_json": json.dumps(data, indent=4, default=repr),
|
||||||
}
|
}
|
||||||
# Add has_debug_permission if this view requires permissions-debug
|
# Add has_debug_permission if this view requires permissions-debug
|
||||||
if self.permission == "permissions-debug":
|
if self.permission == "permissions-debug":
|
||||||
|
|
@ -913,183 +910,75 @@ class ApiExplorerView(BaseView):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class JumpView(BaseView):
|
class TablesView(BaseView):
|
||||||
"""
|
"""
|
||||||
Endpoint for the jump menu. Returns JSON navigation items the actor can use.
|
Simple endpoint that uses the new allowed_resources() API.
|
||||||
|
Returns JSON list of all tables the actor can view.
|
||||||
|
|
||||||
|
Supports ?q=foo+bar to filter tables matching .*foo.*bar.* pattern,
|
||||||
|
ordered by shortest name first.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "jump"
|
name = "tables"
|
||||||
has_json_alternate = False
|
has_json_alternate = False
|
||||||
|
|
||||||
async def _fragments(self, request):
|
|
||||||
fragments = []
|
|
||||||
for hook in pm.hook.jump_items_sql(
|
|
||||||
datasette=self.ds,
|
|
||||||
actor=request.actor,
|
|
||||||
request=request,
|
|
||||||
):
|
|
||||||
value = await await_me_maybe(hook)
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, JumpSQL):
|
|
||||||
fragments.append(value)
|
|
||||||
elif isinstance(value, (list, tuple)):
|
|
||||||
for fragment in value:
|
|
||||||
if fragment is not None:
|
|
||||||
assert isinstance(
|
|
||||||
fragment, JumpSQL
|
|
||||||
), "jump_items_sql must return JumpSQL instances"
|
|
||||||
fragments.append(fragment)
|
|
||||||
else:
|
|
||||||
raise TypeError("jump_items_sql must return JumpSQL instances")
|
|
||||||
return fragments
|
|
||||||
|
|
||||||
def _resolve_url(self, url):
|
|
||||||
if not url or url.startswith("/"):
|
|
||||||
return url
|
|
||||||
|
|
||||||
descriptor = json.loads(url)
|
|
||||||
if not isinstance(descriptor, dict):
|
|
||||||
raise TypeError("jump item url JSON must be an object")
|
|
||||||
method_name = descriptor.get("method")
|
|
||||||
if not isinstance(method_name, str) or not method_name:
|
|
||||||
raise TypeError("jump item url JSON must include a method")
|
|
||||||
if method_name.startswith("_"):
|
|
||||||
raise AttributeError(f"datasette.urls has no method named {method_name!r}")
|
|
||||||
try:
|
|
||||||
method = getattr(self.ds.urls, method_name)
|
|
||||||
except AttributeError as ex:
|
|
||||||
raise AttributeError(
|
|
||||||
f"datasette.urls has no method named {method_name!r}"
|
|
||||||
) from ex
|
|
||||||
if not callable(method):
|
|
||||||
raise TypeError(f"datasette.urls.{method_name} is not callable")
|
|
||||||
kwargs = {key: value for key, value in descriptor.items() if key != "method"}
|
|
||||||
try:
|
|
||||||
return method(**kwargs)
|
|
||||||
except TypeError as ex:
|
|
||||||
raise TypeError(
|
|
||||||
f"Invalid arguments for datasette.urls.{method_name}(): {ex}"
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
def _sort_key(self, row, q):
|
|
||||||
display_label = row["display_name"] or row["label"]
|
|
||||||
display_label_lower = display_label.lower()
|
|
||||||
q_lower = q.lower()
|
|
||||||
if display_label_lower == q_lower:
|
|
||||||
relevance = 0
|
|
||||||
elif display_label_lower.startswith(q_lower):
|
|
||||||
relevance = 1
|
|
||||||
else:
|
|
||||||
relevance = 2
|
|
||||||
type_sort = {
|
|
||||||
"database": 10,
|
|
||||||
"table": 20,
|
|
||||||
"view": 25,
|
|
||||||
"query": 30,
|
|
||||||
}.get(row["type"], 50)
|
|
||||||
return (relevance, type_sort, len(display_label), row["label"])
|
|
||||||
|
|
||||||
async def _rows_for_database(self, database_name, indexed_fragments, q, pattern):
|
|
||||||
params = {"q": q, "pattern": pattern}
|
|
||||||
union_parts = []
|
|
||||||
for index, fragment in indexed_fragments:
|
|
||||||
fragment_sql, fragment_params = namespace_sql_params(
|
|
||||||
fragment.sql,
|
|
||||||
fragment.params or {},
|
|
||||||
f"jump_{index}",
|
|
||||||
)
|
|
||||||
union_parts.append(f"""
|
|
||||||
SELECT
|
|
||||||
type,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
search_text,
|
|
||||||
display_name
|
|
||||||
FROM (
|
|
||||||
{fragment_sql}
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
params.update(fragment_params)
|
|
||||||
sql = f"""
|
|
||||||
WITH jump_items AS (
|
|
||||||
{" UNION ALL ".join(union_parts)}
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
type,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
search_text,
|
|
||||||
display_name
|
|
||||||
FROM jump_items
|
|
||||||
WHERE :q = ''
|
|
||||||
OR search_text LIKE :pattern COLLATE NOCASE
|
|
||||||
ORDER BY
|
|
||||||
CASE
|
|
||||||
WHEN lower(COALESCE(display_name, label)) = lower(:q) THEN 0
|
|
||||||
WHEN lower(COALESCE(display_name, label)) LIKE lower(:q || '%') THEN 1
|
|
||||||
ELSE 2
|
|
||||||
END,
|
|
||||||
CASE type
|
|
||||||
WHEN 'database' THEN 10
|
|
||||||
WHEN 'table' THEN 20
|
|
||||||
WHEN 'view' THEN 25
|
|
||||||
WHEN 'query' THEN 30
|
|
||||||
ELSE 50
|
|
||||||
END,
|
|
||||||
length(COALESCE(display_name, label)),
|
|
||||||
label
|
|
||||||
LIMIT 101
|
|
||||||
"""
|
|
||||||
db = (
|
|
||||||
self.ds.get_internal_database()
|
|
||||||
if database_name is None
|
|
||||||
else self.ds.get_database(database_name)
|
|
||||||
)
|
|
||||||
result = await db.execute(sql, params)
|
|
||||||
return list(result.rows)
|
|
||||||
|
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
|
# Get search query parameter
|
||||||
q = request.args.get("q", "").strip()
|
q = request.args.get("q", "").strip()
|
||||||
terms = q.split()
|
|
||||||
pattern = "%" + "%".join(terms) + "%" if terms else "%"
|
|
||||||
fragments = await self._fragments(request)
|
|
||||||
|
|
||||||
fragments_by_database = {}
|
# Get SQL for allowed resources using the permission system
|
||||||
for index, fragment in enumerate(fragments):
|
permission_sql, params = await self.ds.allowed_resources_sql(
|
||||||
fragments_by_database.setdefault(fragment.database, []).append(
|
action="view-table", actor=request.actor
|
||||||
(index, fragment)
|
)
|
||||||
|
|
||||||
|
# Build query based on whether we have a search query
|
||||||
|
if q:
|
||||||
|
# Build SQL LIKE pattern from search terms
|
||||||
|
# Split search terms by whitespace and build pattern: %term1%term2%term3%
|
||||||
|
terms = q.split()
|
||||||
|
pattern = "%" + "%".join(terms) + "%"
|
||||||
|
|
||||||
|
# Build query with CTE to filter by search pattern
|
||||||
|
sql = f"""
|
||||||
|
WITH allowed_tables AS (
|
||||||
|
{permission_sql}
|
||||||
)
|
)
|
||||||
|
SELECT parent, child
|
||||||
rows = []
|
FROM allowed_tables
|
||||||
truncated = False
|
WHERE child LIKE :pattern COLLATE NOCASE
|
||||||
for database_name, indexed_fragments in fragments_by_database.items():
|
ORDER BY length(child), child
|
||||||
database_rows = await self._rows_for_database(
|
"""
|
||||||
database_name, indexed_fragments, q, pattern
|
all_params = {**params, "pattern": pattern}
|
||||||
|
else:
|
||||||
|
# No search query - return all tables, ordered by name
|
||||||
|
# Fetch 101 to detect if we need to truncate
|
||||||
|
sql = f"""
|
||||||
|
WITH allowed_tables AS (
|
||||||
|
{permission_sql}
|
||||||
)
|
)
|
||||||
if len(database_rows) > 100:
|
SELECT parent, child
|
||||||
truncated = True
|
FROM allowed_tables
|
||||||
database_rows = database_rows[:100]
|
ORDER BY parent, child
|
||||||
rows.extend(database_rows)
|
LIMIT 101
|
||||||
rows.sort(key=lambda row: self._sort_key(row, q))
|
"""
|
||||||
|
all_params = params
|
||||||
|
|
||||||
if len(rows) > 100:
|
# Execute against internal database
|
||||||
truncated = True
|
result = await self.ds.get_internal_database().execute(sql, all_params)
|
||||||
|
|
||||||
|
# Build response with truncation
|
||||||
|
rows = list(result.rows)
|
||||||
|
truncated = len(rows) > 100
|
||||||
|
if truncated:
|
||||||
rows = rows[:100]
|
rows = rows[:100]
|
||||||
|
|
||||||
matches = []
|
matches = [
|
||||||
for row in rows:
|
{
|
||||||
match = {
|
"name": f"{row['parent']}: {row['child']}",
|
||||||
"name": row["label"],
|
"url": self.ds.urls.table(row["parent"], row["child"]),
|
||||||
"url": self._resolve_url(row["url"]),
|
|
||||||
"type": row["type"],
|
|
||||||
"description": row["description"],
|
|
||||||
}
|
}
|
||||||
if row["display_name"]:
|
for row in rows
|
||||||
match["display_name"] = row["display_name"]
|
]
|
||||||
matches.append(match)
|
|
||||||
|
|
||||||
return Response.json({"matches": matches, "truncated": truncated})
|
return Response.json({"matches": matches, "truncated": truncated})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,483 +0,0 @@
|
||||||
from urllib.parse import parse_qsl, urlencode
|
|
||||||
|
|
||||||
from datasette.resources import DatabaseResource, QueryResource
|
|
||||||
from datasette.stored_queries import stored_query_to_dict
|
|
||||||
from datasette.utils import sqlite3, tilde_decode
|
|
||||||
from datasette.utils.asgi import Response
|
|
||||||
|
|
||||||
from .base import BaseView, _error
|
|
||||||
from .query_helpers import (
|
|
||||||
QueryValidationError,
|
|
||||||
_as_bool,
|
|
||||||
_as_optional_bool,
|
|
||||||
_block_framing,
|
|
||||||
_derived_query_parameters,
|
|
||||||
_json_or_form_payload,
|
|
||||||
_prepare_query_create,
|
|
||||||
_prepare_query_update,
|
|
||||||
_query_create_analysis_data,
|
|
||||||
_query_create_form_context,
|
|
||||||
_query_create_form_error_message,
|
|
||||||
_query_list_limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryParametersView(BaseView):
|
|
||||||
name = "query-parameters"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
|
||||||
if invalid_keys:
|
|
||||||
return _block_framing(
|
|
||||||
_error(
|
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
parameters = _derived_query_parameters(request.args.get("sql") or "")
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
return _block_framing(_error([ex.message], ex.status))
|
|
||||||
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
|
|
||||||
|
|
||||||
|
|
||||||
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
|
|
||||||
set_args = set_args or {}
|
|
||||||
remove_args = set(remove_args or ())
|
|
||||||
skip = set(set_args) | remove_args | {"_next"}
|
|
||||||
pairs = [
|
|
||||||
(key, value)
|
|
||||||
for key, value in parse_qsl(query_string, keep_blank_values=True)
|
|
||||||
if key not in skip
|
|
||||||
]
|
|
||||||
for key, value in set_args.items():
|
|
||||||
if value not in (None, ""):
|
|
||||||
pairs.append((key, value))
|
|
||||||
return path + (("?" + urlencode(pairs)) if pairs else "")
|
|
||||||
|
|
||||||
|
|
||||||
class QueryListView(BaseView):
|
|
||||||
name = "query-list"
|
|
||||||
|
|
||||||
async def database_name(self, request):
|
|
||||||
return (await self.ds.resolve_database(request)).name
|
|
||||||
|
|
||||||
def query_list_path(self, database):
|
|
||||||
return self.ds.urls.database(database) + "/-/queries"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
database = await self.database_name(request)
|
|
||||||
format_ = request.url_vars.get("format") or "html"
|
|
||||||
try:
|
|
||||||
limit = _query_list_limit(
|
|
||||||
request.args.get("_size"),
|
|
||||||
default=20 if format_ == "html" else 50,
|
|
||||||
)
|
|
||||||
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
|
|
||||||
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
return _error([ex.message], ex.status)
|
|
||||||
|
|
||||||
page = await self.ds.list_queries(
|
|
||||||
database,
|
|
||||||
actor=request.actor,
|
|
||||||
limit=limit,
|
|
||||||
cursor=request.args.get("_next"),
|
|
||||||
q=request.args.get("q") or None,
|
|
||||||
is_write=is_write,
|
|
||||||
is_private=is_private,
|
|
||||||
source=request.args.get("source") or None,
|
|
||||||
owner_id=request.args.get("owner_id") or None,
|
|
||||||
include_private=True,
|
|
||||||
)
|
|
||||||
query_list_path = self.query_list_path(database)
|
|
||||||
next_url = None
|
|
||||||
if page.next:
|
|
||||||
pairs = [
|
|
||||||
(key, value)
|
|
||||||
for key, value in parse_qsl(
|
|
||||||
request.query_string, keep_blank_values=True
|
|
||||||
)
|
|
||||||
if key != "_next"
|
|
||||||
]
|
|
||||||
pairs.append(("_next", page.next))
|
|
||||||
next_url = "{}?{}".format(
|
|
||||||
query_list_path,
|
|
||||||
urlencode(pairs),
|
|
||||||
)
|
|
||||||
|
|
||||||
current_filters = {
|
|
||||||
"actor": request.actor,
|
|
||||||
"q": request.args.get("q") or None,
|
|
||||||
"is_write": is_write,
|
|
||||||
"is_private": is_private,
|
|
||||||
"source": request.args.get("source") or None,
|
|
||||||
"owner_id": request.args.get("owner_id") or None,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def facet_count(field, value):
|
|
||||||
if current_filters[field] is not None and current_filters[field] != value:
|
|
||||||
return 0
|
|
||||||
filters = dict(current_filters)
|
|
||||||
filters[field] = value
|
|
||||||
return await self.ds.count_queries(database, **filters)
|
|
||||||
|
|
||||||
def facet_href(field, value):
|
|
||||||
if current_filters[field] == value:
|
|
||||||
return _query_list_url(
|
|
||||||
query_list_path,
|
|
||||||
request.query_string,
|
|
||||||
remove_args=[field],
|
|
||||||
)
|
|
||||||
if current_filters[field] is not None:
|
|
||||||
return None
|
|
||||||
return _query_list_url(
|
|
||||||
query_list_path,
|
|
||||||
request.query_string,
|
|
||||||
set_args={field: str(int(value))},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def facet_item(label, field, value):
|
|
||||||
count = await facet_count(field, value)
|
|
||||||
active = current_filters[field] == value
|
|
||||||
if not active and not count:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"label": label,
|
|
||||||
"count": count,
|
|
||||||
"href": facet_href(field, value) if active or count else None,
|
|
||||||
"active": active,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def facet_items(items):
|
|
||||||
return [
|
|
||||||
item
|
|
||||||
for item in [
|
|
||||||
await facet_item(label, field, value)
|
|
||||||
for label, field, value in items
|
|
||||||
]
|
|
||||||
if item is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
facets = [
|
|
||||||
{
|
|
||||||
"title": "Mode",
|
|
||||||
"items": await facet_items(
|
|
||||||
[
|
|
||||||
("Read-only", "is_write", False),
|
|
||||||
("Writable", "is_write", True),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Visibility",
|
|
||||||
"items": await facet_items(
|
|
||||||
[
|
|
||||||
("Not private", "is_private", False),
|
|
||||||
("Private", "is_private", True),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"ok": True,
|
|
||||||
"database": database,
|
|
||||||
"database_color": (
|
|
||||||
self.ds.get_database(database).color if database is not None else None
|
|
||||||
),
|
|
||||||
"queries": page.queries,
|
|
||||||
"next": page.next,
|
|
||||||
"next_url": next_url,
|
|
||||||
"has_more": page.has_more,
|
|
||||||
"limit": page.limit,
|
|
||||||
"show_private_note": any(query.is_private for query in page.queries),
|
|
||||||
"show_trusted_note": any(query.is_trusted for query in page.queries),
|
|
||||||
"query_list_path": query_list_path,
|
|
||||||
"show_database": database is None,
|
|
||||||
"facets": facets,
|
|
||||||
"filters": {
|
|
||||||
"q": request.args.get("q") or "",
|
|
||||||
"is_write": request.args.get("is_write") or "",
|
|
||||||
"is_private": request.args.get("is_private") or "",
|
|
||||||
"source": request.args.get("source") or "",
|
|
||||||
"owner_id": request.args.get("owner_id") or "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if format_ == "json":
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
**data,
|
|
||||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return await self.render(
|
|
||||||
["query_list.html"],
|
|
||||||
request,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalQueryListView(QueryListView):
|
|
||||||
name = "global-query-list"
|
|
||||||
|
|
||||||
async def database_name(self, request):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def query_list_path(self, database):
|
|
||||||
return self.ds.urls.path("/-/queries")
|
|
||||||
|
|
||||||
|
|
||||||
class QueryCreateView(BaseView):
|
|
||||||
name = "query-create"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def _render_form(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
*,
|
|
||||||
sql="",
|
|
||||||
name="",
|
|
||||||
title="",
|
|
||||||
description="",
|
|
||||||
is_private=True,
|
|
||||||
status=200,
|
|
||||||
):
|
|
||||||
response = await self.render(
|
|
||||||
["query_create.html"],
|
|
||||||
request,
|
|
||||||
await _query_create_form_context(
|
|
||||||
self.ds,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=sql,
|
|
||||||
name=name,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
is_private=is_private,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
response.status = status
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
await self.ds.ensure_permission(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self._render_form(request, db, sql=request.args.get("sql") or "")
|
|
||||||
|
|
||||||
|
|
||||||
class QueryCreateAnalyzeView(BaseView):
|
|
||||||
name = "query-create-analyze"
|
|
||||||
has_json_alternate = False
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _block_framing(_error(["Permission denied: need store-query"], 403))
|
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
|
||||||
if invalid_keys:
|
|
||||||
return _block_framing(
|
|
||||||
_error(
|
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sql = request.args.get("sql") or ""
|
|
||||||
return _block_framing(
|
|
||||||
Response.json(
|
|
||||||
await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryStoreView(QueryCreateView):
|
|
||||||
name = "query-store"
|
|
||||||
|
|
||||||
async def _error_response(self, request, db, query_data, message, status):
|
|
||||||
message = _query_create_form_error_message(message)
|
|
||||||
self.ds.add_message(request, message, self.ds.ERROR)
|
|
||||||
return await self._render_form(
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
sql=query_data.get("sql") or "",
|
|
||||||
name=query_data.get("name") or "",
|
|
||||||
title=query_data.get("title") or "",
|
|
||||||
description=query_data.get("description") or "",
|
|
||||||
is_private=_as_bool(query_data.get("is_private", True)),
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied: need execute-sql"], 403)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="store-query",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied: need store-query"], 403)
|
|
||||||
|
|
||||||
is_json = False
|
|
||||||
query_data = {}
|
|
||||||
try:
|
|
||||||
data, is_json = await _json_or_form_payload(request)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise QueryValidationError("JSON must be a dictionary")
|
|
||||||
query_data = data.get("query") if is_json else data
|
|
||||||
if not isinstance(query_data, dict):
|
|
||||||
raise QueryValidationError("JSON must contain a query dictionary")
|
|
||||||
prepared = await _prepare_query_create(self.ds, request, db, query_data)
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
if not is_json and isinstance(query_data, dict):
|
|
||||||
return await self._error_response(
|
|
||||||
request, db, query_data, ex.message, ex.status
|
|
||||||
)
|
|
||||||
return _error([ex.message], ex.status)
|
|
||||||
|
|
||||||
prepared.pop("analysis")
|
|
||||||
name = prepared.pop("name")
|
|
||||||
try:
|
|
||||||
await self.ds.add_query(db.name, name, replace=False, **prepared)
|
|
||||||
except sqlite3.IntegrityError as ex:
|
|
||||||
if not is_json and isinstance(query_data, dict):
|
|
||||||
return await self._error_response(request, db, query_data, str(ex), 400)
|
|
||||||
return _error([str(ex)], 400)
|
|
||||||
|
|
||||||
query = await self.ds.get_query(db.name, name)
|
|
||||||
assert query is not None
|
|
||||||
if is_json:
|
|
||||||
return Response.json(
|
|
||||||
{"ok": True, "query": stored_query_to_dict(query)}, status=201
|
|
||||||
)
|
|
||||||
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
|
||||||
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
|
|
||||||
|
|
||||||
|
|
||||||
class QueryDefinitionView(BaseView):
|
|
||||||
name = "query-definition"
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
|
||||||
query = await self.ds.get_query(db.name, query_name)
|
|
||||||
if query is None:
|
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="view-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied"], 403)
|
|
||||||
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
|
|
||||||
|
|
||||||
|
|
||||||
class QueryUpdateView(BaseView):
|
|
||||||
name = "query-update"
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
|
||||||
existing = await self.ds.get_query(db.name, query_name)
|
|
||||||
if existing is None:
|
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="update-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied: need update-query"], 403)
|
|
||||||
if existing.is_trusted:
|
|
||||||
return _error(["Trusted queries cannot be updated using the API"], 403)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data, _ = await _json_or_form_payload(request)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise QueryValidationError("JSON must be a dictionary")
|
|
||||||
invalid_keys = set(data) - {"update", "return"}
|
|
||||||
if invalid_keys:
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Invalid keys: {}".format(", ".join(invalid_keys))
|
|
||||||
)
|
|
||||||
update = data.get("update")
|
|
||||||
if not isinstance(update, dict):
|
|
||||||
raise QueryValidationError("JSON must contain an update dictionary")
|
|
||||||
if "sql" in update and not await self.ds.allowed(
|
|
||||||
action="execute-sql",
|
|
||||||
resource=DatabaseResource(db.name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
raise QueryValidationError(
|
|
||||||
"Permission denied: need execute-sql", status=403
|
|
||||||
)
|
|
||||||
update_kwargs = await _prepare_query_update(
|
|
||||||
self.ds, request, db, existing, update
|
|
||||||
)
|
|
||||||
except QueryValidationError as ex:
|
|
||||||
return _error([ex.message], ex.status)
|
|
||||||
|
|
||||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
|
||||||
if data.get("return"):
|
|
||||||
query = await self.ds.get_query(db.name, query_name)
|
|
||||||
assert query is not None
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"query": stored_query_to_dict(query),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return Response.json({"ok": True})
|
|
||||||
|
|
||||||
|
|
||||||
class QueryDeleteView(BaseView):
|
|
||||||
name = "query-delete"
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
|
||||||
existing = await self.ds.get_query(db.name, query_name)
|
|
||||||
if existing is None:
|
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="delete-query",
|
|
||||||
resource=QueryResource(db.name, query_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied: need delete-query"], 403)
|
|
||||||
await self.ds.remove_query(db.name, query_name)
|
|
||||||
return Response.json({"ok": True})
|
|
||||||
|
|
@ -963,12 +963,12 @@ async def table_view_traced(datasette, request):
|
||||||
try:
|
try:
|
||||||
resolved = await datasette.resolve_table(request)
|
resolved = await datasette.resolve_table(request)
|
||||||
except TableNotFound as not_found:
|
except TableNotFound as not_found:
|
||||||
# Was this actually a stored query?
|
# Was this actually a canned query?
|
||||||
stored_query = await datasette.get_query(
|
canned_query = await datasette.get_canned_query(
|
||||||
not_found.database_name, not_found.table
|
not_found.database_name, not_found.table, request.actor
|
||||||
)
|
)
|
||||||
# If this is a stored query, not a table, then dispatch to QueryView instead
|
# If this is a canned query, not a table, then dispatch to QueryView instead
|
||||||
if stored_query:
|
if canned_query:
|
||||||
return await QueryView()(request, datasette)
|
return await QueryView()(request, datasette)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
|
||||||
How permissions are resolved
|
How permissions are resolved
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||||
|
|
||||||
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
|
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
|
||||||
|
|
||||||
|
|
@ -468,7 +468,7 @@ You can control the following:
|
||||||
* Access to the entire Datasette instance
|
* Access to the entire Datasette instance
|
||||||
* Access to specific databases
|
* Access to specific databases
|
||||||
* Access to specific tables and views
|
* Access to specific tables and views
|
||||||
* Access to specific :ref:`queries <queries>`
|
* Access to specific :ref:`canned_queries`
|
||||||
|
|
||||||
If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within.
|
If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within.
|
||||||
|
|
||||||
|
|
@ -641,12 +641,12 @@ This works for SQL views as well - you can list their names in the ``"tables"``
|
||||||
|
|
||||||
.. _authentication_permissions_query:
|
.. _authentication_permissions_query:
|
||||||
|
|
||||||
Access to specific queries
|
Access to specific canned queries
|
||||||
--------------------------
|
---------------------------------
|
||||||
|
|
||||||
:ref:`Queries <queries>` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.
|
:ref:`canned_queries` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important.
|
||||||
|
|
||||||
To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
|
To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user<authentication_root>`:
|
||||||
|
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
config_example(cog, """
|
config_example(cog, """
|
||||||
|
|
@ -1020,7 +1020,7 @@ You can also restrict permissions such that they can only be used within specifi
|
||||||
|
|
||||||
The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database.
|
The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database.
|
||||||
|
|
||||||
Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <queries>` - within a specific database::
|
Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries <canned_queries>` - within a specific database::
|
||||||
|
|
||||||
datasette create-token root --resource mydatabase mytable insert-row
|
datasette create-token root --resource mydatabase mytable insert-row
|
||||||
|
|
||||||
|
|
@ -1285,46 +1285,12 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
|
||||||
view-query
|
view-query
|
||||||
----------
|
----------
|
||||||
|
|
||||||
Actor is allowed to view a stored query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted stored query also requires ``execute-sql`` or the relevant write permissions; :ref:`trusted stored queries <trusted_stored_queries>` can execute with ``view-query`` alone.
|
Actor is allowed to view (and execute) a :ref:`canned query <canned_queries>` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
|
||||||
|
|
||||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||||
``database`` is the name of the database (string)
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
``query`` is the name of the query (string)
|
``query`` is the name of the canned query (string)
|
||||||
|
|
||||||
.. _actions_store_query:
|
|
||||||
|
|
||||||
store-query
|
|
||||||
-----------
|
|
||||||
|
|
||||||
Actor is allowed to create stored queries against a database.
|
|
||||||
|
|
||||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
|
||||||
``database`` is the name of the database (string)
|
|
||||||
|
|
||||||
.. _actions_update_query:
|
|
||||||
|
|
||||||
update-query
|
|
||||||
------------
|
|
||||||
|
|
||||||
Actor is allowed to update a stored query.
|
|
||||||
|
|
||||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
|
||||||
``database`` is the name of the database (string)
|
|
||||||
|
|
||||||
``query`` is the name of the query (string)
|
|
||||||
|
|
||||||
.. _actions_delete_query:
|
|
||||||
|
|
||||||
delete-query
|
|
||||||
------------
|
|
||||||
|
|
||||||
Actor is allowed to delete a stored query.
|
|
||||||
|
|
||||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
|
||||||
``database`` is the name of the database (string)
|
|
||||||
|
|
||||||
``query`` is the name of the query (string)
|
|
||||||
|
|
||||||
.. _actions_insert_row:
|
.. _actions_insert_row:
|
||||||
|
|
||||||
|
|
@ -1413,23 +1379,13 @@ Actor is allowed to drop a database table.
|
||||||
execute-sql
|
execute-sql
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
Actor is allowed to run arbitrary read-only SQL queries against a specific database using the :ref:`custom SQL query page <pages_custom_sql_queries>`, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
|
Actor is allowed to run arbitrary SQL queries against a specific database, e.g. https://latest.datasette.io/fixtures/-/query?sql=select+100
|
||||||
|
|
||||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||||
``database`` is the name of the database (string)
|
``database`` is the name of the database (string)
|
||||||
|
|
||||||
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`.
|
See also :ref:`the default_allow_sql setting <setting_default_allow_sql>`.
|
||||||
|
|
||||||
.. _actions_execute_write_sql:
|
|
||||||
|
|
||||||
execute-write-sql
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
Actor is allowed to run arbitrary writable SQL queries against a specific database using the :ref:`write SQL queries page <pages_execute_write>`, subject to table-level write permissions such as ``insert-row``, ``update-row`` and ``delete-row``. SQL functions are allowed and are not separately restricted by Datasette permissions.
|
|
||||||
|
|
||||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
|
||||||
``database`` is the name of the database (string)
|
|
||||||
|
|
||||||
.. _actions_permissions_debug:
|
.. _actions_permissions_debug:
|
||||||
|
|
||||||
permissions-debug
|
permissions-debug
|
||||||
|
|
@ -1442,4 +1398,4 @@ Actor is allowed to view the ``/-/permissions`` debug tools.
|
||||||
debug-menu
|
debug-menu
|
||||||
----------
|
----------
|
||||||
|
|
||||||
Controls if the various debug pages are displayed in the jump menu.
|
Controls if the various debug pages are displayed in the navigation menu.
|
||||||
|
|
|
||||||
|
|
@ -4,74 +4,6 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
.. _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:
|
.. _v1_0_a28:
|
||||||
|
|
||||||
1.0a28 (2026-04-16)
|
1.0a28 (2026-04-16)
|
||||||
|
|
@ -689,7 +621,7 @@ For more information and workarounds, read `the security advisory <https://githu
|
||||||
Also in this alpha:
|
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`)
|
- 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`)
|
- 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`)
|
- Every instance of example shell script code in the documentation should now include a working copy button, free from additional syntax. (:issue:`2140`)
|
||||||
|
|
||||||
|
|
@ -1083,7 +1015,7 @@ Other small fixes
|
||||||
- The ``base.html`` template now wraps everything other than the ``<footer>`` in a ``<div class="not-footer">`` element, to help with advanced CSS customization. (:issue:`1446`)
|
- The ``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`)
|
- 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`)
|
- :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`)
|
- 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`)
|
- 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`)
|
- ``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`)
|
||||||
|
|
@ -1457,7 +1389,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/>`__.
|
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`)
|
- 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`)
|
- ``register_output_renderer()`` render functions can now return a ``Response``. (:issue:`953`)
|
||||||
- New ``--upgrade`` option for ``datasette install``. (:issue:`945`)
|
- New ``--upgrade`` option for ``datasette install``. (:issue:`945`)
|
||||||
|
|
@ -1549,7 +1481,7 @@ Magic parameters for canned queries, a log out feature, improved plugin document
|
||||||
Magic parameters for canned queries
|
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
|
insert into logs
|
||||||
(user_id, timestamp)
|
(user_id, timestamp)
|
||||||
|
|
@ -1580,7 +1512,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_register_magic_parameters` can be used to define new types of magic canned query parameters.
|
||||||
- :ref:`plugin_hook_startup` can run custom code when Datasette first starts up. `datasette-init <https://github.com/simonw/datasette-init>`__ is a new plugin that uses this hook to create database tables and views on startup if they have not yet been created. (:issue:`834`)
|
- :ref:`plugin_hook_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`)
|
- :ref:`plugin_hook_forbidden` is a hook for customizing how Datasette responds to 403 forbidden errors. (:issue:`812`)
|
||||||
|
|
||||||
Smaller changes
|
Smaller changes
|
||||||
|
|
@ -1655,7 +1587,7 @@ A new debug page at ``/-/permissions`` shows recent permission checks, to help a
|
||||||
Writable canned queries
|
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`):
|
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`):
|
||||||
|
|
||||||
|
|
@ -1674,7 +1606,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
|
Flash messages
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
@ -1729,7 +1661,7 @@ Smaller changes
|
||||||
- New ``request.cookies`` property.
|
- 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
|
- ``/-/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.
|
- ``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.
|
- ``request.args`` is now a :ref:`MultiParams <internals_multiparams>` object.
|
||||||
- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)
|
- Fixed a bug with the ``datasette plugins`` command. (:issue:`802`)
|
||||||
- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)
|
- Nicer pattern for using ``make_app_client()`` in tests. (:issue:`395`)
|
||||||
|
|
@ -1763,7 +1695,7 @@ The main focus of this release is a major upgrade to the :ref:`plugin_register_o
|
||||||
* Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`)
|
* 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`)
|
* 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`)
|
* 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`)
|
* 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`)
|
* 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`)
|
* 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`)
|
||||||
|
|
||||||
|
|
@ -2280,7 +2212,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>`_
|
- Documentation for :ref:`datasette publish and datasette package <publishing>`, closes `#337 <https://github.com/simonw/datasette/issues/337>`_
|
||||||
- Fixed compatibility with Python 3.7
|
- 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]
|
- ``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>`_
|
- 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>`_
|
- ``?_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>`_
|
- URLs displayed in the results of custom SQL queries are now URLified, closes `#298 <https://github.com/simonw/datasette/issues/298>`_
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ This is equivalent to a ``datasette.yaml`` file containing the following:
|
||||||
}
|
}
|
||||||
.. [[[end]]]
|
.. [[[end]]]
|
||||||
|
|
||||||
|
|
||||||
.. _configuration_reference:
|
.. _configuration_reference:
|
||||||
|
|
||||||
``datasette.yaml`` reference
|
``datasette.yaml`` reference
|
||||||
|
|
@ -434,12 +433,12 @@ Here is a simple example:
|
||||||
|
|
||||||
:ref:`authentication_permissions_config` has the full details.
|
:ref:`authentication_permissions_config` has the full details.
|
||||||
|
|
||||||
.. _configuration_reference_queries:
|
.. _configuration_reference_canned_queries:
|
||||||
|
|
||||||
Queries configuration
|
Canned queries configuration
|
||||||
~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
:ref:`Queries <queries>` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level:
|
:ref:`Canned queries <canned_queries>` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level:
|
||||||
|
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
from metadata_doc import config_example, config_example
|
from metadata_doc import config_example, config_example
|
||||||
|
|
@ -484,7 +483,7 @@ Queries configuration
|
||||||
}
|
}
|
||||||
.. [[[end]]]
|
.. [[[end]]]
|
||||||
|
|
||||||
See the :ref:`queries documentation <queries>` for more, including how to configure :ref:`writable queries <queries_writable>`.
|
See the :ref:`canned queries documentation <canned_queries>` for more, including how to configure :ref:`writable canned queries <canned_queries_writable>`.
|
||||||
|
|
||||||
.. _configuration_reference_css_js:
|
.. _configuration_reference_css_js:
|
||||||
|
|
||||||
|
|
@ -1212,3 +1211,4 @@ For column types that accept additional configuration, use an object with ``type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.. [[[end]]]
|
.. [[[end]]]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ The custom SQL template (``/dbname?sql=...``) gets this:
|
||||||
|
|
||||||
<body class="query db-dbname">
|
<body class="query db-dbname">
|
||||||
|
|
||||||
A stored query template (``/dbname/queryname``) gets this:
|
A canned query template (``/dbname/queryname``) gets this:
|
||||||
|
|
||||||
.. code-block:: html
|
.. code-block:: html
|
||||||
|
|
||||||
|
|
@ -193,8 +193,8 @@ The lookup rules Datasette uses are as follows::
|
||||||
query-mydatabase.html
|
query-mydatabase.html
|
||||||
query.html
|
query.html
|
||||||
|
|
||||||
Stored query page (/mydatabase/query-name):
|
Canned query page (/mydatabase/canned-query):
|
||||||
query-mydatabase-query-name.html
|
query-mydatabase-canned-query.html
|
||||||
query-mydatabase.html
|
query-mydatabase.html
|
||||||
query.html
|
query.html
|
||||||
|
|
||||||
|
|
@ -230,7 +230,7 @@ will look something like this::
|
||||||
|
|
||||||
<!-- Templates considered: *query-mydb-tz.html, query-mydb.html, query.html -->
|
<!-- Templates considered: *query-mydb-tz.html, query-mydb.html, query.html -->
|
||||||
|
|
||||||
This example is from the stored query page for a query called "tz" in the
|
This example is from the canned query page for a query called "tz" in the
|
||||||
database called "mydb". The asterisk shows which template was selected - so in
|
database called "mydb". The asterisk shows which template was selected - so in
|
||||||
this case, Datasette found a template file called ``query-mydb-tz.html`` and
|
this case, Datasette found a template file called ``query-mydb-tz.html`` and
|
||||||
used that - but if that template had not been found, it would have tried for
|
used that - but if that template had not been found, it would have tried for
|
||||||
|
|
|
||||||
|
|
@ -725,34 +725,10 @@ The builder methods are:
|
||||||
|
|
||||||
- ``allow_all(action)`` - allow an action across all databases and resources
|
- ``allow_all(action)`` - allow an action across all databases and resources
|
||||||
- ``allow_database(database, action)`` - allow an action on a specific database
|
- ``allow_database(database, action)`` - allow an action on a specific database
|
||||||
- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`stored query <stored_queries>`) within a database
|
- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`canned query <canned_queries>`) within a database
|
||||||
|
|
||||||
Each method returns the ``TokenRestrictions`` instance so calls can be chained.
|
Each method returns the ``TokenRestrictions`` instance so calls can be chained.
|
||||||
|
|
||||||
``TokenRestrictions`` also provides an ``abbreviated(datasette)`` method which returns the restrictions as a dictionary using the compact format described in :ref:`authentication_cli_create_token_restrict`, with action names replaced by their registered abbreviations. It returns the inner dictionary only - the ``"_r"`` wrapping key shown in that section is not included. Returns ``None`` if no restrictions are set. This is useful when writing a custom :ref:`plugin_hook_register_token_handler` that needs to embed restrictions in a token payload.
|
|
||||||
|
|
||||||
For example, the following restrictions:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
restrictions = (
|
|
||||||
TokenRestrictions()
|
|
||||||
.allow_all("view-instance")
|
|
||||||
.allow_database("docs", "view-query")
|
|
||||||
.allow_resource("docs", "attachments", "insert-row")
|
|
||||||
)
|
|
||||||
restrictions.abbreviated(datasette)
|
|
||||||
|
|
||||||
Returns this dictionary, using the abbreviations registered for each action:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
{
|
|
||||||
"a": ["vi"],
|
|
||||||
"d": {"docs": ["vq"]},
|
|
||||||
"r": {"docs": {"attachments": ["ir"]}},
|
|
||||||
}
|
|
||||||
|
|
||||||
The following example creates a token that can access ``view-instance`` and ``view-table`` across everything, can additionally use ``view-query`` for anything in the ``docs`` database and is allowed to execute ``insert-row`` and ``update-row`` in the ``attachments`` table in that database:
|
The following example creates a token that can access ``view-instance`` and ``view-table`` across everything, can additionally use ``view-query`` for anything in the ``docs`` database and is allowed to execute ``insert-row`` and ``update-row`` in the ``attachments`` table in that database:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
@ -837,10 +813,10 @@ await .get_resource_metadata(self, database_name, resource_name)
|
||||||
``database_name`` - string
|
``database_name`` - string
|
||||||
The name of the database to query.
|
The name of the database to query.
|
||||||
``resource_name`` - string
|
``resource_name`` - string
|
||||||
The name of the resource (table, view, or stored query) inside ``database_name`` to query.
|
The name of the resource (table, view, or canned query) inside ``database_name`` to query.
|
||||||
|
|
||||||
Returns metadata keys and values for the specified "resource" as a dictionary.
|
Returns metadata keys and values for the specified "resource" as a dictionary.
|
||||||
A "resource" in this context can be a table, view, or stored query.
|
A "resource" in this context can be a table, view, or canned query.
|
||||||
Internally queries the ``metadata_resources`` table inside the :ref:`internal database <internals_internal>`.
|
Internally queries the ``metadata_resources`` table inside the :ref:`internal database <internals_internal>`.
|
||||||
|
|
||||||
.. _datasette_get_column_metadata:
|
.. _datasette_get_column_metadata:
|
||||||
|
|
@ -851,7 +827,7 @@ await .get_column_metadata(self, database_name, resource_name, column_name)
|
||||||
``database_name`` - string
|
``database_name`` - string
|
||||||
The name of the database to query.
|
The name of the database to query.
|
||||||
``resource_name`` - string
|
``resource_name`` - string
|
||||||
The name of the resource (table, view, or stored query) inside ``database_name`` to query.
|
The name of the resource (table, view, or canned query) inside ``database_name`` to query.
|
||||||
``column_name`` - string
|
``column_name`` - string
|
||||||
The name of the column inside ``resource_name`` to query.
|
The name of the column inside ``resource_name`` to query.
|
||||||
|
|
||||||
|
|
@ -897,7 +873,7 @@ await .set_resource_metadata(self, database_name, resource_name, key, value)
|
||||||
``database_name`` - string
|
``database_name`` - string
|
||||||
The database the metadata entry belongs to.
|
The database the metadata entry belongs to.
|
||||||
``resource_name`` - string
|
``resource_name`` - string
|
||||||
The resource (table, view, or stored query) the metadata entry belongs to.
|
The resource (table, view, or canned query) the metadata entry belongs to.
|
||||||
``key`` - string
|
``key`` - string
|
||||||
The metadata entry key to insert (ex ``title``, ``description``, etc.)
|
The metadata entry key to insert (ex ``title``, ``description``, etc.)
|
||||||
``value`` - string
|
``value`` - string
|
||||||
|
|
@ -915,7 +891,7 @@ await .set_column_metadata(self, database_name, resource_name, column_name, key,
|
||||||
``database_name`` - string
|
``database_name`` - string
|
||||||
The database the metadata entry belongs to.
|
The database the metadata entry belongs to.
|
||||||
``resource_name`` - string
|
``resource_name`` - string
|
||||||
The resource (table, view, or stored query) the metadata entry belongs to.
|
The resource (table, view, or canned query) the metadata entry belongs to.
|
||||||
``column-name`` - string
|
``column-name`` - string
|
||||||
The column the metadata entry belongs to.
|
The column the metadata entry belongs to.
|
||||||
``key`` - string
|
``key`` - string
|
||||||
|
|
@ -927,200 +903,6 @@ Adds a new metadata entry for the specified column.
|
||||||
Any previous column-level metadata entry with the same ``key`` will be overwritten.
|
Any previous column-level metadata entry with the same ``key`` will be overwritten.
|
||||||
Internally upserts the value into the the ``metadata_columns`` table inside the :ref:`internal database <internals_internal>`.
|
Internally upserts the value into the the ``metadata_columns`` table inside the :ref:`internal database <internals_internal>`.
|
||||||
|
|
||||||
.. _datasette_stored_queries:
|
|
||||||
|
|
||||||
Stored queries
|
|
||||||
--------------
|
|
||||||
|
|
||||||
:ref:`Stored queries <stored_queries>` are stored in the ``queries`` table in the :ref:`internal database <internals_internal>`. Plugins can use the following methods to add, update, list and remove stored queries.
|
|
||||||
|
|
||||||
.. _datasette_add_query:
|
|
||||||
|
|
||||||
await .add_query(database, name, sql, ...)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Adds a stored query.
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
async def add_query(
|
|
||||||
self,
|
|
||||||
database,
|
|
||||||
name,
|
|
||||||
sql,
|
|
||||||
*,
|
|
||||||
title=None,
|
|
||||||
description=None,
|
|
||||||
description_html=None,
|
|
||||||
hide_sql=False,
|
|
||||||
fragment=None,
|
|
||||||
parameters=None,
|
|
||||||
is_write=False,
|
|
||||||
is_private=False,
|
|
||||||
is_trusted=False,
|
|
||||||
source="plugin",
|
|
||||||
owner_id=None,
|
|
||||||
on_success_message=None,
|
|
||||||
on_success_message_sql=None,
|
|
||||||
on_success_redirect=None,
|
|
||||||
on_error_message=None,
|
|
||||||
on_error_redirect=None,
|
|
||||||
replace=True,
|
|
||||||
): ...
|
|
||||||
|
|
||||||
``database`` - string
|
|
||||||
The name of the database this query should belong to.
|
|
||||||
``name`` - string
|
|
||||||
The name of the stored query, used in the URL for that query.
|
|
||||||
``sql`` - string
|
|
||||||
The SQL for the stored query.
|
|
||||||
``title`` - string, optional
|
|
||||||
A display title for the query.
|
|
||||||
``description`` - string, optional
|
|
||||||
A plain text description.
|
|
||||||
``description_html`` - string, optional
|
|
||||||
An HTML description.
|
|
||||||
``hide_sql`` - boolean, optional
|
|
||||||
Set to ``True`` to hide the SQL by default on the query page.
|
|
||||||
``fragment`` - string, optional
|
|
||||||
A URL fragment to append to query links, for example ``"chart"``.
|
|
||||||
``parameters`` - list of strings, optional
|
|
||||||
Explicit parameter names for the query form. If omitted, Datasette derives parameters from the SQL.
|
|
||||||
``is_write`` - boolean, optional
|
|
||||||
Set to ``True`` for writable queries. They will the run against the SQLite write connection for the database.
|
|
||||||
``is_private`` - boolean, optional
|
|
||||||
Set to ``True`` for private queries. Private queries can only be viewed, updated or deleted by their owner.
|
|
||||||
``is_trusted`` - boolean, optional
|
|
||||||
Set to ``True`` for :ref:`trusted stored queries <trusted_stored_queries>`.
|
|
||||||
``source`` - string, optional
|
|
||||||
Identifies where the query came from. Defaults to ``"plugin"``.
|
|
||||||
``owner_id`` - string, optional
|
|
||||||
Actor ID of the query owner, used by private query permissions.
|
|
||||||
``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message``, ``on_error_redirect`` - strings, optional
|
|
||||||
Options for :ref:`writable queries <queries_writable>`.
|
|
||||||
``replace`` - boolean, optional
|
|
||||||
Defaults to ``True``, which replaces any existing stored query with the same ``database`` and ``name``. Set this to ``False`` to raise a SQLite integrity error if the query already exists.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
await datasette.add_query(
|
|
||||||
database="fixtures",
|
|
||||||
name="recent_rows",
|
|
||||||
sql="select * from facetable order by created desc limit 10",
|
|
||||||
title="Recent rows",
|
|
||||||
source="my-plugin",
|
|
||||||
)
|
|
||||||
|
|
||||||
.. _datasette_update_query:
|
|
||||||
|
|
||||||
await .update_query(database, name, ...)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Updates fields for an existing stored query. Only keyword arguments that are provided will be changed.
|
|
||||||
|
|
||||||
The available keyword arguments are the same as those for :ref:`datasette_add_query`, except for ``replace``. Pass ``None`` to clear optional text fields and options such as ``on_success_redirect``. Passing ``hide_sql=False`` removes the ``hide_sql`` option.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
await datasette.update_query(
|
|
||||||
database="fixtures",
|
|
||||||
name="recent_rows",
|
|
||||||
title="Latest rows",
|
|
||||||
is_private=True,
|
|
||||||
owner_id="alice",
|
|
||||||
)
|
|
||||||
|
|
||||||
.. _datasette_get_query:
|
|
||||||
|
|
||||||
await .get_query(database, name)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Returns a ``StoredQuery`` dataclass instance, or ``None`` if the query does not exist.
|
|
||||||
|
|
||||||
``StoredQuery`` has the following attributes: ``database``, ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write``, ``is_private``, ``is_trusted``, ``source``, ``owner_id``, ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``.
|
|
||||||
|
|
||||||
``parameters`` is a list of explicit parameter names.
|
|
||||||
|
|
||||||
.. _datasette_list_queries:
|
|
||||||
|
|
||||||
await .list_queries(database=None, ...)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Lists stored queries visible to the specified actor.
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
async def list_queries(
|
|
||||||
self,
|
|
||||||
database=None,
|
|
||||||
*,
|
|
||||||
actor=None,
|
|
||||||
limit=50,
|
|
||||||
cursor=None,
|
|
||||||
q=None,
|
|
||||||
is_write=None,
|
|
||||||
is_private=None,
|
|
||||||
is_trusted=None,
|
|
||||||
source=None,
|
|
||||||
owner_id=None,
|
|
||||||
include_private=False,
|
|
||||||
): ...
|
|
||||||
|
|
||||||
``database`` - string, optional
|
|
||||||
Restrict results to a specific database. Omit this to list queries across all databases.
|
|
||||||
``actor`` - dictionary, optional
|
|
||||||
The authenticated actor. Results are filtered using that actor's ``view-query`` permission.
|
|
||||||
``limit`` - integer, optional
|
|
||||||
Number of queries to return. Values are clamped to the range 1-1000.
|
|
||||||
``cursor`` - string, optional
|
|
||||||
Pagination cursor from the previous page's ``next`` value.
|
|
||||||
``q`` - string, optional
|
|
||||||
Search string matched against query name, title, description and SQL.
|
|
||||||
``is_write``, ``is_private``, ``is_trusted`` - boolean, optional
|
|
||||||
Filter by those stored query flags.
|
|
||||||
``source`` - string, optional
|
|
||||||
Filter by query source.
|
|
||||||
``owner_id`` - string, optional
|
|
||||||
Filter by owner actor ID.
|
|
||||||
``include_private`` - boolean, optional
|
|
||||||
Set to ``True`` to populate a ``private`` boolean on each returned ``StoredQuery`` indicating if anonymous users would be unable to view that query.
|
|
||||||
|
|
||||||
The return value is a ``StoredQueryPage`` dataclass instance with these attributes:
|
|
||||||
|
|
||||||
``queries`` - list of StoredQuery instances
|
|
||||||
Stored queries in the same format returned by :ref:`datasette_get_query`.
|
|
||||||
``next`` - string or None
|
|
||||||
Pagination cursor for the next page, if one exists.
|
|
||||||
``has_more`` - boolean
|
|
||||||
``True`` if another page of results is available.
|
|
||||||
``limit`` - integer
|
|
||||||
The limit used for this page.
|
|
||||||
|
|
||||||
.. _datasette_count_queries:
|
|
||||||
|
|
||||||
await .count_queries(database=None, ...)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Counts stored queries visible to the specified actor. This accepts the same filtering keyword arguments as :ref:`datasette_list_queries`, except for ``limit``, ``cursor`` and ``include_private``.
|
|
||||||
|
|
||||||
.. _datasette_remove_query:
|
|
||||||
|
|
||||||
await .remove_query(database, name, source=None)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Removes a stored query.
|
|
||||||
|
|
||||||
``database`` - string
|
|
||||||
The database the query belongs to.
|
|
||||||
``name`` - string
|
|
||||||
The query name.
|
|
||||||
``source`` - string, optional
|
|
||||||
If provided, only a query with this source will be removed.
|
|
||||||
|
|
||||||
.. _datasette_column_types:
|
.. _datasette_column_types:
|
||||||
|
|
||||||
Column types
|
Column types
|
||||||
|
|
@ -2342,26 +2124,6 @@ The internal database schema is as follows:
|
||||||
config TEXT,
|
config TEXT,
|
||||||
PRIMARY KEY (database_name, resource_name, column_name)
|
PRIMARY KEY (database_name, resource_name, column_name)
|
||||||
);
|
);
|
||||||
CREATE TABLE queries (
|
|
||||||
database_name TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sql TEXT NOT NULL,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
description_html TEXT,
|
|
||||||
options TEXT NOT NULL DEFAULT '{}',
|
|
||||||
parameters TEXT NOT NULL DEFAULT '[]',
|
|
||||||
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
|
|
||||||
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
|
|
||||||
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
|
|
||||||
source TEXT NOT NULL DEFAULT 'user',
|
|
||||||
owner_id TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (database_name, name)
|
|
||||||
);
|
|
||||||
CREATE INDEX queries_owner_idx
|
|
||||||
ON queries(owner_id);
|
|
||||||
|
|
||||||
.. [[[end]]]
|
.. [[[end]]]
|
||||||
|
|
||||||
|
|
@ -2433,8 +2195,8 @@ Note that the space character is a special case: it will be replaced with a ``+`
|
||||||
|
|
||||||
.. _internals_utils_call_with_supported_arguments:
|
.. _internals_utils_call_with_supported_arguments:
|
||||||
|
|
||||||
call_with_supported_arguments(fn, \*\*kwargs)
|
call_with_supported_arguments(fn, **kwargs)
|
||||||
---------------------------------------------
|
-------------------------------------------
|
||||||
|
|
||||||
Call ``fn``, passing it only those keyword arguments that match its function signature. This implements a dependency injection pattern - the caller provides all available arguments, and the function receives only the ones it declares as parameters.
|
Call ``fn``, passing it only those keyword arguments that match its function signature. This implements a dependency injection pattern - the caller provides all available arguments, and the function receives only the ones it declares as parameters.
|
||||||
|
|
||||||
|
|
@ -2461,8 +2223,8 @@ This is useful in plugins that want to define callback functions that only decla
|
||||||
|
|
||||||
.. _internals_utils_async_call_with_supported_arguments:
|
.. _internals_utils_async_call_with_supported_arguments:
|
||||||
|
|
||||||
await async_call_with_supported_arguments(fn, \*\*kwargs)
|
await async_call_with_supported_arguments(fn, **kwargs)
|
||||||
---------------------------------------------------------
|
-------------------------------------------------------
|
||||||
|
|
||||||
Async version of :ref:`call_with_supported_arguments <internals_utils_call_with_supported_arguments>`. Use this for ``async def`` callback functions.
|
Async version of :ref:`call_with_supported_arguments <internals_utils_call_with_supported_arguments>`. Use this for ``async def`` callback functions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,62 +144,46 @@ Shows currently attached databases. `Databases example <https://latest.datasette
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
.. _JumpView:
|
.. _TablesView:
|
||||||
|
|
||||||
/-/jump
|
/-/tables
|
||||||
-------
|
---------
|
||||||
|
|
||||||
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and stored queries, and plugins can contribute additional items.
|
Returns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.
|
||||||
|
|
||||||
Each item includes a ``type`` string used as a category label in the menu. Items can also include an optional ``description`` with longer text describing that individual result.
|
The endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.
|
||||||
|
|
||||||
The endpoint supports a ``?q=`` query parameter for filtering items by name.
|
`Tables example <https://latest.datasette.io/-/tables>`_:
|
||||||
|
|
||||||
`Jump example <https://latest.datasette.io/-/jump>`_:
|
|
||||||
|
|
||||||
.. code-block:: json
|
.. code-block:: json
|
||||||
|
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
{
|
{
|
||||||
"name": "fixtures",
|
"name": "fixtures/facetable",
|
||||||
"url": "/fixtures",
|
"url": "/fixtures/facetable"
|
||||||
"type": "database",
|
|
||||||
"description": null
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "fixtures: facetable",
|
"name": "fixtures/searchable",
|
||||||
"url": "/fixtures/facetable",
|
"url": "/fixtures/searchable"
|
||||||
"type": "table",
|
|
||||||
"description": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "fixtures: recent_releases",
|
|
||||||
"url": "/fixtures/recent_releases",
|
|
||||||
"type": "query",
|
|
||||||
"description": null
|
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"truncated": false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
|
Search example with ``?q=facet`` returns only tables matching ``.*facet.*``:
|
||||||
|
|
||||||
.. code-block:: json
|
.. code-block:: json
|
||||||
|
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
{
|
{
|
||||||
"name": "fixtures: facetable",
|
"name": "fixtures/facetable",
|
||||||
"url": "/fixtures/facetable",
|
"url": "/fixtures/facetable"
|
||||||
"type": "table",
|
|
||||||
"description": null
|
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"truncated": false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name.
|
When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.
|
||||||
|
|
||||||
.. _JsonDataView_threads:
|
.. _JsonDataView_threads:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,48 +58,6 @@ JavaScript plugins are blocks of code that can be registered with Datasette usin
|
||||||
|
|
||||||
The ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:
|
The ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:
|
||||||
|
|
||||||
.. _javascript_plugins_makeJumpSections:
|
|
||||||
|
|
||||||
makeJumpSections()
|
|
||||||
~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the ``/`` jump menu, before the user starts typing a search.
|
|
||||||
|
|
||||||
Each object should have the following:
|
|
||||||
|
|
||||||
``id`` - string
|
|
||||||
A unique string ID for the section, for example ``agent-chat``
|
|
||||||
``render(node, context)`` - function
|
|
||||||
A function that will be called with a DOM node to render the section into
|
|
||||||
|
|
||||||
The ``context`` object has the following keys:
|
|
||||||
|
|
||||||
``navigationSearch``
|
|
||||||
The ``<navigation-search>`` custom element instance.
|
|
||||||
|
|
||||||
This example shows how a plugin might add a button for starting a new chat:
|
|
||||||
|
|
||||||
.. code-block:: javascript
|
|
||||||
|
|
||||||
document.addEventListener('datasette_init', function(ev) {
|
|
||||||
ev.detail.registerPlugin('agent-plugin', {
|
|
||||||
version: 0.1,
|
|
||||||
makeJumpSections: () => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'agent-chat',
|
|
||||||
render: node => {
|
|
||||||
node.innerHTML = '<button type="button">Start a new chat</button>';
|
|
||||||
node.querySelector('button').addEventListener('click', () => {
|
|
||||||
location.href = '/-/agent/new';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
.. _javascript_plugins_makeAboveTablePanelConfigs:
|
.. _javascript_plugins_makeAboveTablePanelConfigs:
|
||||||
|
|
||||||
makeAboveTablePanelConfigs()
|
makeAboveTablePanelConfigs()
|
||||||
|
|
|
||||||
|
|
@ -50,25 +50,6 @@ The ``"truncated"`` key lets you know if the query was truncated. This can happe
|
||||||
|
|
||||||
For table pages, an additional key ``"next"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``.
|
For table pages, an additional key ``"next"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``.
|
||||||
|
|
||||||
.. _json_api_custom_sql:
|
|
||||||
|
|
||||||
Executing custom SQL
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
Actors with the :ref:`actions_execute_sql` permission can execute read-only SQL against a database using ``/-/query.json``:
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
GET /<database>/-/query.json?sql=select+*+from+dogs
|
|
||||||
|
|
||||||
Values for named SQL parameters can be provided as additional query string parameters:
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
GET /<database>/-/query.json?sql=select+*+from+dogs+where+name=:name&name=Cleo
|
|
||||||
|
|
||||||
The response uses the same default representation described above.
|
|
||||||
|
|
||||||
.. _json_api_shapes:
|
.. _json_api_shapes:
|
||||||
|
|
||||||
Different shapes
|
Different shapes
|
||||||
|
|
@ -524,70 +505,6 @@ The JSON write API
|
||||||
|
|
||||||
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
|
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
|
||||||
|
|
||||||
.. _ExecuteWriteView:
|
|
||||||
|
|
||||||
Executing write SQL
|
|
||||||
~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Actors with the :ref:`actions_execute_write_sql` permission can execute arbitrary writable SQL against a mutable database using ``/-/execute-write``.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
POST /<database>/-/execute-write
|
|
||||||
Content-Type: application/json
|
|
||||||
Authorization: Bearer dstok_<rest-of-token>
|
|
||||||
|
|
||||||
The request body must include a ``"sql"`` string. Named SQL parameters can be provided using the optional ``"params"`` object:
|
|
||||||
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"sql": "insert into dogs (name) values (:name)",
|
|
||||||
"params": {
|
|
||||||
"name": "Cleo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
The SQL must be writable. Read-only ``select`` queries should use the regular :ref:`custom SQL query JSON API <json_api_custom_sql>` instead.
|
|
||||||
|
|
||||||
Datasette analyzes the SQL before executing it. The actor must have ``execute-write-sql`` permission for the database, and must also have any permissions required by the operations in the SQL. For example, inserts and updates against a table require ``insert-row``, ``update-row`` and ``delete-row`` permissions for that table. Reads performed as part of the write, such as ``insert into dogs select ... from other_table``, require ``view-table`` permission on the source table. Schema changes require ``create-table``, ``alter-table`` or ``drop-table`` permissions as appropriate.
|
|
||||||
|
|
||||||
Unsupported SQL operations are rejected by default. ``VACUUM`` is not allowed in arbitrary write SQL, and writes to SQLite virtual tables or shadow tables are rejected. SQL functions are allowed and are not separately restricted by Datasette permissions.
|
|
||||||
|
|
||||||
A successful response includes a message, the SQLite ``rowcount`` and a summary of the operations that were executed:
|
|
||||||
|
|
||||||
The shape of the ``"analysis"`` block is not yet considered a stable API and may change in future Datasette releases.
|
|
||||||
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"message": "Query executed, 1 row affected",
|
|
||||||
"rowcount": 1,
|
|
||||||
"analysis": [
|
|
||||||
{
|
|
||||||
"operation": "insert",
|
|
||||||
"database": "data",
|
|
||||||
"table": "dogs",
|
|
||||||
"required_permission": "insert-row, update-row, delete-row",
|
|
||||||
"source": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
If SQLite reports ``-1`` for the row count, the message will be ``"Query executed"``.
|
|
||||||
|
|
||||||
Errors use the standard Datasette error format:
|
|
||||||
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"ok": false,
|
|
||||||
"errors": [
|
|
||||||
"Permission denied: need execute-write-sql"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
.. _TableInsertView:
|
.. _TableInsertView:
|
||||||
|
|
||||||
Inserting rows
|
Inserting rows
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ The index page can also be accessed at ``/-/``, useful for if the default index
|
||||||
Database
|
Database
|
||||||
========
|
========
|
||||||
|
|
||||||
Each database has a page listing the tables, views and stored queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data.
|
Each database has a page listing the tables, views and canned queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|
@ -62,43 +62,16 @@ The following tables are hidden by default:
|
||||||
Queries
|
Queries
|
||||||
=======
|
=======
|
||||||
|
|
||||||
.. _pages_custom_sql_queries:
|
|
||||||
|
|
||||||
Custom SQL queries
|
|
||||||
------------------
|
|
||||||
|
|
||||||
The ``/database-name/-/query`` page can be used to execute an arbitrary SQL query against that database, if the :ref:`actions_execute_sql` permission is enabled. This query is passed as the ``?sql=`` query string parameter.
|
The ``/database-name/-/query`` page can be used to execute an arbitrary SQL query against that database, if the :ref:`actions_execute_sql` permission is enabled. This query is passed as the ``?sql=`` query string parameter.
|
||||||
|
|
||||||
This means you can link directly to a query by constructing the following URL:
|
This means you can link directly to a query by constructing the following URL:
|
||||||
|
|
||||||
``/database-name/-/query?sql=SELECT+*+FROM+table_name``
|
``/database-name/-/query?sql=SELECT+*+FROM+table_name``
|
||||||
|
|
||||||
Each configured :ref:`stored query <stored_queries>` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results.
|
Each configured :ref:`canned query <canned_queries>` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results.
|
||||||
|
|
||||||
In both cases adding a ``.json`` extension to the URL will return the results as JSON.
|
In both cases adding a ``.json`` extension to the URL will return the results as JSON.
|
||||||
|
|
||||||
.. _pages_execute_write:
|
|
||||||
|
|
||||||
Write SQL queries
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
The ``/database-name/-/execute-write`` page can be used to execute SQL statements that write to a mutable database, if the :ref:`actions_execute_write_sql` permission is enabled.
|
|
||||||
|
|
||||||
This page extracts named parameters from the SQL, shows the tables that will be affected and lists the permissions required before the query can be executed. It also includes templates for common ``INSERT``, ``UPDATE`` and ``DELETE`` statements.
|
|
||||||
|
|
||||||
Datasette checks additional permissions based on the operations in the SQL. Row changes require the relevant table-level permissions such as :ref:`actions_insert_row`, :ref:`actions_update_row` and :ref:`actions_delete_row`; reads from source tables require :ref:`actions_view_table`; and schema changes require permissions such as :ref:`actions_create_table`, :ref:`actions_alter_table` or :ref:`actions_drop_table`.
|
|
||||||
|
|
||||||
Use the :ref:`ExecuteWriteView` JSON API to execute writable SQL programmatically.
|
|
||||||
|
|
||||||
.. _pages_stored_query_browser:
|
|
||||||
|
|
||||||
Stored query browsers
|
|
||||||
---------------------
|
|
||||||
|
|
||||||
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database.
|
|
||||||
|
|
||||||
These pages support search, pagination and filters for read-only or writable queries and private or public queries. Adding a ``.json`` extension to either URL returns the same list as JSON.
|
|
||||||
|
|
||||||
.. _TableView:
|
.. _TableView:
|
||||||
|
|
||||||
Table
|
Table
|
||||||
|
|
|
||||||
|
|
@ -609,7 +609,7 @@ When a request is received, the ``"render"`` callback function is called with ze
|
||||||
The SQL query that was executed.
|
The SQL query that was executed.
|
||||||
|
|
||||||
``query_name`` - string or None
|
``query_name`` - string or None
|
||||||
If this was the execution of a :ref:`stored query <stored_queries>`, the name of that query.
|
If this was the execution of a :ref:`canned query <canned_queries>`, the name of that query.
|
||||||
|
|
||||||
``database`` - string
|
``database`` - string
|
||||||
The name of the database.
|
The name of the database.
|
||||||
|
|
@ -1207,6 +1207,85 @@ Potential use-cases:
|
||||||
|
|
||||||
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__
|
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__
|
||||||
|
|
||||||
|
.. _plugin_hook_canned_queries:
|
||||||
|
|
||||||
|
canned_queries(datasette, database, actor)
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``database`` - string
|
||||||
|
The name of the database.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
Use this hook to return a dictionary of additional :ref:`canned query <canned_queries>` definitions for the specified database. The return value should be the same shape as the JSON described in the :ref:`canned query <canned_queries>` documentation.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(datasette, database):
|
||||||
|
if database == "mydb":
|
||||||
|
return {
|
||||||
|
"my_query": {
|
||||||
|
"sql": "select * from my_table where id > :min_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
The hook can alternatively return an awaitable function that returns a list. Here's an example that returns queries that have been stored in the ``saved_queries`` database table, if one exists:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(datasette, database):
|
||||||
|
async def inner():
|
||||||
|
db = datasette.get_database(database)
|
||||||
|
if await db.table_exists("saved_queries"):
|
||||||
|
results = await db.execute(
|
||||||
|
"select name, sql from saved_queries"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
result["name"]: {"sql": result["sql"]}
|
||||||
|
for result in results
|
||||||
|
}
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
The actor parameter can be used to include the currently authenticated actor in your decision. Here's an example that returns saved queries that were saved by that actor:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(datasette, database, actor):
|
||||||
|
async def inner():
|
||||||
|
db = datasette.get_database(database)
|
||||||
|
if actor is not None and await db.table_exists(
|
||||||
|
"saved_queries"
|
||||||
|
):
|
||||||
|
results = await db.execute(
|
||||||
|
"select name, sql from saved_queries where actor_id = :id",
|
||||||
|
{"id": actor["id"]},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
result["name"]: {"sql": result["sql"]}
|
||||||
|
for result in results
|
||||||
|
}
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
Example: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__
|
||||||
|
|
||||||
.. _plugin_hook_actor_from_request:
|
.. _plugin_hook_actor_from_request:
|
||||||
|
|
||||||
actor_from_request(datasette, request)
|
actor_from_request(datasette, request)
|
||||||
|
|
@ -1625,7 +1704,7 @@ register_magic_parameters(datasette)
|
||||||
``datasette`` - :ref:`internals_datasette`
|
``datasette`` - :ref:`internals_datasette`
|
||||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``.
|
||||||
|
|
||||||
:ref:`queries_magic_parameters` can be used to add automatic parameters to :ref:`configured queries <queries>`. This plugin hook allows additional magic parameters to be defined by plugins.
|
:ref:`canned_queries_magic_parameters` can be used to add automatic parameters to :ref:`canned queries <canned_queries>`. This plugin hook allows additional magic parameters to be defined by plugins.
|
||||||
|
|
||||||
Magic parameters all take this format: ``_prefix_rest_of_parameter``. The prefix indicates which magic parameter function should be called - the rest of the parameter is passed as an argument to that function.
|
Magic parameters all take this format: ``_prefix_rest_of_parameter``. The prefix indicates which magic parameter function should be called - the rest of the parameter is passed as an argument to that function.
|
||||||
|
|
||||||
|
|
@ -1802,106 +1881,6 @@ Using :ref:`internals_datasette_urls` here ensures that links in the menu will t
|
||||||
|
|
||||||
Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
|
Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
|
||||||
|
|
||||||
.. _plugin_hook_jump_items_sql:
|
|
||||||
|
|
||||||
jump_items_sql(datasette, actor, request)
|
|
||||||
-----------------------------------------
|
|
||||||
|
|
||||||
``datasette`` - :ref:`internals_datasette`
|
|
||||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
|
||||||
|
|
||||||
``actor`` - dictionary or None
|
|
||||||
The currently authenticated :ref:`actor <authentication_actor>`.
|
|
||||||
|
|
||||||
``request`` - :ref:`internals_request`
|
|
||||||
The current HTTP request.
|
|
||||||
|
|
||||||
This hook allows plugins to add extra results to Datasette's ``/`` jump menu, which is powered by the ``/-/jump`` JSON endpoint.
|
|
||||||
|
|
||||||
Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and stored query results. The hook can also be an ``async def`` function, or return an awaitable that resolves to one of these values.
|
|
||||||
|
|
||||||
``JumpSQL`` queries run against Datasette's internal database by default. To run a query against another database, pass its name as the optional ``database=`` argument. For example, ``JumpSQL(database="content", sql="...")`` runs against the ``content`` database.
|
|
||||||
|
|
||||||
Datasette groups ``JumpSQL`` queries by database and executes one ``UNION ALL`` query for each database.
|
|
||||||
|
|
||||||
The SQL query must return these columns:
|
|
||||||
|
|
||||||
``type``
|
|
||||||
A short type string for the result, for example ``"app"`` or ``"dashboard"``. The jump menu displays this above the item as a category label.
|
|
||||||
|
|
||||||
``label``
|
|
||||||
The stable name for the result. This is returned as ``name`` in the JSON API and is used for sorting.
|
|
||||||
|
|
||||||
``description``
|
|
||||||
Optional longer text describing this individual item, or ``NULL``. The jump menu displays this below the item's URL when it is present.
|
|
||||||
|
|
||||||
``url``
|
|
||||||
The URL to navigate to when the item is selected. This can be either a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods. For example, ``json_object('method', 'table', 'database', 'fixtures', 'table', 'facetable')`` calls ``datasette.urls.table(database='fixtures', table='facetable')``. Unknown methods or invalid named arguments will result in an error.
|
|
||||||
|
|
||||||
``search_text``
|
|
||||||
Text that should be searched by the ``?q=`` parameter.
|
|
||||||
|
|
||||||
``display_name``
|
|
||||||
A human-readable label for the result, or ``NULL``. Datasette returns this as ``display_name`` in the JSON API, and the jump menu shows it as the primary readable label with ``name`` shown underneath.
|
|
||||||
|
|
||||||
Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before adding the SQL fragment to the per-database ``UNION ALL`` query.
|
|
||||||
|
|
||||||
This example returns a SQL fragment that searches rows from a ``dashboards`` table in the ``content`` database. The ``url`` column uses ``json_object()`` to describe a call to ``datasette.urls.row(database='content', table='dashboards', row_path=slug)``:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.jump import JumpSQL
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def jump_items_sql(datasette, actor, request):
|
|
||||||
if not actor:
|
|
||||||
return None
|
|
||||||
return JumpSQL(
|
|
||||||
sql="""
|
|
||||||
SELECT
|
|
||||||
'dashboard' AS type,
|
|
||||||
slug AS label,
|
|
||||||
description,
|
|
||||||
json_object(
|
|
||||||
'method', 'row',
|
|
||||||
'database', 'content',
|
|
||||||
'table', 'dashboards',
|
|
||||||
'row_path', slug
|
|
||||||
) AS url,
|
|
||||||
slug || ' ' || COALESCE(title, '') || ' ' || COALESCE(description, '') AS search_text,
|
|
||||||
title AS display_name
|
|
||||||
FROM dashboards
|
|
||||||
WHERE owner_id = :actor_id
|
|
||||||
""",
|
|
||||||
params={"actor_id": actor["id"]},
|
|
||||||
database="content",
|
|
||||||
)
|
|
||||||
|
|
||||||
This example uses the ``JumpSQL.menu_item()`` shortcut to add a single "Plugin dashboard" result for signed-in users:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.jump import JumpSQL
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def jump_items_sql(datasette, actor, request):
|
|
||||||
if not actor:
|
|
||||||
return None
|
|
||||||
return JumpSQL.menu_item(
|
|
||||||
item_type="dashboard",
|
|
||||||
label="plugin-dashboard",
|
|
||||||
description="Review plugin status and configuration.",
|
|
||||||
url="/-/plugin-dashboard",
|
|
||||||
search_text="plugin dashboard",
|
|
||||||
display_name="Plugin dashboard",
|
|
||||||
)
|
|
||||||
|
|
||||||
``JumpSQL.menu_item(...)`` is a shortcut for adding a single jump menu item from Python code. It accepts the keyword arguments shown above.
|
|
||||||
|
|
||||||
.. _plugin_actions:
|
.. _plugin_actions:
|
||||||
|
|
||||||
Action hooks
|
Action hooks
|
||||||
|
|
@ -1994,7 +1973,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params)
|
||||||
The name of the database.
|
The name of the database.
|
||||||
|
|
||||||
``query_name`` - string or None
|
``query_name`` - string or None
|
||||||
The name of the stored query, or ``None`` if this is an arbitrary SQL query.
|
The name of the canned query, or ``None`` if this is an arbitrary SQL query.
|
||||||
|
|
||||||
``request`` - :ref:`internals_request`
|
``request`` - :ref:`internals_request`
|
||||||
The current HTTP request.
|
The current HTTP request.
|
||||||
|
|
@ -2005,7 +1984,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params)
|
||||||
``params`` - dictionary
|
``params`` - dictionary
|
||||||
The parameters passed to the SQL query, if any.
|
The parameters passed to the SQL query, if any.
|
||||||
|
|
||||||
Populates a "Query actions" menu on the stored query and arbitrary SQL query pages.
|
Populates a "Query actions" menu on the canned query and arbitrary SQL query pages.
|
||||||
|
|
||||||
This example adds a new query action linking to a page for explaining a query:
|
This example adds a new query action linking to a page for explaining a query:
|
||||||
|
|
||||||
|
|
@ -2269,9 +2248,9 @@ top_query(datasette, request, database, sql)
|
||||||
|
|
||||||
Returns HTML to be displayed at the top of the query results page.
|
Returns HTML to be displayed at the top of the query results page.
|
||||||
|
|
||||||
.. _plugin_hook_top_stored_query:
|
.. _plugin_hook_top_canned_query:
|
||||||
|
|
||||||
top_stored_query(datasette, request, database, query_name)
|
top_canned_query(datasette, request, database, query_name)
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
``datasette`` - :ref:`internals_datasette`
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
|
@ -2284,9 +2263,9 @@ top_stored_query(datasette, request, database, query_name)
|
||||||
The name of the database.
|
The name of the database.
|
||||||
|
|
||||||
``query_name`` - string
|
``query_name`` - string
|
||||||
The name of the stored query.
|
The name of the canned query.
|
||||||
|
|
||||||
Returns HTML to be displayed at the top of the stored query page.
|
Returns HTML to be displayed at the top of the canned query page.
|
||||||
|
|
||||||
.. _plugin_event_tracking:
|
.. _plugin_event_tracking:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,33 +216,6 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
|
||||||
"register_column_types"
|
"register_column_types"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "datasette.default_database_actions",
|
|
||||||
"static": false,
|
|
||||||
"templates": false,
|
|
||||||
"version": null,
|
|
||||||
"hooks": [
|
|
||||||
"database_actions"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "datasette.default_debug_menu",
|
|
||||||
"static": false,
|
|
||||||
"templates": false,
|
|
||||||
"version": null,
|
|
||||||
"hooks": [
|
|
||||||
"jump_items_sql"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "datasette.default_jump_items",
|
|
||||||
"static": false,
|
|
||||||
"templates": false,
|
|
||||||
"version": null,
|
|
||||||
"hooks": [
|
|
||||||
"jump_items_sql"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "datasette.default_magic_parameters",
|
"name": "datasette.default_magic_parameters",
|
||||||
"static": false,
|
"static": false,
|
||||||
|
|
@ -252,12 +225,22 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
|
||||||
"register_magic_parameters"
|
"register_magic_parameters"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "datasette.default_menu_links",
|
||||||
|
"static": false,
|
||||||
|
"templates": false,
|
||||||
|
"version": null,
|
||||||
|
"hooks": [
|
||||||
|
"menu_links"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "datasette.default_permissions",
|
"name": "datasette.default_permissions",
|
||||||
"static": false,
|
"static": false,
|
||||||
"templates": false,
|
"templates": false,
|
||||||
"version": null,
|
"version": null,
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
"canned_queries",
|
||||||
"permission_resources_sql"
|
"permission_resources_sql"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ Warning
|
||||||
The following steps are recommended:
|
The following steps are recommended:
|
||||||
|
|
||||||
- Disable arbitrary SQL queries by untrusted users. See :ref:`authentication_permissions_execute_sql` for ways to do this. The easiest is to start Datasette with the ``datasette --setting default_allow_sql off`` option.
|
- Disable arbitrary SQL queries by untrusted users. See :ref:`authentication_permissions_execute_sql` for ways to do this. The easiest is to start Datasette with the ``datasette --setting default_allow_sql off`` option.
|
||||||
- Define :ref:`queries <queries>` with the SQL queries that use SpatiaLite functions that you want people to be able to execute.
|
- Define :ref:`canned_queries` with the SQL queries that use SpatiaLite functions that you want people to be able to execute.
|
||||||
|
|
||||||
The `Datasette SpatiaLite tutorial <https://datasette.io/tutorials/spatialite>`__ includes detailed instructions for running SpatiaLite safely using these techniques
|
The `Datasette SpatiaLite tutorial <https://datasette.io/tutorials/spatialite>`__ includes detailed instructions for running SpatiaLite safely using these techniques
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ Datasette treats SQLite database files as read-only and immutable. This means it
|
||||||
|
|
||||||
The easiest way to execute custom SQL against Datasette is through the web UI. The database index page includes a SQL editor that lets you run any SELECT query you like. You can also construct queries using the filter interface on the tables page, then click "View and edit SQL" to open that query in the custom SQL editor.
|
The easiest way to execute custom SQL against Datasette is through the web UI. The database index page includes a SQL editor that lets you run any SELECT query you like. You can also construct queries using the filter interface on the tables page, then click "View and edit SQL" to open that query in the custom SQL editor.
|
||||||
|
|
||||||
For mutable databases, actors with the appropriate permissions can use the :ref:`write SQL page <pages_execute_write>` to execute SQL statements that insert, update or delete rows.
|
|
||||||
|
|
||||||
Note that this interface is only available if the :ref:`actions_execute_sql` permission is allowed. See :ref:`authentication_permissions_execute_sql`.
|
Note that this interface is only available if the :ref:`actions_execute_sql` permission is allowed. See :ref:`authentication_permissions_execute_sql`.
|
||||||
|
|
||||||
Any Datasette SQL query is reflected in the URL of the page, allowing you to bookmark them, share them with others and navigate through previous queries using your browser back button.
|
Any Datasette SQL query is reflected in the URL of the page, allowing you to bookmark them, share them with others and navigate through previous queries using your browser back button.
|
||||||
|
|
@ -68,12 +66,12 @@ You can also use the `sqlite-utils <https://sqlite-utils.datasette.io/>`__ tool
|
||||||
|
|
||||||
sqlite-utils create-view sf-trees.db demo_view "select qSpecies from Street_Tree_List"
|
sqlite-utils create-view sf-trees.db demo_view "select qSpecies from Street_Tree_List"
|
||||||
|
|
||||||
.. _queries:
|
.. _canned_queries:
|
||||||
|
|
||||||
Queries
|
Canned queries
|
||||||
-------
|
--------------
|
||||||
|
|
||||||
As an alternative to adding views to your database, you can define named queries inside your ``datasette.yaml`` file. Here's an example:
|
As an alternative to adding views to your database, you can define canned queries inside your ``datasette.yaml`` file. Here's an example:
|
||||||
|
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
from metadata_doc import config_example, config_example
|
from metadata_doc import config_example, config_example
|
||||||
|
|
@ -122,67 +120,24 @@ Then run Datasette like this::
|
||||||
|
|
||||||
datasette sf-trees.db -m metadata.json
|
datasette sf-trees.db -m metadata.json
|
||||||
|
|
||||||
Each configured query will be listed on the database index page, and will also get its own URL at::
|
Each canned query will be listed on the database index page, and will also get its own URL at::
|
||||||
|
|
||||||
/database-name/query-name
|
/database-name/canned-query-name
|
||||||
|
|
||||||
For the above example, that URL would be::
|
For the above example, that URL would be::
|
||||||
|
|
||||||
/sf-trees/just_species
|
/sf-trees/just_species
|
||||||
|
|
||||||
You can optionally include ``"title"`` and ``"description"`` keys to show a title and description on the query page. As with regular table metadata you can alternatively specify ``"description_html"`` to have your description rendered as HTML (rather than having HTML special characters escaped).
|
You can optionally include ``"title"`` and ``"description"`` keys to show a title and description on the canned query page. As with regular table metadata you can alternatively specify ``"description_html"`` to have your description rendered as HTML (rather than having HTML special characters escaped).
|
||||||
|
|
||||||
.. _stored_queries:
|
.. _canned_queries_named_parameters:
|
||||||
.. _saved_queries:
|
|
||||||
|
|
||||||
Stored queries
|
Canned query parameters
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Datasette stores both configured queries and user-created queries in the ``queries`` table in the :ref:`internal database <internals_internal>`. Configured queries come from the ``queries`` section of ``datasette.yaml``. User-created stored queries can be created from the SQL query page by actors with the :ref:`actions_store_query` and :ref:`actions_execute_sql` permissions. Writable stored queries also require the permissions needed for the writes they perform.
|
Canned queries support named parameters, so if you include those in the SQL you will then be able to enter them using the form fields on the canned query page or by adding them to the URL. This means canned queries can be used to create custom JSON APIs based on a carefully designed SQL statement.
|
||||||
|
|
||||||
Stored queries created by users default to private. Private stored queries can only be viewed, updated or deleted by the actor that created them. Broad ``view-query``, ``update-query`` or ``delete-query`` permission grants still do not allow other actors to access another actor's private stored queries.
|
Here's an example of a canned query with a named parameter:
|
||||||
|
|
||||||
Stored queries created by users are untrusted. This means they execute using the permissions of the actor who runs them, as if that actor had pasted the SQL into the regular custom SQL interface or write SQL interface. Read-only stored queries require ``execute-sql``. Writable stored queries require ``execute-write-sql`` plus the relevant table-level write permissions. SQL functions are allowed and are not separately restricted by Datasette permissions.
|
|
||||||
|
|
||||||
.. _trusted_stored_queries:
|
|
||||||
.. _trusted_saved_queries:
|
|
||||||
|
|
||||||
Trusted stored queries
|
|
||||||
++++++++++++++++++++++
|
|
||||||
|
|
||||||
A trusted stored query can execute with ``view-query`` permission alone. It skips the additional ``execute-sql`` and write permission checks that are applied to untrusted stored queries.
|
|
||||||
|
|
||||||
Trusted stored queries should only be used for SQL that has been reviewed by someone trusted to configure the Datasette instance. For that reason, trusted stored queries can only be added using configuration. Users cannot create trusted stored queries through the web interface or the stored query JSON API.
|
|
||||||
|
|
||||||
Queries defined in ``datasette.yaml`` are trusted by default:
|
|
||||||
|
|
||||||
.. code-block:: yaml
|
|
||||||
|
|
||||||
databases:
|
|
||||||
mydatabase:
|
|
||||||
queries:
|
|
||||||
report:
|
|
||||||
sql: select * from report
|
|
||||||
|
|
||||||
You can opt out of this behavior for a configured query using ``is_trusted: false``:
|
|
||||||
|
|
||||||
.. code-block:: yaml
|
|
||||||
|
|
||||||
databases:
|
|
||||||
mydatabase:
|
|
||||||
queries:
|
|
||||||
report:
|
|
||||||
sql: select * from report
|
|
||||||
is_trusted: false
|
|
||||||
|
|
||||||
.. _queries_named_parameters:
|
|
||||||
|
|
||||||
Query parameters
|
|
||||||
~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Configured queries support named parameters, so if you include those in the SQL you will then be able to enter them using the form fields on the query page or by adding them to the URL. This means configured queries can be used to create custom JSON APIs based on a carefully designed SQL statement.
|
|
||||||
|
|
||||||
Here's an example of a configured query with a named parameter:
|
|
||||||
|
|
||||||
.. code-block:: sql
|
.. code-block:: sql
|
||||||
|
|
||||||
|
|
@ -192,7 +147,7 @@ Here's an example of a configured query with a named parameter:
|
||||||
where neighborhood like '%' || :text || '%'
|
where neighborhood like '%' || :text || '%'
|
||||||
order by neighborhood;
|
order by neighborhood;
|
||||||
|
|
||||||
The query configuration looks like this:
|
In the canned query configuration looks like this:
|
||||||
|
|
||||||
|
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
|
|
@ -249,7 +204,7 @@ The query configuration looks like this:
|
||||||
|
|
||||||
Note that we are using SQLite string concatenation here - the ``||`` operator - to add wildcard ``%`` characters to the string provided by the user.
|
Note that we are using SQLite string concatenation here - the ``||`` operator - to add wildcard ``%`` characters to the string provided by the user.
|
||||||
|
|
||||||
You can try this query out here:
|
You can try this canned query out here:
|
||||||
https://latest.datasette.io/fixtures/neighborhood_search?text=town
|
https://latest.datasette.io/fixtures/neighborhood_search?text=town
|
||||||
|
|
||||||
In this example the ``:text`` named parameter is automatically extracted from the query using a regular expression.
|
In this example the ``:text`` named parameter is automatically extracted from the query using a regular expression.
|
||||||
|
|
@ -315,17 +270,17 @@ You can alternatively provide an explicit list of named parameters using the ``"
|
||||||
}
|
}
|
||||||
.. [[[end]]]
|
.. [[[end]]]
|
||||||
|
|
||||||
.. _queries_options:
|
.. _canned_queries_options:
|
||||||
|
|
||||||
Additional query options
|
Additional canned query options
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Additional options can be specified for configured queries in the YAML or JSON configuration.
|
Additional options can be specified for canned queries in the YAML or JSON configuration.
|
||||||
|
|
||||||
hide_sql
|
hide_sql
|
||||||
++++++++
|
++++++++
|
||||||
|
|
||||||
Configured queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a "show" link that can be used to make it visible.
|
Canned queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a "show" link that can be used to make it visible.
|
||||||
|
|
||||||
Add the ``"hide_sql": true`` option to hide the SQL query by default.
|
Add the ``"hide_sql": true`` option to hide the SQL query by default.
|
||||||
|
|
||||||
|
|
@ -334,7 +289,7 @@ fragment
|
||||||
|
|
||||||
Some plugins, such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, can be configured by including additional data in the fragment hash of the URL - the bit that comes after a ``#`` symbol.
|
Some plugins, such as `datasette-vega <https://github.com/simonw/datasette-vega>`__, can be configured by including additional data in the fragment hash of the URL - the bit that comes after a ``#`` symbol.
|
||||||
|
|
||||||
You can set a default fragment hash that will be included in the link to the query from the database index page using the ``"fragment"`` key.
|
You can set a default fragment hash that will be included in the link to the canned query from the database index page using the ``"fragment"`` key.
|
||||||
|
|
||||||
This example demonstrates both ``fragment`` and ``hide_sql``:
|
This example demonstrates both ``fragment`` and ``hide_sql``:
|
||||||
|
|
||||||
|
|
@ -391,14 +346,14 @@ This example demonstrates both ``fragment`` and ``hide_sql``:
|
||||||
|
|
||||||
`See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action.
|
`See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action.
|
||||||
|
|
||||||
.. _queries_writable:
|
.. _canned_queries_writable:
|
||||||
|
|
||||||
Writable queries
|
Writable canned queries
|
||||||
~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Configured queries are read-only by default. You can use the ``"write": true`` key to indicate that a query can write to the database.
|
Canned queries by default are read-only. You can use the ``"write": true`` key to indicate that a canned query can write to the database.
|
||||||
|
|
||||||
See :ref:`authentication_permissions_query` for details on how to add permission checks to queries, using the ``"allow"`` key.
|
See :ref:`authentication_permissions_query` for details on how to add permission checks to canned queries, using the ``"allow"`` key.
|
||||||
|
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
config_example(cog, {
|
config_example(cog, {
|
||||||
|
|
@ -526,14 +481,14 @@ You can pre-populate form fields when the page first loads using a query string,
|
||||||
|
|
||||||
If you specify a query in ``"on_success_message_sql"``, that query will be executed after the main query. The first column of the first row return by that query will be displayed as a success message. Named parameters from the main query will be made available to the success message query as well.
|
If you specify a query in ``"on_success_message_sql"``, that query will be executed after the main query. The first column of the first row return by that query will be displayed as a success message. Named parameters from the main query will be made available to the success message query as well.
|
||||||
|
|
||||||
.. _queries_magic_parameters:
|
.. _canned_queries_magic_parameters:
|
||||||
|
|
||||||
Magic parameters
|
Magic parameters
|
||||||
~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Named parameters that start with an underscore are special: they can be used to automatically add values created by Datasette that are not contained in the incoming form fields or query string.
|
Named parameters that start with an underscore are special: they can be used to automatically add values created by Datasette that are not contained in the incoming form fields or query string.
|
||||||
|
|
||||||
These magic parameters are only supported for configured queries: to avoid security issues (such as queries that extract the user's private cookies) they are not available to SQL that is executed by the user as a custom SQL query.
|
These magic parameters are only supported for canned queries: to avoid security issues (such as queries that extract the user's private cookies) they are not available to SQL that is executed by the user as a custom SQL query.
|
||||||
|
|
||||||
Available magic parameters are:
|
Available magic parameters are:
|
||||||
|
|
||||||
|
|
@ -623,14 +578,14 @@ The form presented at ``/mydatabase/add_message`` will have just a field for ``m
|
||||||
|
|
||||||
Additional custom magic parameters can be added by plugins using the :ref:`plugin_hook_register_magic_parameters` hook.
|
Additional custom magic parameters can be added by plugins using the :ref:`plugin_hook_register_magic_parameters` hook.
|
||||||
|
|
||||||
.. _queries_json_api:
|
.. _canned_queries_json_api:
|
||||||
|
|
||||||
JSON API for writable queries
|
JSON API for writable canned queries
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Writable queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON.
|
Writable canned queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON.
|
||||||
|
|
||||||
To submit JSON to a writable query, encode key/value parameters as a JSON document::
|
To submit JSON to a writable canned query, encode key/value parameters as a JSON document::
|
||||||
|
|
||||||
POST /mydatabase/add_message
|
POST /mydatabase/add_message
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,48 +82,6 @@ This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepar
|
||||||
|
|
||||||
If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request.
|
If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request.
|
||||||
|
|
||||||
.. _testing_plugins_datasette_fixtures_database:
|
|
||||||
|
|
||||||
Using Datasette's fixtures database
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Datasette's own test suite uses a SQLite database with tables that exercise features such as compound primary keys, foreign keys, sortable columns, facets, full-text search and binary data.
|
|
||||||
|
|
||||||
You can use those same tables in your plugin tests using the ``populate_fixture_database(conn)`` helper in ``datasette.fixtures``:
|
|
||||||
|
|
||||||
Be aware that future Datasette releases may change details of these tables, so try not to rely on their exact structure in your own tests.
|
|
||||||
|
|
||||||
.. _datasette_fixtures_populate_fixture_database:
|
|
||||||
|
|
||||||
``populate_fixture_database(conn)``
|
|
||||||
Populates an existing SQLite connection with the fixture tables.
|
|
||||||
|
|
||||||
For an in-memory test database:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette.app import Datasette
|
|
||||||
from datasette.fixtures import populate_fixture_database
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def datasette():
|
|
||||||
datasette = Datasette()
|
|
||||||
db = datasette.add_memory_database("fixtures")
|
|
||||||
await db.execute_write_fn(populate_fixture_database)
|
|
||||||
await datasette.invoke_startup()
|
|
||||||
return datasette
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_facetable(datasette):
|
|
||||||
response = await datasette.client.get(
|
|
||||||
"/fixtures/facetable.json?_shape=array"
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
.. _testing_plugins_autoclose:
|
.. _testing_plugins_autoclose:
|
||||||
|
|
||||||
Automatic cleanup of Datasette instances
|
Automatic cleanup of Datasette instances
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ except (KeyError, TypeError):
|
||||||
New code:
|
New code:
|
||||||
```python
|
```python
|
||||||
try:
|
try:
|
||||||
query_info = await datasette.get_query(database, query_name)
|
query_info = await datasette.get_canned_query(database, query_name, request.actor)
|
||||||
if query_info and "title" in query_info:
|
if query_info and "title" in query_info:
|
||||||
title = query_info["title"]
|
title = query_info["title"]
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
|
|
@ -253,7 +253,7 @@ except (KeyError, TypeError):
|
||||||
|
|
||||||
### Update render functions to async
|
### Update render functions to async
|
||||||
|
|
||||||
If your plugin's render function needs to call `datasette.get_query()` or other async Datasette methods, it must be declared as async:
|
If your plugin's render function needs to call `datasette.get_canned_query()` or other async Datasette methods, it must be declared as async:
|
||||||
|
|
||||||
Old code:
|
Old code:
|
||||||
```python
|
```python
|
||||||
|
|
@ -268,7 +268,7 @@ New code:
|
||||||
async def render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):
|
async def render_atom(datasette, request, sql, columns, rows, database, table, query_name, view_name, data):
|
||||||
# ...
|
# ...
|
||||||
if query_name:
|
if query_name:
|
||||||
query_info = await datasette.get_query(database, query_name)
|
query_info = await datasette.get_canned_query(database, query_name, request.actor)
|
||||||
if query_info and "title" in query_info:
|
if query_info and "title" in query_info:
|
||||||
title = query_info["title"]
|
title = query_info["title"]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ dependencies = [
|
||||||
"pluggy>=1.0",
|
"pluggy>=1.0",
|
||||||
"uvicorn>=0.11",
|
"uvicorn>=0.11",
|
||||||
"aiofiles>=0.4",
|
"aiofiles>=0.4",
|
||||||
|
"janus>=0.6.2",
|
||||||
"PyYAML>=5.3",
|
"PyYAML>=5.3",
|
||||||
"mergedeep>=1.1.1",
|
"mergedeep>=1.1.1",
|
||||||
"itsdangerous>=1.1",
|
"itsdangerous>=1.1",
|
||||||
|
|
@ -62,7 +63,7 @@ dev = [
|
||||||
"pytest-xdist>=2.2.1",
|
"pytest-xdist>=2.2.1",
|
||||||
"pytest-asyncio>=1.2.0",
|
"pytest-asyncio>=1.2.0",
|
||||||
"beautifulsoup4>=4.8.1",
|
"beautifulsoup4>=4.8.1",
|
||||||
"black==26.3.1",
|
"black==26.1.0",
|
||||||
"blacken-docs==1.20.0",
|
"blacken-docs==1.20.0",
|
||||||
"pytest-timeout>=1.4.2",
|
"pytest-timeout>=1.4.2",
|
||||||
"trustme>=0.7",
|
"trustme>=0.7",
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ async def ds_client():
|
||||||
"num_sql_threads": 1,
|
"num_sql_threads": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
from datasette.fixtures import populate_fixture_database
|
from .fixtures import TABLES, TABLE_PARAMETERIZED_SQL
|
||||||
|
|
||||||
# Use a unique memory_name to avoid collisions between different
|
# Use a unique memory_name to avoid collisions between different
|
||||||
# Datasette instances in the same process, but use "fixtures" for routing
|
# Datasette instances in the same process, but use "fixtures" for routing
|
||||||
|
|
@ -82,7 +82,10 @@ async def ds_client():
|
||||||
|
|
||||||
def prepare(conn):
|
def prepare(conn):
|
||||||
if not conn.execute("select count(*) from sqlite_master").fetchone()[0]:
|
if not conn.execute("select count(*) from sqlite_master").fetchone()[0]:
|
||||||
populate_fixture_database(conn)
|
conn.executescript(TABLES)
|
||||||
|
for sql, params in TABLE_PARAMETERIZED_SQL:
|
||||||
|
with conn:
|
||||||
|
conn.execute(sql, params)
|
||||||
|
|
||||||
await db.execute_write_fn(prepare)
|
await db.execute_write_fn(prepare)
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
|
|
@ -261,6 +264,8 @@ from .fixtures import ( # noqa: E402, F401
|
||||||
app_client_with_cors,
|
app_client_with_cors,
|
||||||
app_client_with_dot,
|
app_client_with_dot,
|
||||||
app_client_with_trace,
|
app_client_with_trace,
|
||||||
|
generate_compound_rows,
|
||||||
|
generate_sortable_rows,
|
||||||
make_app_client,
|
make_app_client,
|
||||||
TEMP_PLUGIN_SECRET_FILE,
|
TEMP_PLUGIN_SECRET_FILE,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.fixtures import (
|
from datasette.utils.sqlite import sqlite3
|
||||||
EXTRA_DATABASE_SQL,
|
|
||||||
write_extra_database,
|
|
||||||
write_fixture_database,
|
|
||||||
)
|
|
||||||
from datasette.utils.testing import TestClient
|
from datasette.utils.testing import TestClient
|
||||||
import click
|
import click
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import pytest
|
import pytest
|
||||||
|
import random
|
||||||
|
import string
|
||||||
import tempfile
|
import tempfile
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
|
|
@ -35,6 +34,7 @@ EXPECTED_PLUGINS = [
|
||||||
"hooks": [
|
"hooks": [
|
||||||
"actor_from_request",
|
"actor_from_request",
|
||||||
"asgi_wrapper",
|
"asgi_wrapper",
|
||||||
|
"canned_queries",
|
||||||
"database_actions",
|
"database_actions",
|
||||||
"extra_body_script",
|
"extra_body_script",
|
||||||
"extra_css_urls",
|
"extra_css_urls",
|
||||||
|
|
@ -67,6 +67,7 @@ EXPECTED_PLUGINS = [
|
||||||
"hooks": [
|
"hooks": [
|
||||||
"actor_from_request",
|
"actor_from_request",
|
||||||
"asgi_wrapper",
|
"asgi_wrapper",
|
||||||
|
"canned_queries",
|
||||||
"extra_js_urls",
|
"extra_js_urls",
|
||||||
"extra_template_vars",
|
"extra_template_vars",
|
||||||
"handle_exception",
|
"handle_exception",
|
||||||
|
|
@ -127,18 +128,19 @@ def make_app_client(
|
||||||
else:
|
else:
|
||||||
files = [filepath]
|
files = [filepath]
|
||||||
immutables = []
|
immutables = []
|
||||||
write_fixture_database(filepath)
|
conn = sqlite3.connect(filepath)
|
||||||
|
conn.executescript(TABLES)
|
||||||
|
for sql, params in TABLE_PARAMETERIZED_SQL:
|
||||||
|
with conn:
|
||||||
|
conn.execute(sql, params)
|
||||||
|
# Close the connection to avoid "too many open files" errors
|
||||||
|
conn.close()
|
||||||
if extra_databases is not None:
|
if extra_databases is not None:
|
||||||
for extra_filename, extra_sql in extra_databases.items():
|
for extra_filename, extra_sql in extra_databases.items():
|
||||||
extra_filepath = os.path.join(tmpdir, extra_filename)
|
extra_filepath = os.path.join(tmpdir, extra_filename)
|
||||||
if extra_sql == EXTRA_DATABASE_SQL:
|
c2 = sqlite3.connect(extra_filepath)
|
||||||
write_extra_database(extra_filepath)
|
c2.executescript(extra_sql)
|
||||||
else:
|
c2.close()
|
||||||
from datasette.utils.sqlite import sqlite3
|
|
||||||
|
|
||||||
c2 = sqlite3.connect(extra_filepath)
|
|
||||||
c2.executescript(extra_sql)
|
|
||||||
c2.close()
|
|
||||||
# Insert at start to help test /-/databases ordering:
|
# Insert at start to help test /-/databases ordering:
|
||||||
files.insert(0, extra_filepath)
|
files.insert(0, extra_filepath)
|
||||||
os.chdir(os.path.dirname(filepath))
|
os.chdir(os.path.dirname(filepath))
|
||||||
|
|
@ -277,6 +279,29 @@ def app_client_immutable_and_inspect_file():
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def generate_compound_rows(num):
|
||||||
|
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):
|
||||||
|
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"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"name-of-plugin": {"depth": "root"},
|
"name-of-plugin": {"depth": "root"},
|
||||||
|
|
@ -374,6 +399,345 @@ METADATA = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def assert_permissions_checked(datasette, actions):
|
def assert_permissions_checked(datasette, actions):
|
||||||
# actions is a list of "action" or (action, resource) tuples
|
# actions is a list of "action" or (action, resource) tuples
|
||||||
|
|
@ -455,7 +819,12 @@ def cli(db_filename, config, metadata, plugins_path, recreate, extra_db_filename
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
pathlib.Path(db_filename).unlink()
|
pathlib.Path(db_filename).unlink()
|
||||||
write_fixture_database(db_filename)
|
conn = sqlite3.connect(db_filename)
|
||||||
|
conn.executescript(TABLES)
|
||||||
|
for sql, params in TABLE_PARAMETERIZED_SQL:
|
||||||
|
with conn:
|
||||||
|
conn.execute(sql, params)
|
||||||
|
conn.close()
|
||||||
print(f"Test tables written to {db_filename}")
|
print(f"Test tables written to {db_filename}")
|
||||||
if metadata:
|
if metadata:
|
||||||
with open(metadata, "w") as fp:
|
with open(metadata, "w") as fp:
|
||||||
|
|
@ -482,7 +851,9 @@ def cli(db_filename, config, metadata, plugins_path, recreate, extra_db_filename
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
pathlib.Path(extra_db_filename).unlink()
|
pathlib.Path(extra_db_filename).unlink()
|
||||||
write_extra_database(extra_db_filename)
|
conn = sqlite3.connect(extra_db_filename)
|
||||||
|
conn.executescript(EXTRA_DATABASE_SQL)
|
||||||
|
conn.close()
|
||||||
print(f"Test tables written to {extra_db_filename}")
|
print(f"Test tables written to {extra_db_filename}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,11 @@ def startup(datasette):
|
||||||
_ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow)
|
_ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow)
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(datasette, database, actor):
|
||||||
|
return {"from_hook": f"select 1, '{actor['id'] if actor else 'null'}' as actor_id"}
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def register_magic_parameters():
|
def register_magic_parameters():
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
@ -382,8 +387,8 @@ def view_actions(datasette, database, view, actor):
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def query_actions(datasette, database, query_name, sql):
|
def query_actions(datasette, database, query_name, sql):
|
||||||
# Don't explain an explain (or a missing query)
|
# Don't explain an explain
|
||||||
if not sql or sql.lower().startswith("explain"):
|
if sql.lower().startswith("explain"):
|
||||||
return
|
return
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,20 @@ def startup(datasette):
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(datasette, database):
|
||||||
|
async def inner():
|
||||||
|
return {
|
||||||
|
"from_async_hook": "select {}".format(
|
||||||
|
(
|
||||||
|
await datasette.get_database(database).execute("select 1 + 1")
|
||||||
|
).first()[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(trylast=True)
|
@hookimpl(trylast=True)
|
||||||
def menu_links(datasette, actor):
|
def menu_links(datasette, actor):
|
||||||
async def inner():
|
async def inner():
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,10 @@ import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.permissions import PermissionSQL
|
from datasette.permissions import PermissionSQL
|
||||||
from datasette.resources import DatabaseResource, QueryResource, TableResource
|
from datasette.resources import TableResource
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
def test_resource_string_representations():
|
|
||||||
assert str(DatabaseResource("content")) == "content"
|
|
||||||
assert repr(DatabaseResource("content")) == (
|
|
||||||
"DatabaseResource(parent='content', child=None)"
|
|
||||||
)
|
|
||||||
assert str(TableResource("content", "dogs")) == "content/dogs"
|
|
||||||
assert repr(TableResource("content", "dogs")) == (
|
|
||||||
"TableResource(parent='content', child='dogs')"
|
|
||||||
)
|
|
||||||
assert str(QueryResource("content", "insert-a-dog")) == "content/insert-a-dog"
|
|
||||||
|
|
||||||
|
|
||||||
# Test plugin that provides permission rules
|
# Test plugin that provides permission rules
|
||||||
class PermissionRulesPlugin:
|
class PermissionRulesPlugin:
|
||||||
def __init__(self, rules_callback):
|
def __init__(self, rules_callback):
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ async def test_ds():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_allowed_resources_global_access(test_ds):
|
async def test_tables_endpoint_global_access(test_ds):
|
||||||
"""Test allowed_resources() with global access permissions"""
|
"""Test /-/tables with global access permissions"""
|
||||||
|
|
||||||
def rules_callback(datasette, actor, action):
|
def rules_callback(datasette, actor, action):
|
||||||
if actor and actor.get("id") == "alice":
|
if actor and actor.get("id") == "alice":
|
||||||
|
|
@ -91,7 +91,7 @@ async def test_tables_allowed_resources_global_access(test_ds):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_database_restriction(test_ds):
|
async def test_tables_endpoint_database_restriction(test_ds):
|
||||||
"""Test allowed_resources() with database-level restriction"""
|
"""Test /-/tables with database-level restriction"""
|
||||||
|
|
||||||
def rules_callback(datasette, actor, action):
|
def rules_callback(datasette, actor, action):
|
||||||
if actor and actor.get("role") == "analyst":
|
if actor and actor.get("role") == "analyst":
|
||||||
|
|
@ -133,7 +133,7 @@ async def test_tables_endpoint_database_restriction(test_ds):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_table_exception(test_ds):
|
async def test_tables_endpoint_table_exception(test_ds):
|
||||||
"""Test allowed_resources() with table-level exception (deny database, allow specific table)"""
|
"""Test /-/tables with table-level exception (deny database, allow specific table)"""
|
||||||
|
|
||||||
def rules_callback(datasette, actor, action):
|
def rules_callback(datasette, actor, action):
|
||||||
if actor and actor.get("id") == "carol":
|
if actor and actor.get("id") == "carol":
|
||||||
|
|
@ -217,7 +217,7 @@ async def test_tables_endpoint_deny_overrides_allow(test_ds):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_no_permissions():
|
async def test_tables_endpoint_no_permissions():
|
||||||
"""Test allowed_resources() when user has no custom permissions (only defaults)"""
|
"""Test /-/tables when user has no custom permissions (only defaults)"""
|
||||||
|
|
||||||
ds = Datasette()
|
ds = Datasette()
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
|
|
@ -241,7 +241,7 @@ async def test_tables_endpoint_no_permissions():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_specific_table_only(test_ds):
|
async def test_tables_endpoint_specific_table_only(test_ds):
|
||||||
"""Test allowed_resources() when only specific tables are allowed (no parent/global rules)"""
|
"""Test /-/tables when only specific tables are allowed (no parent/global rules)"""
|
||||||
|
|
||||||
def rules_callback(datasette, actor, action):
|
def rules_callback(datasette, actor, action):
|
||||||
if actor and actor.get("id") == "dave":
|
if actor and actor.get("id") == "dave":
|
||||||
|
|
@ -283,7 +283,7 @@ async def test_tables_endpoint_specific_table_only(test_ds):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_empty_result(test_ds):
|
async def test_tables_endpoint_empty_result(test_ds):
|
||||||
"""Test allowed_resources() when all tables are explicitly denied"""
|
"""Test /-/tables when all tables are explicitly denied"""
|
||||||
|
|
||||||
def rules_callback(datasette, actor, action):
|
def rules_callback(datasette, actor, action):
|
||||||
if actor and actor.get("id") == "blocked":
|
if actor and actor.get("id") == "blocked":
|
||||||
|
|
@ -314,7 +314,7 @@ async def test_tables_endpoint_empty_result(test_ds):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_no_query_returns_all():
|
async def test_tables_endpoint_no_query_returns_all():
|
||||||
"""Test allowed_resources() without query parameter returns all tables"""
|
"""Test /-/tables without query parameter returns all tables"""
|
||||||
ds = Datasette()
|
ds = Datasette()
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
|
|
||||||
|
|
@ -338,7 +338,7 @@ async def test_tables_endpoint_no_query_returns_all():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_truncation():
|
async def test_tables_endpoint_truncation():
|
||||||
"""Test allowed_resources() truncates at 100 tables and sets truncated flag"""
|
"""Test /-/tables truncates at 100 tables and sets truncated flag"""
|
||||||
ds = Datasette()
|
ds = Datasette()
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
|
|
||||||
|
|
@ -359,7 +359,7 @@ async def test_tables_endpoint_truncation():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_search_single_term():
|
async def test_tables_endpoint_search_single_term():
|
||||||
"""Test allowed_resources()?q=user to filter tables matching 'user'"""
|
"""Test /-/tables?q=user to filter tables matching 'user'"""
|
||||||
|
|
||||||
ds = Datasette()
|
ds = Datasette()
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
|
|
@ -396,7 +396,7 @@ async def test_tables_endpoint_search_single_term():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tables_endpoint_search_multiple_terms():
|
async def test_tables_endpoint_search_multiple_terms():
|
||||||
"""Test allowed_resources()?q=user+profile to filter tables matching .*user.*profile.*"""
|
"""Test /-/tables?q=user+profile to filter tables matching .*user.*profile.*"""
|
||||||
|
|
||||||
ds = Datasette()
|
ds = Datasette()
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
|
|
|
||||||
|
|
@ -717,17 +717,6 @@ def test_cors(
|
||||||
assert "Access-Control-Max-Age" not in response.headers
|
assert "Access-Control-Max-Age" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
def test_cors_query_redirect(app_client_with_cors):
|
|
||||||
# /db?sql= redirects to /db/-/query - the redirect itself needs CORS
|
|
||||||
# headers, otherwise browsers refuse to follow it cross-origin
|
|
||||||
response = app_client_with_cors.get(
|
|
||||||
"/fixtures?sql=select+1", follow_redirects=False
|
|
||||||
)
|
|
||||||
assert response.status == 302
|
|
||||||
assert response.headers["Location"] == "/fixtures/-/query?sql=select+1"
|
|
||||||
assert response.headers["Access-Control-Allow-Origin"] == "*"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"path",
|
"path",
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
from bs4 import BeautifulSoup as Soup
|
from bs4 import BeautifulSoup as Soup
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
import re
|
import re
|
||||||
from .fixtures import make_app_client
|
from .fixtures import make_app_client
|
||||||
|
|
||||||
|
|
||||||
def update_query(client, name, **kwargs):
|
|
||||||
async_to_sync(client.ds.invoke_startup)()
|
|
||||||
async_to_sync(client.ds.update_query)("data", name, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def stored_write_client(tmpdir):
|
def canned_write_client(tmpdir):
|
||||||
template_dir = tmpdir / "stored_write_templates"
|
template_dir = tmpdir / "canned_write_templates"
|
||||||
template_dir.mkdir()
|
template_dir.mkdir()
|
||||||
(template_dir / "query-data-update_name.html").write_text(
|
(template_dir / "query-data-update_name.html").write_text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -29,7 +23,7 @@ def stored_write_client(tmpdir):
|
||||||
"databases": {
|
"databases": {
|
||||||
"data": {
|
"data": {
|
||||||
"queries": {
|
"queries": {
|
||||||
"stored_read": {"sql": "select * from names"},
|
"canned_read": {"sql": "select * from names"},
|
||||||
"add_name": {
|
"add_name": {
|
||||||
"sql": "insert into names (name) values (:name)",
|
"sql": "insert into names (name) values (:name)",
|
||||||
"write": True,
|
"write": True,
|
||||||
|
|
@ -66,7 +60,7 @@ def stored_write_client(tmpdir):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def stored_write_immutable_client():
|
def canned_write_immutable_client():
|
||||||
with make_app_client(
|
with make_app_client(
|
||||||
is_immutable=True,
|
is_immutable=True,
|
||||||
config={
|
config={
|
||||||
|
|
@ -86,7 +80,7 @@ def stored_write_immutable_client():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stored_query_with_named_parameter(ds_client):
|
async def test_canned_query_with_named_parameter(ds_client):
|
||||||
response = await ds_client.get(
|
response = await ds_client.get(
|
||||||
"/fixtures/neighborhood_search.json?text=town&_shape=arrays"
|
"/fixtures/neighborhood_search.json?text=town&_shape=arrays"
|
||||||
)
|
)
|
||||||
|
|
@ -100,14 +94,14 @@ async def test_stored_query_with_named_parameter(ds_client):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_insert(stored_write_client):
|
def test_insert(canned_write_client):
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/add_name",
|
"/data/add_name",
|
||||||
{"name": "Hello"},
|
{"name": "Hello"},
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
cookies={"foo": "bar"},
|
cookies={"foo": "bar"},
|
||||||
)
|
)
|
||||||
messages = stored_write_client.ds.unsign(
|
messages = canned_write_client.ds.unsign(
|
||||||
response.cookies["ds_messages"], "messages"
|
response.cookies["ds_messages"], "messages"
|
||||||
)
|
)
|
||||||
assert messages == [["Query executed, 1 row affected", 1]]
|
assert messages == [["Query executed, 1 row affected", 1]]
|
||||||
|
|
@ -115,9 +109,9 @@ def test_insert(stored_write_client):
|
||||||
assert response.headers["Location"] == "/data/add_name?success"
|
assert response.headers["Location"] == "/data/add_name?success"
|
||||||
|
|
||||||
|
|
||||||
def test_insert_blocked_cross_site(stored_write_client):
|
def test_insert_blocked_cross_site(canned_write_client):
|
||||||
# A cross-site POST (browser-originated) must be blocked
|
# A cross-site POST (browser-originated) must be blocked
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/add_name",
|
"/data/add_name",
|
||||||
{"name": "Hello"},
|
{"name": "Hello"},
|
||||||
headers={"sec-fetch-site": "cross-site"},
|
headers={"sec-fetch-site": "cross-site"},
|
||||||
|
|
@ -125,72 +119,74 @@ def test_insert_blocked_cross_site(stored_write_client):
|
||||||
assert 403 == response.status
|
assert 403 == response.status
|
||||||
|
|
||||||
|
|
||||||
def test_insert_no_cookies_no_csrf(stored_write_client):
|
def test_insert_no_cookies_no_csrf(canned_write_client):
|
||||||
response = stored_write_client.post("/data/add_name", {"name": "Hello"})
|
response = canned_write_client.post("/data/add_name", {"name": "Hello"})
|
||||||
assert 302 == response.status
|
assert 302 == response.status
|
||||||
assert "/data/add_name?success" == response.headers["Location"]
|
assert "/data/add_name?success" == response.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
def test_custom_success_message(stored_write_client):
|
def test_custom_success_message(canned_write_client):
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/delete_name",
|
"/data/delete_name",
|
||||||
{"rowid": 1},
|
{"rowid": 1},
|
||||||
cookies={"ds_actor": stored_write_client.actor_cookie({"id": "root"})},
|
cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
)
|
)
|
||||||
assert 302 == response.status
|
assert 302 == response.status
|
||||||
messages = stored_write_client.ds.unsign(
|
messages = canned_write_client.ds.unsign(
|
||||||
response.cookies["ds_messages"], "messages"
|
response.cookies["ds_messages"], "messages"
|
||||||
)
|
)
|
||||||
assert [["Name deleted", 1]] == messages
|
assert [["Name deleted", 1]] == messages
|
||||||
|
|
||||||
|
|
||||||
def test_insert_error(stored_write_client):
|
def test_insert_error(canned_write_client):
|
||||||
stored_write_client.post("/data/add_name", {"name": "Hello"}, csrftoken_from=True)
|
canned_write_client.post("/data/add_name", {"name": "Hello"}, csrftoken_from=True)
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/add_name_specify_id",
|
"/data/add_name_specify_id",
|
||||||
{"rowid": 1, "name": "Should fail"},
|
{"rowid": 1, "name": "Should fail"},
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
)
|
)
|
||||||
assert 302 == response.status
|
assert 302 == response.status
|
||||||
assert "/data/add_name_specify_id?error" == response.headers["Location"]
|
assert "/data/add_name_specify_id?error" == response.headers["Location"]
|
||||||
messages = stored_write_client.ds.unsign(
|
messages = canned_write_client.ds.unsign(
|
||||||
response.cookies["ds_messages"], "messages"
|
response.cookies["ds_messages"], "messages"
|
||||||
)
|
)
|
||||||
assert [["UNIQUE constraint failed: names.rowid", 3]] == messages
|
assert [["UNIQUE constraint failed: names.rowid", 3]] == messages
|
||||||
# How about with a custom error message?
|
# How about with a custom error message?
|
||||||
update_query(stored_write_client, "add_name_specify_id", on_error_message="ERROR")
|
canned_write_client.ds.config["databases"]["data"]["queries"][
|
||||||
response = stored_write_client.post(
|
"add_name_specify_id"
|
||||||
|
]["on_error_message"] = "ERROR"
|
||||||
|
response = canned_write_client.post(
|
||||||
"/data/add_name_specify_id",
|
"/data/add_name_specify_id",
|
||||||
{"rowid": 1, "name": "Should fail"},
|
{"rowid": 1, "name": "Should fail"},
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
)
|
)
|
||||||
assert [["ERROR", 3]] == stored_write_client.ds.unsign(
|
assert [["ERROR", 3]] == canned_write_client.ds.unsign(
|
||||||
response.cookies["ds_messages"], "messages"
|
response.cookies["ds_messages"], "messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_on_success_message_sql(stored_write_client):
|
def test_on_success_message_sql(canned_write_client):
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/add_name_specify_id",
|
"/data/add_name_specify_id",
|
||||||
{"rowid": 5, "name": "Should be OK"},
|
{"rowid": 5, "name": "Should be OK"},
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
)
|
)
|
||||||
assert response.status == 302
|
assert response.status == 302
|
||||||
assert response.headers["Location"] == "/data/add_name_specify_id"
|
assert response.headers["Location"] == "/data/add_name_specify_id"
|
||||||
messages = stored_write_client.ds.unsign(
|
messages = canned_write_client.ds.unsign(
|
||||||
response.cookies["ds_messages"], "messages"
|
response.cookies["ds_messages"], "messages"
|
||||||
)
|
)
|
||||||
assert messages == [["Name added: Should be OK with rowid 5", 1]]
|
assert messages == [["Name added: Should be OK with rowid 5", 1]]
|
||||||
|
|
||||||
|
|
||||||
def test_error_in_on_success_message_sql(stored_write_client):
|
def test_error_in_on_success_message_sql(canned_write_client):
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/add_name_specify_id_with_error_in_on_success_message_sql",
|
"/data/add_name_specify_id_with_error_in_on_success_message_sql",
|
||||||
{"rowid": 1, "name": "Should fail"},
|
{"rowid": 1, "name": "Should fail"},
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
)
|
)
|
||||||
messages = stored_write_client.ds.unsign(
|
messages = canned_write_client.ds.unsign(
|
||||||
response.cookies["ds_messages"], "messages"
|
response.cookies["ds_messages"], "messages"
|
||||||
)
|
)
|
||||||
assert messages == [
|
assert messages == [
|
||||||
|
|
@ -198,29 +194,26 @@ def test_error_in_on_success_message_sql(stored_write_client):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_custom_params(stored_write_client):
|
def test_custom_params(canned_write_client):
|
||||||
response = stored_write_client.get("/data/update_name?extra=foo")
|
response = canned_write_client.get("/data/update_name?extra=foo")
|
||||||
assert (
|
assert '<input type="text" id="qp3" name="extra" value="foo">' in response.text
|
||||||
'<input type="text" id="qp3" name="extra" value="foo" data-parameter-control>'
|
|
||||||
in response.text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stored_query_pages_no_vary_header(stored_write_client):
|
def test_canned_query_pages_no_vary_header(canned_write_client):
|
||||||
# These pages no longer embed per-cookie CSRF tokens, so they must not
|
# These pages no longer embed per-cookie CSRF tokens, so they must not
|
||||||
# set Vary: Cookie - they should be cacheable across users.
|
# set Vary: Cookie - they should be cacheable across users.
|
||||||
assert "vary" not in stored_write_client.get("/data").headers
|
assert "vary" not in canned_write_client.get("/data").headers
|
||||||
assert "vary" not in stored_write_client.get("/data/update_name").headers
|
assert "vary" not in canned_write_client.get("/data/update_name").headers
|
||||||
|
|
||||||
|
|
||||||
def test_json_post_body(stored_write_client):
|
def test_json_post_body(canned_write_client):
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/add_name",
|
"/data/add_name",
|
||||||
body=json.dumps({"name": ["Hello", "there"]}),
|
body=json.dumps({"name": ["Hello", "there"]}),
|
||||||
)
|
)
|
||||||
assert 302 == response.status
|
assert 302 == response.status
|
||||||
assert "/data/add_name?success" == response.headers["Location"]
|
assert "/data/add_name?success" == response.headers["Location"]
|
||||||
rows = stored_write_client.get("/data/names.json?_shape=array").json
|
rows = canned_write_client.get("/data/names.json?_shape=array").json
|
||||||
assert rows == [{"rowid": 1, "name": "['Hello', 'there']"}]
|
assert rows == [{"rowid": 1, "name": "['Hello', 'there']"}]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -233,8 +226,8 @@ def test_json_post_body(stored_write_client):
|
||||||
(None, '{"name": "NameGoesHere", "_json": 1}', None),
|
(None, '{"name": "NameGoesHere", "_json": 1}', None),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_json_response(stored_write_client, headers, body, querystring):
|
def test_json_response(canned_write_client, headers, body, querystring):
|
||||||
response = stored_write_client.post(
|
response = canned_write_client.post(
|
||||||
"/data/add_name" + (querystring or ""),
|
"/data/add_name" + (querystring or ""),
|
||||||
body=body,
|
body=body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|
@ -246,27 +239,29 @@ def test_json_response(stored_write_client, headers, body, querystring):
|
||||||
"message": "Query executed, 1 row affected",
|
"message": "Query executed, 1 row affected",
|
||||||
"redirect": "/data/add_name?success",
|
"redirect": "/data/add_name?success",
|
||||||
}
|
}
|
||||||
rows = stored_write_client.get("/data/names.json?_shape=array").json
|
rows = canned_write_client.get("/data/names.json?_shape=array").json
|
||||||
assert rows == [{"rowid": 1, "name": "NameGoesHere"}]
|
assert rows == [{"rowid": 1, "name": "NameGoesHere"}]
|
||||||
|
|
||||||
|
|
||||||
def test_stored_query_permissions_on_database_page(stored_write_client):
|
def test_canned_query_permissions_on_database_page(canned_write_client):
|
||||||
# Without auth shows the five public queries
|
# Without auth only shows three queries
|
||||||
anon_response = stored_write_client.get("/data.json")
|
query_names = {
|
||||||
query_names = {q["name"] for q in anon_response.json["queries"]}
|
q["name"] for q in canned_write_client.get("/data.json").json["queries"]
|
||||||
|
}
|
||||||
assert query_names == {
|
assert query_names == {
|
||||||
"add_name_specify_id_with_error_in_on_success_message_sql",
|
"add_name_specify_id_with_error_in_on_success_message_sql",
|
||||||
|
"from_hook",
|
||||||
"update_name",
|
"update_name",
|
||||||
"add_name_specify_id",
|
"add_name_specify_id",
|
||||||
"stored_read",
|
"from_async_hook",
|
||||||
|
"canned_read",
|
||||||
"add_name",
|
"add_name",
|
||||||
}
|
}
|
||||||
assert anon_response.json["queries_more"] is False
|
|
||||||
|
|
||||||
# With auth the database page preview shows the first five queries
|
# With auth shows four
|
||||||
response = stored_write_client.get(
|
response = canned_write_client.get(
|
||||||
"/data.json",
|
"/data.json",
|
||||||
cookies={"ds_actor": stored_write_client.actor_cookie({"id": "root"})},
|
cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
|
||||||
)
|
)
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
query_names_and_private = sorted(
|
query_names_and_private = sorted(
|
||||||
|
|
@ -283,43 +278,20 @@ def test_stored_query_permissions_on_database_page(stored_write_client):
|
||||||
"name": "add_name_specify_id_with_error_in_on_success_message_sql",
|
"name": "add_name_specify_id_with_error_in_on_success_message_sql",
|
||||||
"private": False,
|
"private": False,
|
||||||
},
|
},
|
||||||
|
{"name": "canned_read", "private": False},
|
||||||
{"name": "delete_name", "private": True},
|
{"name": "delete_name", "private": True},
|
||||||
{"name": "stored_read", "private": False},
|
{"name": "from_async_hook", "private": False},
|
||||||
]
|
{"name": "from_hook", "private": False},
|
||||||
assert response.json["queries_more"] is True
|
|
||||||
|
|
||||||
# The full query list endpoint includes the remaining query
|
|
||||||
response = stored_write_client.get(
|
|
||||||
"/data/-/queries.json?_size=10",
|
|
||||||
cookies={"ds_actor": stored_write_client.actor_cookie({"id": "root"})},
|
|
||||||
)
|
|
||||||
assert response.status == 200
|
|
||||||
query_names_and_private = sorted(
|
|
||||||
[
|
|
||||||
{"name": q["name"], "private": q["private"]}
|
|
||||||
for q in response.json["queries"]
|
|
||||||
],
|
|
||||||
key=lambda q: q["name"],
|
|
||||||
)
|
|
||||||
assert query_names_and_private == [
|
|
||||||
{"name": "add_name", "private": False},
|
|
||||||
{"name": "add_name_specify_id", "private": False},
|
|
||||||
{
|
|
||||||
"name": "add_name_specify_id_with_error_in_on_success_message_sql",
|
|
||||||
"private": False,
|
|
||||||
},
|
|
||||||
{"name": "delete_name", "private": True},
|
|
||||||
{"name": "stored_read", "private": False},
|
|
||||||
{"name": "update_name", "private": False},
|
{"name": "update_name", "private": False},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_stored_query_permissions(stored_write_client):
|
def test_canned_query_permissions(canned_write_client):
|
||||||
assert 403 == stored_write_client.get("/data/delete_name").status
|
assert 403 == canned_write_client.get("/data/delete_name").status
|
||||||
assert 200 == stored_write_client.get("/data/update_name").status
|
assert 200 == canned_write_client.get("/data/update_name").status
|
||||||
cookies = {"ds_actor": stored_write_client.actor_cookie({"id": "root"})}
|
cookies = {"ds_actor": canned_write_client.actor_cookie({"id": "root"})}
|
||||||
assert 200 == stored_write_client.get("/data/delete_name", cookies=cookies).status
|
assert 200 == canned_write_client.get("/data/delete_name", cookies=cookies).status
|
||||||
assert 200 == stored_write_client.get("/data/update_name", cookies=cookies).status
|
assert 200 == canned_write_client.get("/data/update_name", cookies=cookies).status
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|
@ -355,16 +327,12 @@ def magic_parameters_client():
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_magic_parameters(magic_parameters_client, magic_parameter, expected_re):
|
def test_magic_parameters(magic_parameters_client, magic_parameter, expected_re):
|
||||||
update_query(
|
magic_parameters_client.ds.config["databases"]["data"]["queries"]["runme_post"][
|
||||||
magic_parameters_client,
|
"sql"
|
||||||
"runme_post",
|
] = f"insert into logs (line) values (:{magic_parameter})"
|
||||||
sql=f"insert into logs (line) values (:{magic_parameter})",
|
magic_parameters_client.ds.config["databases"]["data"]["queries"]["runme_get"][
|
||||||
)
|
"sql"
|
||||||
update_query(
|
] = f"select :{magic_parameter} as result"
|
||||||
magic_parameters_client,
|
|
||||||
"runme_get",
|
|
||||||
sql=f"select :{magic_parameter} as result",
|
|
||||||
)
|
|
||||||
cookies = {
|
cookies = {
|
||||||
"ds_actor": magic_parameters_client.actor_cookie({"id": "root"}),
|
"ds_actor": magic_parameters_client.actor_cookie({"id": "root"}),
|
||||||
"foo": "bar",
|
"foo": "bar",
|
||||||
|
|
@ -398,11 +366,9 @@ def test_magic_parameters(magic_parameters_client, magic_parameter, expected_re)
|
||||||
@pytest.mark.parametrize("use_csrf", [True, False])
|
@pytest.mark.parametrize("use_csrf", [True, False])
|
||||||
@pytest.mark.parametrize("return_json", [True, False])
|
@pytest.mark.parametrize("return_json", [True, False])
|
||||||
def test_magic_parameters_csrf_json(magic_parameters_client, use_csrf, return_json):
|
def test_magic_parameters_csrf_json(magic_parameters_client, use_csrf, return_json):
|
||||||
update_query(
|
magic_parameters_client.ds.config["databases"]["data"]["queries"]["runme_post"][
|
||||||
magic_parameters_client,
|
"sql"
|
||||||
"runme_post",
|
] = "insert into logs (line) values (:_header_host)"
|
||||||
sql="insert into logs (line) values (:_header_host)",
|
|
||||||
)
|
|
||||||
qs = ""
|
qs = ""
|
||||||
if return_json:
|
if return_json:
|
||||||
qs = "?_json=1"
|
qs = "?_json=1"
|
||||||
|
|
@ -434,8 +400,8 @@ def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_c
|
||||||
assert response.json["error"].startswith("You did not supply a value for binding")
|
assert response.json["error"].startswith("You did not supply a value for binding")
|
||||||
|
|
||||||
|
|
||||||
def test_stored_write_custom_template(stored_write_client):
|
def test_canned_write_custom_template(canned_write_client):
|
||||||
response = stored_write_client.get("/data/update_name")
|
response = canned_write_client.get("/data/update_name")
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text
|
assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -453,10 +419,10 @@ def test_stored_write_custom_template(stored_write_client):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_stored_write_query_disabled_for_immutable_database(
|
def test_canned_write_query_disabled_for_immutable_database(
|
||||||
stored_write_immutable_client,
|
canned_write_immutable_client,
|
||||||
):
|
):
|
||||||
response = stored_write_immutable_client.get("/fixtures/add")
|
response = canned_write_immutable_client.get("/fixtures/add")
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert (
|
assert (
|
||||||
"This query cannot be executed because the database is immutable."
|
"This query cannot be executed because the database is immutable."
|
||||||
|
|
@ -464,7 +430,7 @@ def test_stored_write_query_disabled_for_immutable_database(
|
||||||
)
|
)
|
||||||
assert '<input type="submit" value="Run SQL" disabled>' in response.text
|
assert '<input type="submit" value="Run SQL" disabled>' in response.text
|
||||||
# Submitting form should get a forbidden error
|
# Submitting form should get a forbidden error
|
||||||
response = stored_write_immutable_client.post(
|
response = canned_write_immutable_client.post(
|
||||||
"/fixtures/add",
|
"/fixtures/add",
|
||||||
{"text": "text"},
|
{"text": "text"},
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
|
|
@ -35,28 +35,12 @@ def test_inspect_cli(app_client):
|
||||||
assert expected_count == database["tables"][table_name]["count"]
|
assert expected_count == database["tables"][table_name]["count"]
|
||||||
|
|
||||||
|
|
||||||
def test_inspect_cli_counts_all_rows(tmp_path):
|
|
||||||
db_path = tmp_path / "big.db"
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
with conn:
|
|
||||||
conn.execute("create table t (id integer primary key)")
|
|
||||||
conn.executemany("insert into t (id) values (?)", ((i,) for i in range(10002)))
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["inspect", str(db_path)])
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
data = json.loads(result.output)
|
|
||||||
|
|
||||||
assert data["big"]["tables"]["t"]["count"] == 10002
|
|
||||||
|
|
||||||
|
|
||||||
def test_inspect_cli_writes_to_file(app_client):
|
def test_inspect_cli_writes_to_file(app_client):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
cli, ["inspect", "fixtures.db", "--inspect-file", "foo.json"]
|
cli, ["inspect", "fixtures.db", "--inspect-file", "foo.json"]
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert 0 == result.exit_code, result.output
|
||||||
with open("foo.json") as fp:
|
with open("foo.json") as fp:
|
||||||
data = json.load(fp)
|
data = json.load(fp)
|
||||||
assert ["fixtures"] == list(data.keys())
|
assert ["fixtures"] == list(data.keys())
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ Tests to ensure certain things are documented.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datasette import app, utils
|
from datasette import app, utils
|
||||||
import datasette.fixtures # noqa: F401
|
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.filters import Filters
|
from datasette.filters import Filters
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -66,23 +65,7 @@ def documented_views():
|
||||||
if first_word.endswith("View"):
|
if first_word.endswith("View"):
|
||||||
view_labels.add(first_word)
|
view_labels.add(first_word)
|
||||||
# We deliberately don't document these:
|
# We deliberately don't document these:
|
||||||
view_labels.update(
|
view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView"))
|
||||||
(
|
|
||||||
"PatternPortfolioView",
|
|
||||||
"AuthTokenView",
|
|
||||||
"ApiExplorerView",
|
|
||||||
"ExecuteWriteAnalyzeView",
|
|
||||||
"ExecuteWriteView",
|
|
||||||
"GlobalQueryListView",
|
|
||||||
"QueryCreateAnalyzeView",
|
|
||||||
"QueryDeleteView",
|
|
||||||
"QueryDefinitionView",
|
|
||||||
"QueryListView",
|
|
||||||
"QueryParametersView",
|
|
||||||
"QueryStoreView",
|
|
||||||
"QueryUpdateView",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return view_labels
|
return view_labels
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,17 +95,20 @@ def test_table_filters_are_documented(documented_table_filters, subtests):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def documented_labels():
|
def documented_fns():
|
||||||
labels = set()
|
internals_rst = (docs_path / "internals.rst").read_text()
|
||||||
for filename in docs_path.glob("*.rst"):
|
# Any line that starts .. _internals_utils_X
|
||||||
labels.update(get_labels(filename.name))
|
lines = internals_rst.split("\n")
|
||||||
return labels
|
prefix = ".. _internals_utils_"
|
||||||
|
return {
|
||||||
|
line.split(prefix)[1].split(":")[0] for line in lines if line.startswith(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_functions_marked_with_documented_are_documented(documented_labels, subtests):
|
def test_functions_marked_with_documented_are_documented(documented_fns, subtests):
|
||||||
for fn in utils.functions_marked_as_documented:
|
for fn in utils.functions_marked_as_documented:
|
||||||
with subtests.test(fn=fn.__name__):
|
with subtests.test(fn=fn.__name__):
|
||||||
assert fn._datasette_docs_label in documented_labels
|
assert fn.__name__ in documented_fns
|
||||||
|
|
||||||
|
|
||||||
def test_rst_heading_underlines_match_title_length():
|
def test_rst_heading_underlines_match_title_length():
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
from datasette.fixtures import (
|
|
||||||
populate_extra_database,
|
|
||||||
populate_fixture_database,
|
|
||||||
write_extra_database,
|
|
||||||
write_fixture_database,
|
|
||||||
)
|
|
||||||
from datasette.utils.sqlite import sqlite3
|
|
||||||
|
|
||||||
|
|
||||||
def count(conn, table):
|
|
||||||
return conn.execute(f"select count(*) from [{table}]").fetchone()[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_populate_fixture_database():
|
|
||||||
conn = sqlite3.connect(":memory:")
|
|
||||||
try:
|
|
||||||
populate_fixture_database(conn)
|
|
||||||
assert count(conn, "facetable") == 15
|
|
||||||
assert count(conn, "compound_three_primary_keys") == 1001
|
|
||||||
assert count(conn, "binary_data") == 3
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_fixture_database(tmp_path):
|
|
||||||
db_path = tmp_path / "fixtures.db"
|
|
||||||
write_fixture_database(db_path)
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
assert count(conn, "sortable") == 201
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_extra_database_helpers(tmp_path):
|
|
||||||
conn = sqlite3.connect(":memory:")
|
|
||||||
try:
|
|
||||||
populate_extra_database(conn)
|
|
||||||
assert count(conn, "searchable") == 2
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
db_path = tmp_path / "extra.db"
|
|
||||||
write_extra_database(db_path)
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
assert count(conn, "searchable") == 2
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
@ -154,10 +154,12 @@ async def test_database_page(ds_client):
|
||||||
("/fixtures/simple_view", "simple_view"),
|
("/fixtures/simple_view", "simple_view"),
|
||||||
] == sorted([(a["href"], a.text) for a in views_ul.find_all("a")])
|
] == sorted([(a["href"], a.text) for a in views_ul.find_all("a")])
|
||||||
|
|
||||||
# And a list of stored queries
|
# And a list of canned queries
|
||||||
queries_ul = soup.find("h2", string="Queries").find_next_sibling("ul")
|
queries_ul = soup.find("h2", string="Queries").find_next_sibling("ul")
|
||||||
assert queries_ul is not None
|
assert queries_ul is not None
|
||||||
assert [
|
assert [
|
||||||
|
("/fixtures/from_async_hook", "from_async_hook"),
|
||||||
|
("/fixtures/from_hook", "from_hook"),
|
||||||
("/fixtures/magic_parameters", "magic_parameters"),
|
("/fixtures/magic_parameters", "magic_parameters"),
|
||||||
("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"),
|
("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"),
|
||||||
("/fixtures/pragma_cache_size", "pragma_cache_size"),
|
("/fixtures/pragma_cache_size", "pragma_cache_size"),
|
||||||
|
|
@ -239,22 +241,6 @@ def test_query_page_truncates():
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_query_page_with_no_sql(ds_client):
|
|
||||||
# https://github.com/simonw/datasette/issues/2743
|
|
||||||
response = await ds_client.get("/fixtures/-/query")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert '<textarea id="sql-editor" name="sql"' in response.text
|
|
||||||
assert 'class="rows-and-columns"' not in response.text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_query_csv_with_no_sql_is_400(ds_client):
|
|
||||||
# https://github.com/simonw/datasette/issues/2743
|
|
||||||
response = await ds_client.get("/fixtures/-/query.csv")
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"path,expected_classes",
|
"path,expected_classes",
|
||||||
|
|
@ -342,29 +328,17 @@ async def test_query_parameter_form_fields(ds_client):
|
||||||
response = await ds_client.get("/fixtures/-/query?sql=select+:name")
|
response = await ds_client.get("/fixtures/-/query?sql=select+:name")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (
|
assert (
|
||||||
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="" data-parameter-control>'
|
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="">'
|
||||||
in response.text
|
in response.text
|
||||||
)
|
)
|
||||||
assert 'data-parameters-url="/fixtures/-/query/parameters"' in response.text
|
|
||||||
assert 'id="sql-parameters-section"' in response.text
|
|
||||||
assert "setupSqlParameterRefresh" in response.text
|
|
||||||
response2 = await ds_client.get("/fixtures/-/query?sql=select+:name&name=hello")
|
response2 = await ds_client.get("/fixtures/-/query?sql=select+:name&name=hello")
|
||||||
assert response2.status_code == 200
|
assert response2.status_code == 200
|
||||||
assert (
|
assert (
|
||||||
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello" data-parameter-control>'
|
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello">'
|
||||||
in response2.text
|
in response2.text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_database_page_sql_parameter_refresh_markup(ds_client):
|
|
||||||
response = await ds_client.get("/fixtures")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert 'data-parameters-url="/fixtures/-/query/parameters"' in response.text
|
|
||||||
assert 'id="sql-parameters-section"' in response.text
|
|
||||||
assert "setupSqlParameterRefresh" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_row_html_simple_primary_key(ds_client):
|
async def test_row_html_simple_primary_key(ds_client):
|
||||||
response = await ds_client.get("/fixtures/simple_primary_key/1")
|
response = await ds_client.get("/fixtures/simple_primary_key/1")
|
||||||
|
|
@ -633,7 +607,7 @@ async def test_404_content_type(ds_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stored_query_default_title(ds_client):
|
async def test_canned_query_default_title(ds_client):
|
||||||
response = await ds_client.get("/fixtures/magic_parameters")
|
response = await ds_client.get("/fixtures/magic_parameters")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
soup = Soup(response.content, "html.parser")
|
soup = Soup(response.content, "html.parser")
|
||||||
|
|
@ -641,7 +615,7 @@ async def test_stored_query_default_title(ds_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stored_query_with_custom_metadata(ds_client):
|
async def test_canned_query_with_custom_metadata(ds_client):
|
||||||
response = await ds_client.get("/fixtures/neighborhood_search?text=town")
|
response = await ds_client.get("/fixtures/neighborhood_search?text=town")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
soup = Soup(response.content, "html.parser")
|
soup = Soup(response.content, "html.parser")
|
||||||
|
|
@ -700,8 +674,8 @@ async def test_show_hide_sql_query(ds_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stored_query_with_hide_has_no_hidden_sql(ds_client):
|
async def test_canned_query_with_hide_has_no_hidden_sql(ds_client):
|
||||||
# For a stored query the show/hide should NOT have a hidden SQL field
|
# For a canned query the show/hide should NOT have a hidden SQL field
|
||||||
# https://github.com/simonw/datasette/issues/1411
|
# https://github.com/simonw/datasette/issues/1411
|
||||||
response = await ds_client.get("/fixtures/pragma_cache_size?_hide_sql=1")
|
response = await ds_client.get("/fixtures/pragma_cache_size?_hide_sql=1")
|
||||||
soup = Soup(response.content, "html.parser")
|
soup = Soup(response.content, "html.parser")
|
||||||
|
|
@ -720,7 +694,7 @@ async def test_stored_query_with_hide_has_no_hidden_sql(ds_client):
|
||||||
(True, "?_show_sql=1", "_show_sql", "/_memory/one", "hide"),
|
(True, "?_show_sql=1", "_show_sql", "/_memory/one", "hide"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_stored_query_show_hide_metadata_option(
|
def test_canned_query_show_hide_metadata_option(
|
||||||
hide_sql,
|
hide_sql,
|
||||||
querystring,
|
querystring,
|
||||||
expected_hidden,
|
expected_hidden,
|
||||||
|
|
@ -831,7 +805,7 @@ async def test_blob_download_invalid_messages(ds_client, path, expected_message)
|
||||||
async def test_zero_results(ds_client, path):
|
async def test_zero_results(ds_client, path):
|
||||||
response = await ds_client.get(path)
|
response = await ds_client.get(path)
|
||||||
soup = Soup(response.text, "html.parser")
|
soup = Soup(response.text, "html.parser")
|
||||||
assert 0 == len(soup.select("table tbody tr"))
|
assert 0 == len(soup.select("table"))
|
||||||
assert 1 == len(soup.select("p.zero-results"))
|
assert 1 == len(soup.select("p.zero-results"))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -981,10 +955,10 @@ def test_base_url_affects_metadata_extra_css_urls(app_client_base_url_prefix):
|
||||||
("/fixtures/magic_parameters", None),
|
("/fixtures/magic_parameters", None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_edit_sql_link_on_stored_queries(ds_client, path, expected):
|
async def test_edit_sql_link_on_canned_queries(ds_client, path, expected):
|
||||||
response = await ds_client.get(path)
|
response = await ds_client.get(path)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
expected_link = f'<a href="{expected}" class="stored-query-edit-sql">Edit SQL</a>'
|
expected_link = f'<a href="{expected}" class="canned-query-edit-sql">Edit SQL</a>'
|
||||||
if expected:
|
if expected:
|
||||||
assert expected_link in response.text
|
assert expected_link in response.text
|
||||||
else:
|
else:
|
||||||
|
|
@ -1020,7 +994,7 @@ def test_edit_sql_link_not_shown_if_user_lacks_permission(has_permission):
|
||||||
[
|
[
|
||||||
(None, None, None),
|
(None, None, None),
|
||||||
("test", None, ["/-/permissions"]),
|
("test", None, ["/-/permissions"]),
|
||||||
("root", None, ["/-/permissions", "/-/allow-debug"]),
|
("root", ["/-/permissions", "/-/allow-debug"], None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_navigation_menu_links(
|
async def test_navigation_menu_links(
|
||||||
|
|
@ -1034,26 +1008,10 @@ async def test_navigation_menu_links(
|
||||||
kwargs["actor"] = {"id": actor_id}
|
kwargs["actor"] = {"id": actor_id}
|
||||||
html = (await ds_client.get("/", **kwargs)).text
|
html = (await ds_client.get("/", **kwargs)).text
|
||||||
soup = Soup(html, "html.parser")
|
soup = Soup(html, "html.parser")
|
||||||
details = soup.find("nav").find("details", {"class": "nav-menu"})
|
details = soup.find("nav").find("details")
|
||||||
assert details is not None
|
|
||||||
search_button = details.find("button", {"data-navigation-search-open": True})
|
|
||||||
assert search_button is not None
|
|
||||||
assert search_button.text.strip() == "Jump to... /"
|
|
||||||
assert search_button.find("kbd", {"class": "keyboard-shortcut"}).text == "/"
|
|
||||||
assert search_button.find("kbd")["aria-hidden"] == "true"
|
|
||||||
assert (
|
|
||||||
search_button.find("kbd")["title"]
|
|
||||||
== "Keyboard shortcut: press / to open Jump to"
|
|
||||||
)
|
|
||||||
navigation_search_script = soup.find(
|
|
||||||
"script", {"src": re.compile(r"navigation-search\.js")}
|
|
||||||
)
|
|
||||||
assert navigation_search_script["src"] == "/-/static/navigation-search.js"
|
|
||||||
assert details.find("li").find("button") == search_button
|
|
||||||
if not actor_id:
|
if not actor_id:
|
||||||
# The app menu is always visible, but anonymous users do not see logout
|
# Should not show a menu
|
||||||
# or debug links.
|
assert details is None
|
||||||
assert details.find("form") is None
|
|
||||||
return
|
return
|
||||||
# They are logged in: should show a menu
|
# They are logged in: should show a menu
|
||||||
assert details is not None
|
assert details is not None
|
||||||
|
|
@ -1106,7 +1064,7 @@ async def test_trace_correctly_escaped(ds_client):
|
||||||
"/fixtures/-/query?sql=select+*+from+facetable",
|
"/fixtures/-/query?sql=select+*+from+facetable",
|
||||||
"http://localhost/fixtures/-/query.json?sql=select+*+from+facetable",
|
"http://localhost/fixtures/-/query.json?sql=select+*+from+facetable",
|
||||||
),
|
),
|
||||||
# Stored query page
|
# Canned query page
|
||||||
(
|
(
|
||||||
"/fixtures/neighborhood_search?text=town",
|
"/fixtures/neighborhood_search?text=town",
|
||||||
"http://localhost/fixtures/neighborhood_search.json?text=town",
|
"http://localhost/fixtures/neighborhood_search.json?text=town",
|
||||||
|
|
|
||||||
|
|
@ -139,96 +139,3 @@ async def test_stale_catalog_entry_database_fix(tmp_path):
|
||||||
f"Index page should return 200, not {response.status_code}. "
|
f"Index page should return 200, not {response.status_code}. "
|
||||||
"This fails due to stale catalog entries causing KeyError."
|
"This fails due to stale catalog entries causing KeyError."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stale_catalog_child_entries_removed_for_missing_database(tmp_path):
|
|
||||||
from datasette.app import Datasette
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
internal_db_path = str(tmp_path / "internal.db")
|
|
||||||
alpha_db_path = str(tmp_path / "alpha.db")
|
|
||||||
bravo_db_path = str(tmp_path / "bravo.db")
|
|
||||||
|
|
||||||
for db_path, table_name in (
|
|
||||||
(alpha_db_path, "alpha_table"),
|
|
||||||
(bravo_db_path, "bravo_table"),
|
|
||||||
(bravo_db_path, "bravo_table_2"),
|
|
||||||
):
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.execute(f"CREATE TABLE {table_name} (id INTEGER PRIMARY KEY)")
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
ds1 = Datasette(files=[alpha_db_path, bravo_db_path], internal=internal_db_path)
|
|
||||||
await ds1.invoke_startup()
|
|
||||||
|
|
||||||
catalog_tables = await ds1.get_internal_database().execute("""
|
|
||||||
SELECT database_name, table_name
|
|
||||||
FROM catalog_tables
|
|
||||||
ORDER BY database_name, table_name
|
|
||||||
""")
|
|
||||||
assert [tuple(row) for row in catalog_tables.rows] == [
|
|
||||||
("alpha", "alpha_table"),
|
|
||||||
("bravo", "bravo_table"),
|
|
||||||
("bravo", "bravo_table_2"),
|
|
||||||
]
|
|
||||||
|
|
||||||
ds1.close()
|
|
||||||
|
|
||||||
ds2 = Datasette(files=[alpha_db_path], internal=internal_db_path)
|
|
||||||
await ds2.invoke_startup()
|
|
||||||
|
|
||||||
catalog_tables = await ds2.get_internal_database().execute("""
|
|
||||||
SELECT database_name, table_name
|
|
||||||
FROM catalog_tables
|
|
||||||
ORDER BY database_name, table_name
|
|
||||||
""")
|
|
||||||
assert [tuple(row) for row in catalog_tables.rows] == [("alpha", "alpha_table")]
|
|
||||||
|
|
||||||
ds2.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_orphan_stale_catalog_child_entries_removed(tmp_path):
|
|
||||||
from datasette.app import Datasette
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
internal_db_path = str(tmp_path / "internal.db")
|
|
||||||
alpha_db_path = str(tmp_path / "alpha.db")
|
|
||||||
|
|
||||||
conn = sqlite3.connect(alpha_db_path)
|
|
||||||
conn.execute("CREATE TABLE alpha_table (id INTEGER PRIMARY KEY)")
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
ds1 = Datasette(files=[alpha_db_path], internal=internal_db_path)
|
|
||||||
await ds1.invoke_startup()
|
|
||||||
ds1.close()
|
|
||||||
|
|
||||||
# Simulate the state left behind by old cleanup code: the parent database
|
|
||||||
# row was deleted, but child catalog rows survived because foreign key
|
|
||||||
# enforcement is not enabled for these internal catalog writes.
|
|
||||||
conn = sqlite3.connect(internal_db_path)
|
|
||||||
conn.execute("DELETE FROM catalog_databases WHERE database_name = 'fixtures'")
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
|
||||||
VALUES ('fixtures', 'stale_table', 1, 'CREATE TABLE stale_table (id INTEGER)')
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
ds2 = Datasette(files=[alpha_db_path], internal=internal_db_path)
|
|
||||||
await ds2.invoke_startup()
|
|
||||||
|
|
||||||
catalog_tables = await ds2.get_internal_database().execute("""
|
|
||||||
SELECT database_name, table_name
|
|
||||||
FROM catalog_tables
|
|
||||||
ORDER BY database_name, table_name
|
|
||||||
""")
|
|
||||||
assert [tuple(row) for row in catalog_tables.rows] == [("alpha", "alpha_table")]
|
|
||||||
|
|
||||||
response = await ds2.client.get("/-/jump.json")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
ds2.close()
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,10 @@
|
||||||
Tests for the datasette.database.Database class
|
Tests for the datasette.database.Database class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.database import Database, Results, MultipleValues
|
from datasette.database import Database, Results, MultipleValues
|
||||||
from datasette.database import DatasetteClosedError
|
from datasette.database import DatasetteClosedError
|
||||||
from datasette.database import _deliver_write_result
|
from datasette.utils.sqlite import sqlite3, sqlite_version
|
||||||
from datasette.utils.sqlite import sqlite3
|
|
||||||
from datasette.utils import Column
|
from datasette.utils import Column
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
|
|
@ -593,37 +590,6 @@ async def test_execute_write_fn_connection_exception(tmpdir, app_client):
|
||||||
app_client.ds.remove_database("immutable-db")
|
app_client.ds.remove_database("immutable-db")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deliver_write_result_leaves_done_future_alone():
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
reply_future = loop.create_future()
|
|
||||||
reply_future.set_result("original")
|
|
||||||
task = SimpleNamespace(loop=loop, reply_future=reply_future)
|
|
||||||
|
|
||||||
# The write thread can finish after the caller has stopped waiting for the
|
|
||||||
# result. Delivery should notice that the future is already resolved and
|
|
||||||
# leave the caller's outcome alone instead of raising InvalidStateError.
|
|
||||||
_deliver_write_result(task, "replacement", None)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
assert reply_future.result() == "original"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deliver_write_result_ignores_closed_loop():
|
|
||||||
closed_loop = asyncio.new_event_loop()
|
|
||||||
closed_loop.close()
|
|
||||||
reply_future = asyncio.get_running_loop().create_future()
|
|
||||||
task = SimpleNamespace(loop=closed_loop, reply_future=reply_future)
|
|
||||||
|
|
||||||
# If the event loop that submitted the write has gone away, the write
|
|
||||||
# thread should drop the result rather than crash while reporting back to
|
|
||||||
# that closed loop.
|
|
||||||
_deliver_write_result(task, "result", None)
|
|
||||||
|
|
||||||
assert not reply_future.done()
|
|
||||||
|
|
||||||
|
|
||||||
def table_exists(conn, name):
|
def table_exists(conn, name):
|
||||||
return bool(
|
return bool(
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -688,60 +654,6 @@ async def test_execute_isolated(db, disable_threads):
|
||||||
assert not await db.execute_isolated_fn(table_exists_checker("created_by_isolated"))
|
assert not await db.execute_isolated_fn(table_exists_checker("created_by_isolated"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_sql():
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
db = ds.add_memory_database("test_analyze_sql", name="data")
|
|
||||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
|
||||||
|
|
||||||
analysis = await db.analyze_sql("select name from dogs where id = ?", (1,))
|
|
||||||
|
|
||||||
assert [
|
|
||||||
(
|
|
||||||
operation.operation,
|
|
||||||
operation.database,
|
|
||||||
operation.sqlite_schema,
|
|
||||||
operation.table,
|
|
||||||
operation.columns,
|
|
||||||
operation.source,
|
|
||||||
)
|
|
||||||
for operation in analysis.operations
|
|
||||||
if operation.target_type == "table"
|
|
||||||
and operation.operation in {"read", "insert", "update", "delete"}
|
|
||||||
and not operation.internal
|
|
||||||
] == [
|
|
||||||
("read", "data", "main", "dogs", ("id", "name"), None),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_sql_insert_select():
|
|
||||||
ds = Datasette(memory=True)
|
|
||||||
db = ds.add_memory_database("test_analyze_sql_insert_select", name="data")
|
|
||||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
|
||||||
await db.execute_write("create table cats (id integer primary key, name text)")
|
|
||||||
|
|
||||||
analysis = await db.analyze_sql("insert into dogs (name) select name from cats")
|
|
||||||
|
|
||||||
assert {
|
|
||||||
(
|
|
||||||
operation.operation,
|
|
||||||
operation.database,
|
|
||||||
operation.sqlite_schema,
|
|
||||||
operation.table,
|
|
||||||
operation.columns,
|
|
||||||
operation.source,
|
|
||||||
)
|
|
||||||
for operation in analysis.operations
|
|
||||||
if operation.target_type == "table"
|
|
||||||
and operation.operation in {"read", "insert", "update", "delete"}
|
|
||||||
and not operation.internal
|
|
||||||
} == {
|
|
||||||
("insert", "data", "main", "dogs", (), None),
|
|
||||||
("read", "data", "main", "cats", ("name",), None),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mtime_ns(db):
|
async def test_mtime_ns(db):
|
||||||
assert isinstance(db.mtime_ns, int)
|
assert isinstance(db.mtime_ns, int)
|
||||||
|
|
@ -798,7 +710,14 @@ async def test_in_memory_databases_forbid_writes(app_client):
|
||||||
assert await db.table_names() == ["foo"]
|
assert await db.table_names() == ["foo"]
|
||||||
|
|
||||||
|
|
||||||
|
def pragma_table_list_supported():
|
||||||
|
return sqlite_version()[1] >= 37
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not pragma_table_list_supported(), reason="Requires PRAGMA table_list support"
|
||||||
|
)
|
||||||
async def test_hidden_tables(app_client):
|
async def test_hidden_tables(app_client):
|
||||||
ds = app_client.ds
|
ds = app_client.ds
|
||||||
db = ds.add_database(Database(ds, is_memory=True, is_mutable=True))
|
db = ds.add_database(Database(ds, is_memory=True, is_mutable=True))
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,8 @@
|
||||||
Tests for the datasette.app.Datasette class
|
Tests for the datasette.app.Datasette class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from datasette import Context
|
from datasette import Context
|
||||||
from datasette.app import Datasette, Database, ResourcesSQL
|
from datasette.app import Datasette, Database, ResourcesSQL
|
||||||
from datasette.database import DatasetteClosedError
|
from datasette.database import DatasetteClosedError
|
||||||
|
|
@ -259,52 +256,6 @@ async def test_datasette_close_raises_on_use():
|
||||||
await ds.get_internal_database().execute("select 1")
|
await ds.get_internal_database().execute("select 1")
|
||||||
|
|
||||||
|
|
||||||
async def _datasette_with_sleeping_execute(tmp_path, sleep_ms=200):
|
|
||||||
db_path = tmp_path / "data.db"
|
|
||||||
internal_path = tmp_path / "internal.db"
|
|
||||||
sqlite3.connect(db_path).close()
|
|
||||||
ds = Datasette([str(db_path)], internal=str(internal_path))
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
sql_started = asyncio.Event()
|
|
||||||
original_prepare_connection = ds._prepare_connection
|
|
||||||
|
|
||||||
def prepare_connection(conn, name):
|
|
||||||
original_prepare_connection(conn, name)
|
|
||||||
|
|
||||||
def sleep_ms(ms):
|
|
||||||
loop.call_soon_threadsafe(sql_started.set)
|
|
||||||
time.sleep(ms / 1000)
|
|
||||||
return ms
|
|
||||||
|
|
||||||
conn.create_function("sleep_ms", 1, sleep_ms)
|
|
||||||
|
|
||||||
ds._prepare_connection = prepare_connection
|
|
||||||
task = asyncio.create_task(
|
|
||||||
ds.get_database().execute(
|
|
||||||
f"select sleep_ms({sleep_ms})", custom_time_limit=1000
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.wait_for(sql_started.wait(), timeout=5)
|
|
||||||
return ds, task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_datasette_close_waits_for_in_flight_execute(tmp_path):
|
|
||||||
ds, task = await _datasette_with_sleeping_execute(tmp_path)
|
|
||||||
ds.close()
|
|
||||||
results = await task
|
|
||||||
assert [tuple(row) for row in results.rows] == [(200,)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_datasette_close_waits_for_cancelled_in_flight_execute(tmp_path):
|
|
||||||
ds, task = await _datasette_with_sleeping_execute(tmp_path)
|
|
||||||
task.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
ds.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_asgi_lifespan_shutdown_closes_datasette():
|
async def test_asgi_lifespan_shutdown_closes_datasette():
|
||||||
ds = Datasette(memory=True)
|
ds = Datasette(memory=True)
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ async def test_skip_permission_checks_with_admin_actor(datasette_with_permission
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_skip_permission_checks_shows_denied_tables():
|
async def test_skip_permission_checks_shows_denied_tables():
|
||||||
"""Test that skip_permission_checks=True shows tables from denied databases in /-/jump.json"""
|
"""Test that skip_permission_checks=True shows tables from denied databases in /-/tables.json"""
|
||||||
ds = Datasette(
|
ds = Datasette(
|
||||||
config={
|
config={
|
||||||
"databases": {
|
"databases": {
|
||||||
|
|
@ -211,8 +211,8 @@ async def test_skip_permission_checks_shows_denied_tables():
|
||||||
await db.execute_write("INSERT INTO test_table (id, name) VALUES (1, 'Alice')")
|
await db.execute_write("INSERT INTO test_table (id, name) VALUES (1, 'Alice')")
|
||||||
await ds._refresh_schemas()
|
await ds._refresh_schemas()
|
||||||
|
|
||||||
# Without skip_permission_checks, tables from denied database should not appear in /-/jump.json
|
# Without skip_permission_checks, tables from denied database should not appear in /-/tables.json
|
||||||
response = await ds.client.get("/-/jump.json")
|
response = await ds.client.get("/-/tables.json")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
table_names = [match["name"] for match in data["matches"]]
|
table_names = [match["name"] for match in data["matches"]]
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_skip_permission_checks_shows_denied_tables():
|
||||||
assert len(fixtures_tables) == 0
|
assert len(fixtures_tables) == 0
|
||||||
|
|
||||||
# With skip_permission_checks=True, tables from denied database SHOULD appear
|
# With skip_permission_checks=True, tables from denied database SHOULD appear
|
||||||
response = await ds.client.get("/-/jump.json", skip_permission_checks=True)
|
response = await ds.client.get("/-/tables.json", skip_permission_checks=True)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
table_names = [match["name"] for match in data["matches"]]
|
table_names = [match["name"] for match in data["matches"]]
|
||||||
|
|
|
||||||
|
|
@ -1,465 +0,0 @@
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.app import Datasette
|
|
||||||
from datasette.jump import JumpSQL
|
|
||||||
from datasette.plugins import pm
|
|
||||||
from datasette.views.special import JumpView
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def ds_for_jump():
|
|
||||||
ds = Datasette(
|
|
||||||
config={
|
|
||||||
"databases": {
|
|
||||||
"content": {
|
|
||||||
"allow": {"id": "*"},
|
|
||||||
"tables": {
|
|
||||||
"articles": {"allow": {"id": "editor"}},
|
|
||||||
"comments": {"allow": True},
|
|
||||||
},
|
|
||||||
"queries": {
|
|
||||||
"recent_comments": {
|
|
||||||
"sql": "select * from comments",
|
|
||||||
"allow": {"id": "*"},
|
|
||||||
"title": "Recent comments",
|
|
||||||
},
|
|
||||||
"release_notes": {
|
|
||||||
"sql": "select 1",
|
|
||||||
"allow": {"id": "*"},
|
|
||||||
"title": "Recent Datasette releases",
|
|
||||||
},
|
|
||||||
"editor_report": {
|
|
||||||
"sql": "select * from articles",
|
|
||||||
"allow": {"id": "editor"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"private": {
|
|
||||||
"allow": False,
|
|
||||||
"queries": {
|
|
||||||
"private_report": "select 1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
await ds.invoke_startup()
|
|
||||||
|
|
||||||
content_db = ds.add_memory_database("jump_test_content", name="content")
|
|
||||||
await content_db.execute_write(
|
|
||||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)"
|
|
||||||
)
|
|
||||||
await content_db.execute_write(
|
|
||||||
"CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, body TEXT)"
|
|
||||||
)
|
|
||||||
await content_db.execute_write(
|
|
||||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
||||||
)
|
|
||||||
await content_db.execute_write(
|
|
||||||
"CREATE VIEW IF NOT EXISTS comment_summary AS SELECT body FROM comments"
|
|
||||||
)
|
|
||||||
|
|
||||||
private_db = ds.add_memory_database("jump_test_private", name="private")
|
|
||||||
await private_db.execute_write(
|
|
||||||
"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)"
|
|
||||||
)
|
|
||||||
|
|
||||||
public_db = ds.add_memory_database("jump_test_public", name="public")
|
|
||||||
await public_db.execute_write(
|
|
||||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)"
|
|
||||||
)
|
|
||||||
|
|
||||||
await ds._refresh_schemas()
|
|
||||||
return ds
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_searches_tables_databases_views_and_stored_queries(ds_for_jump):
|
|
||||||
response = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=content", actor={"id": "user"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
matches_by_type_and_name = {
|
|
||||||
(match["type"], match["name"]): match for match in data["matches"]
|
|
||||||
}
|
|
||||||
assert ("database", "content") in matches_by_type_and_name
|
|
||||||
assert ("table", "content: comments") in matches_by_type_and_name
|
|
||||||
assert ("view", "content: comment_summary") in matches_by_type_and_name
|
|
||||||
assert ("query", "content: recent_comments") in matches_by_type_and_name
|
|
||||||
assert matches_by_type_and_name[("database", "content")]["url"] == "/content"
|
|
||||||
assert (
|
|
||||||
matches_by_type_and_name[("query", "content: recent_comments")]["url"]
|
|
||||||
== "/content/recent_comments"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_uses_stored_query_names_not_titles(ds_for_jump):
|
|
||||||
response = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=datasette", actor={"id": "user"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["matches"] == []
|
|
||||||
|
|
||||||
response = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=release", actor={"id": "user"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["matches"] == [
|
|
||||||
{
|
|
||||||
"name": "content: release_notes",
|
|
||||||
"url": "/content/release_notes",
|
|
||||||
"type": "query",
|
|
||||||
"description": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_respects_resource_permissions(ds_for_jump):
|
|
||||||
regular = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=articles", actor={"id": "regular"}
|
|
||||||
)
|
|
||||||
editor = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=articles", actor={"id": "editor"}
|
|
||||||
)
|
|
||||||
private = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=secrets", actor={"id": "editor"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert {match["name"] for match in regular.json()["matches"]} == {
|
|
||||||
"public: articles"
|
|
||||||
}
|
|
||||||
assert {match["name"] for match in editor.json()["matches"]} == {
|
|
||||||
"content: articles",
|
|
||||||
"public: articles",
|
|
||||||
}
|
|
||||||
assert private.json()["matches"] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_sql_menu_item_helper(ds_for_jump):
|
|
||||||
assert JumpSQL("SELECT 1").database is None
|
|
||||||
assert JumpSQL("SELECT 1", database="content").database == "content"
|
|
||||||
assert JumpSQL("SELECT 1", None, "content").database == "content"
|
|
||||||
|
|
||||||
fragment = JumpSQL.menu_item(
|
|
||||||
label="Plugin dashboard",
|
|
||||||
url="/-/plugin-dashboard",
|
|
||||||
description="Plugin tool",
|
|
||||||
search_text="dashboard plugin",
|
|
||||||
display_name="Plugin Dashboard",
|
|
||||||
item_type="plugin",
|
|
||||||
)
|
|
||||||
result = await ds_for_jump.get_internal_database().execute(
|
|
||||||
fragment.sql, fragment.params
|
|
||||||
)
|
|
||||||
assert dict(result.first()) == {
|
|
||||||
"type": "plugin",
|
|
||||||
"label": "Plugin dashboard",
|
|
||||||
"description": "Plugin tool",
|
|
||||||
"url": "/-/plugin-dashboard",
|
|
||||||
"search_text": "dashboard plugin",
|
|
||||||
"display_name": "Plugin Dashboard",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_debug_menu_items_are_in_jump_for_debug_menu_permission():
|
|
||||||
ds = Datasette(
|
|
||||||
config={
|
|
||||||
"permissions": {
|
|
||||||
"debug-menu": {"id": "debugger"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
await ds.invoke_startup()
|
|
||||||
response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "debugger"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
debug_matches = [
|
|
||||||
match for match in response.json()["matches"] if match["type"] == "debug"
|
|
||||||
]
|
|
||||||
assert {match["name"]: match["url"] for match in debug_matches} == {
|
|
||||||
"Databases": "/-/databases",
|
|
||||||
"Installed plugins": "/-/plugins",
|
|
||||||
"Version info": "/-/versions",
|
|
||||||
"Settings": "/-/settings",
|
|
||||||
"Debug permissions": "/-/permissions",
|
|
||||||
"Debug messages": "/-/messages",
|
|
||||||
"Debug allow rules": "/-/allow-debug",
|
|
||||||
"Debug threads": "/-/threads",
|
|
||||||
"Debug actor": "/-/actor",
|
|
||||||
"Pattern portfolio": "/-/patterns",
|
|
||||||
}
|
|
||||||
descriptions_by_name = {
|
|
||||||
match["name"]: match["description"] for match in debug_matches
|
|
||||||
}
|
|
||||||
assert all(descriptions_by_name.values())
|
|
||||||
assert descriptions_by_name["Databases"] == (
|
|
||||||
"List of databases known to this Datasette instance."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_debug_menu_items_are_hidden_without_debug_menu_permission():
|
|
||||||
ds = Datasette()
|
|
||||||
await ds.invoke_startup()
|
|
||||||
response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "regular"})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert [
|
|
||||||
match for match in response.json()["matches"] if match["type"] == "debug"
|
|
||||||
] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
|
|
||||||
class JumpPlugin:
|
|
||||||
@hookimpl
|
|
||||||
def jump_items_sql(self, datasette, actor, request):
|
|
||||||
return JumpSQL(
|
|
||||||
sql="""
|
|
||||||
SELECT
|
|
||||||
'plugin' AS type,
|
|
||||||
'plugin-dashboard: ' || :actor_id AS label,
|
|
||||||
'Plugin supplied item' AS description,
|
|
||||||
'/-/plugin-dashboard' AS url,
|
|
||||||
'plugin dashboard ' || :actor_id AS search_text,
|
|
||||||
'Plugin dashboard for ' || :actor_id AS display_name
|
|
||||||
""",
|
|
||||||
params={"actor_id": actor["id"] if actor else "anonymous"},
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin = JumpPlugin()
|
|
||||||
pm.register(plugin, name="test-jump-plugin")
|
|
||||||
try:
|
|
||||||
response = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=dashboard", actor={"id": "alice"}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="test-jump-plugin")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
plugin_matches = [
|
|
||||||
match for match in response.json()["matches"] if match["type"] == "plugin"
|
|
||||||
]
|
|
||||||
assert plugin_matches == [
|
|
||||||
{
|
|
||||||
"name": "plugin-dashboard: alice",
|
|
||||||
"display_name": "Plugin dashboard for alice",
|
|
||||||
"url": "/-/plugin-dashboard",
|
|
||||||
"type": "plugin",
|
|
||||||
"description": "Plugin supplied item",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_sql_unions_fragments_by_database(ds_for_jump, monkeypatch):
|
|
||||||
class JumpPlugin:
|
|
||||||
@hookimpl
|
|
||||||
def jump_items_sql(self, datasette, actor, request):
|
|
||||||
return [
|
|
||||||
JumpSQL(sql="""
|
|
||||||
SELECT
|
|
||||||
'plugin' AS type,
|
|
||||||
'first-unioned-item' AS label,
|
|
||||||
NULL AS description,
|
|
||||||
'/-/first-unioned-item' AS url,
|
|
||||||
'unioned item' AS search_text,
|
|
||||||
NULL AS display_name
|
|
||||||
"""),
|
|
||||||
JumpSQL(sql="""
|
|
||||||
SELECT
|
|
||||||
'plugin' AS type,
|
|
||||||
'second-unioned-item' AS label,
|
|
||||||
NULL AS description,
|
|
||||||
'/-/second-unioned-item' AS url,
|
|
||||||
'unioned item' AS search_text,
|
|
||||||
NULL AS display_name
|
|
||||||
"""),
|
|
||||||
JumpSQL(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
'plugin' AS type,
|
|
||||||
'content-first-unioned-item' AS label,
|
|
||||||
NULL AS description,
|
|
||||||
'/-/content-first-unioned-item' AS url,
|
|
||||||
'unioned item' AS search_text,
|
|
||||||
NULL AS display_name
|
|
||||||
""",
|
|
||||||
None,
|
|
||||||
"content",
|
|
||||||
),
|
|
||||||
JumpSQL(
|
|
||||||
database="content",
|
|
||||||
sql="""
|
|
||||||
SELECT
|
|
||||||
'plugin' AS type,
|
|
||||||
'content-second-unioned-item' AS label,
|
|
||||||
NULL AS description,
|
|
||||||
'/-/content-second-unioned-item' AS url,
|
|
||||||
'unioned item' AS search_text,
|
|
||||||
NULL AS display_name
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
internal_db = ds_for_jump.get_internal_database()
|
|
||||||
original_execute = internal_db.execute
|
|
||||||
internal_jump_query_sql = []
|
|
||||||
|
|
||||||
async def internal_execute_with_recording(sql, *args, **kwargs):
|
|
||||||
if "unioned-item" in sql:
|
|
||||||
internal_jump_query_sql.append(sql)
|
|
||||||
return await original_execute(sql, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(internal_db, "execute", internal_execute_with_recording)
|
|
||||||
|
|
||||||
content_db = ds_for_jump.get_database("content")
|
|
||||||
original_content_execute = content_db.execute
|
|
||||||
content_jump_query_sql = []
|
|
||||||
|
|
||||||
async def content_execute_with_recording(sql, *args, **kwargs):
|
|
||||||
if "unioned-item" in sql:
|
|
||||||
content_jump_query_sql.append(sql)
|
|
||||||
return await original_content_execute(sql, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(content_db, "execute", content_execute_with_recording)
|
|
||||||
|
|
||||||
plugin = JumpPlugin()
|
|
||||||
pm.register(plugin, name="test-jump-union-plugin")
|
|
||||||
try:
|
|
||||||
response = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=unioned", actor={"id": "alice"}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="test-jump-union-plugin")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert len(internal_jump_query_sql) == 1
|
|
||||||
assert " UNION ALL " in internal_jump_query_sql[0]
|
|
||||||
assert len(content_jump_query_sql) == 1
|
|
||||||
assert " UNION ALL " in content_jump_query_sql[0]
|
|
||||||
assert {match["name"] for match in response.json()["matches"]} == {
|
|
||||||
"content-first-unioned-item",
|
|
||||||
"content-second-unioned-item",
|
|
||||||
"first-unioned-item",
|
|
||||||
"second-unioned-item",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_sql_can_query_named_database(ds_for_jump):
|
|
||||||
content_db = ds_for_jump.get_database("content")
|
|
||||||
await content_db.execute_write(
|
|
||||||
"INSERT INTO comments (id, body) VALUES (1001, 'Named database jump target')"
|
|
||||||
)
|
|
||||||
|
|
||||||
class JumpPlugin:
|
|
||||||
@hookimpl
|
|
||||||
def jump_items_sql(self, datasette, actor, request):
|
|
||||||
return JumpSQL(
|
|
||||||
database="content",
|
|
||||||
sql="""
|
|
||||||
SELECT
|
|
||||||
'comment' AS type,
|
|
||||||
body AS label,
|
|
||||||
'Comment from content database' AS description,
|
|
||||||
json_object(
|
|
||||||
'method', 'table',
|
|
||||||
'database', 'content',
|
|
||||||
'table', 'comments'
|
|
||||||
) AS url,
|
|
||||||
body AS search_text,
|
|
||||||
body AS display_name
|
|
||||||
FROM comments
|
|
||||||
WHERE id = :comment_id
|
|
||||||
""",
|
|
||||||
params={"comment_id": 1001},
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin = JumpPlugin()
|
|
||||||
pm.register(plugin, name="test-jump-content-db-plugin")
|
|
||||||
try:
|
|
||||||
response = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=named+database", actor={"id": "alice"}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="test-jump-content-db-plugin")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
plugin_matches = [
|
|
||||||
match for match in response.json()["matches"] if match["type"] == "comment"
|
|
||||||
]
|
|
||||||
assert plugin_matches == [
|
|
||||||
{
|
|
||||||
"name": "Named database jump target",
|
|
||||||
"display_name": "Named database jump target",
|
|
||||||
"url": "/content/comments",
|
|
||||||
"type": "comment",
|
|
||||||
"description": "Comment from content database",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_resolves_url_descriptors_from_sql(ds_for_jump):
|
|
||||||
class JumpPlugin:
|
|
||||||
@hookimpl
|
|
||||||
def jump_items_sql(self, datasette, actor, request):
|
|
||||||
return JumpSQL(sql="""
|
|
||||||
SELECT
|
|
||||||
'plugin' AS type,
|
|
||||||
'Table descriptor' AS label,
|
|
||||||
NULL AS description,
|
|
||||||
json_object(
|
|
||||||
'method', 'table',
|
|
||||||
'database', 'content',
|
|
||||||
'table', 'comments'
|
|
||||||
) AS url,
|
|
||||||
'table descriptor comments' AS search_text,
|
|
||||||
NULL AS display_name
|
|
||||||
""")
|
|
||||||
|
|
||||||
plugin = JumpPlugin()
|
|
||||||
pm.register(plugin, name="test-jump-url-descriptor-plugin")
|
|
||||||
try:
|
|
||||||
response = await ds_for_jump.client.get(
|
|
||||||
"/-/jump.json?q=descriptor", actor={"id": "alice"}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
pm.unregister(name="test-jump-url-descriptor-plugin")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
plugin_matches = [
|
|
||||||
match for match in response.json()["matches"] if match["type"] == "plugin"
|
|
||||||
]
|
|
||||||
assert plugin_matches == [
|
|
||||||
{
|
|
||||||
"name": "Table descriptor",
|
|
||||||
"url": "/content/comments",
|
|
||||||
"type": "plugin",
|
|
||||||
"description": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_jump_url_descriptor_errors(ds_for_jump):
|
|
||||||
view = JumpView(ds_for_jump)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
view._resolve_url('{"method": "not_a_url_method"}')
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
view._resolve_url(
|
|
||||||
'{"method": "table", "database_name": "content", "table_name": "comments"}'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tables_endpoint_removed(ds_for_jump):
|
|
||||||
response = await ds_for_jump.client.get("/-/tables.json")
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
@ -1,394 +0,0 @@
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
STATIC_DIR = REPO_ROOT / "datasette" / "static"
|
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_search_tracks_and_renders_recent_items():
|
|
||||||
script = textwrap.dedent("""
|
|
||||||
const fs = require("fs");
|
|
||||||
const vm = require("vm");
|
|
||||||
const navigationSearchJs = __NAVIGATION_SEARCH_JS__;
|
|
||||||
|
|
||||||
class FakeElement {
|
|
||||||
constructor() {
|
|
||||||
this.innerHTML = "";
|
|
||||||
this.value = "";
|
|
||||||
this.dataset = {};
|
|
||||||
this.open = false;
|
|
||||||
}
|
|
||||||
addEventListener() {}
|
|
||||||
close() { this.open = false; }
|
|
||||||
focus() {}
|
|
||||||
querySelector() {
|
|
||||||
return { scrollIntoView() {} };
|
|
||||||
}
|
|
||||||
showModal() { this.open = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeShadowRoot {
|
|
||||||
constructor() {
|
|
||||||
this.innerHTML = "";
|
|
||||||
this.dialog = new FakeElement();
|
|
||||||
this.input = new FakeElement();
|
|
||||||
this.results = new FakeElement();
|
|
||||||
}
|
|
||||||
querySelector(selector) {
|
|
||||||
if (selector == "dialog") return this.dialog;
|
|
||||||
if (selector == ".search-input") return this.input;
|
|
||||||
if (selector == ".results-container") return this.results;
|
|
||||||
return new FakeElement();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.HTMLElement = class {
|
|
||||||
constructor() {
|
|
||||||
this.attributes = {};
|
|
||||||
}
|
|
||||||
attachShadow() {
|
|
||||||
this.shadowRoot = new FakeShadowRoot();
|
|
||||||
return this.shadowRoot;
|
|
||||||
}
|
|
||||||
dispatchEvent() {}
|
|
||||||
getAttribute(name) {
|
|
||||||
return this.attributes[name] || null;
|
|
||||||
}
|
|
||||||
querySelector() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
setAttribute(name, value) {
|
|
||||||
this.attributes[name] = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
global.CustomEvent = class {
|
|
||||||
constructor(name, options) {
|
|
||||||
this.name = name;
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
global.customElements = {
|
|
||||||
registry: new Map(),
|
|
||||||
define(name, cls) {
|
|
||||||
this.registry.set(name, cls);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
global.document = {
|
|
||||||
addEventListener() {},
|
|
||||||
activeElement: null,
|
|
||||||
createElement() {
|
|
||||||
return {
|
|
||||||
set textContent(value) {
|
|
||||||
this.innerHTML = String(value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
global.localStorage = {
|
|
||||||
store: {},
|
|
||||||
getItem(key) {
|
|
||||||
return Object.prototype.hasOwnProperty.call(this.store, key)
|
|
||||||
? this.store[key]
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
setItem(key, value) {
|
|
||||||
this.store[key] = String(value);
|
|
||||||
},
|
|
||||||
removeItem(key) {
|
|
||||||
delete this.store[key];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
global.window = { location: { href: "" } };
|
|
||||||
|
|
||||||
vm.runInThisContext(
|
|
||||||
fs.readFileSync(navigationSearchJs, "utf8"),
|
|
||||||
{ filename: "navigation-search.js" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const Component = customElements.registry.get("navigation-search");
|
|
||||||
const element = new Component();
|
|
||||||
const items = Array.from({ length: 6 }, (_, index) => ({
|
|
||||||
name: `Item ${index + 1}`,
|
|
||||||
url: `/item-${index + 1}`,
|
|
||||||
type: "table",
|
|
||||||
description: "Table",
|
|
||||||
}));
|
|
||||||
items[5].name = "content: recent_datasette_releases";
|
|
||||||
items[5].display_name = "Recent Datasette releases";
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
element.matches = [item];
|
|
||||||
element.renderedMatches = [item];
|
|
||||||
element.selectedIndex = 0;
|
|
||||||
element.selectCurrentItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = JSON.parse(
|
|
||||||
Object.values(localStorage.store).find((value) => value.includes("/item-6"))
|
|
||||||
);
|
|
||||||
if (stored.length !== 5) {
|
|
||||||
throw new Error(`Expected 5 recent items, got ${stored.length}`);
|
|
||||||
}
|
|
||||||
if (stored[0].url !== "/item-6" || stored[4].url !== "/item-2") {
|
|
||||||
throw new Error(`Unexpected recent order: ${JSON.stringify(stored)}`);
|
|
||||||
}
|
|
||||||
if (stored[0].display_name !== "Recent Datasette releases") {
|
|
||||||
throw new Error(`Missing display_name in recent item: ${JSON.stringify(stored[0])}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.matches = [
|
|
||||||
items[5],
|
|
||||||
items[4],
|
|
||||||
{
|
|
||||||
name: "Other",
|
|
||||||
url: "/other",
|
|
||||||
type: "database",
|
|
||||||
description: "Database",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
element.shadowRoot.input.value = "";
|
|
||||||
element.renderResults();
|
|
||||||
|
|
||||||
const html = element.shadowRoot.results.innerHTML;
|
|
||||||
if (!html.includes("Recent")) {
|
|
||||||
throw new Error(`Missing Recent heading: ${html}`);
|
|
||||||
}
|
|
||||||
if (!html.includes("Recent Datasette releases") || !html.includes("Item 5")) {
|
|
||||||
throw new Error(`Missing recent items: ${html}`);
|
|
||||||
}
|
|
||||||
if (!html.includes("content: recent_datasette_releases")) {
|
|
||||||
throw new Error(`Missing canonical item name for display_name item: ${html}`);
|
|
||||||
}
|
|
||||||
if (!html.includes("Item 4") || !html.includes("Item 2")) {
|
|
||||||
throw new Error(`Expected all stored recent items in empty state: ${html}`);
|
|
||||||
}
|
|
||||||
if (html.includes("Other")) {
|
|
||||||
throw new Error(`Rendered non-recent item in empty state: ${html}`);
|
|
||||||
}
|
|
||||||
if (!html.includes("Clear recent")) {
|
|
||||||
throw new Error(`Missing Clear recent control: ${html}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.clearRecentItems();
|
|
||||||
if (localStorage.getItem(element.recentItemsStorageKey()) !== null) {
|
|
||||||
throw new Error("Expected recent items to be cleared");
|
|
||||||
}
|
|
||||||
element.renderResults();
|
|
||||||
if (element.shadowRoot.results.innerHTML.includes("Clear recent")) {
|
|
||||||
throw new Error("Clear recent should disappear after clearing");
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify(stored));
|
|
||||||
""").replace(
|
|
||||||
"__NAVIGATION_SEARCH_JS__",
|
|
||||||
json.dumps(str(STATIC_DIR / "navigation-search.js")),
|
|
||||||
)
|
|
||||||
result = subprocess.run(
|
|
||||||
["node", "-e", script],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
assert [item["url"] for item in json.loads(result.stdout)] == [
|
|
||||||
"/item-6",
|
|
||||||
"/item-5",
|
|
||||||
"/item-4",
|
|
||||||
"/item-3",
|
|
||||||
"/item-2",
|
|
||||||
]
|
|
||||||
assert json.loads(result.stdout)[0]["display_name"] == "Recent Datasette releases"
|
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_search_renders_jump_sections_from_javascript_plugins():
|
|
||||||
script = (
|
|
||||||
textwrap.dedent("""
|
|
||||||
const fs = require("fs");
|
|
||||||
const vm = require("vm");
|
|
||||||
const datasetteManagerJs = __DATASETTE_MANAGER_JS__;
|
|
||||||
const navigationSearchJs = __NAVIGATION_SEARCH_JS__;
|
|
||||||
|
|
||||||
const documentListeners = {};
|
|
||||||
|
|
||||||
class FakeElement {
|
|
||||||
constructor(tagName = "div", parent = null) {
|
|
||||||
this._innerHTML = "";
|
|
||||||
this.value = "";
|
|
||||||
this.dataset = {};
|
|
||||||
this.open = false;
|
|
||||||
this.parent = parent;
|
|
||||||
this.tagName = tagName.toUpperCase();
|
|
||||||
}
|
|
||||||
set textContent(value) {
|
|
||||||
this.innerHTML = String(value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
get innerHTML() {
|
|
||||||
return this._innerHTML;
|
|
||||||
}
|
|
||||||
set innerHTML(value) {
|
|
||||||
this._innerHTML = String(value);
|
|
||||||
if (this.parent) {
|
|
||||||
this.parent._innerHTML += this._innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addEventListener() {}
|
|
||||||
appendChild(child) {
|
|
||||||
this._innerHTML += child.innerHTML || "";
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
close() { this.open = false; }
|
|
||||||
focus() {}
|
|
||||||
querySelector(selector) {
|
|
||||||
if (selector.startsWith("[data-jump-section-index=")) {
|
|
||||||
return new FakeElement("div", this);
|
|
||||||
}
|
|
||||||
return { scrollIntoView() {} };
|
|
||||||
}
|
|
||||||
showModal() { this.open = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeShadowRoot {
|
|
||||||
constructor() {
|
|
||||||
this.innerHTML = "";
|
|
||||||
this.dialog = new FakeElement("dialog");
|
|
||||||
this.input = new FakeElement("input");
|
|
||||||
this.results = new FakeElement("div");
|
|
||||||
}
|
|
||||||
querySelector(selector) {
|
|
||||||
if (selector == "dialog") return this.dialog;
|
|
||||||
if (selector == ".search-input") return this.input;
|
|
||||||
if (selector == ".results-container") return this.results;
|
|
||||||
return new FakeElement();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.HTMLElement = class {
|
|
||||||
constructor() {
|
|
||||||
this.attributes = {};
|
|
||||||
}
|
|
||||||
attachShadow() {
|
|
||||||
this.shadowRoot = new FakeShadowRoot();
|
|
||||||
return this.shadowRoot;
|
|
||||||
}
|
|
||||||
dispatchEvent() {}
|
|
||||||
getAttribute(name) {
|
|
||||||
return this.attributes[name] || null;
|
|
||||||
}
|
|
||||||
querySelector() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
setAttribute(name, value) {
|
|
||||||
this.attributes[name] = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
global.CustomEvent = class {
|
|
||||||
constructor(name, options) {
|
|
||||||
this.name = name;
|
|
||||||
this.type = name;
|
|
||||||
this.detail = options ? options.detail : undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
global.customElements = {
|
|
||||||
registry: new Map(),
|
|
||||||
define(name, cls) {
|
|
||||||
this.registry.set(name, cls);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
global.document = {
|
|
||||||
addEventListener(name, callback) {
|
|
||||||
documentListeners[name] = documentListeners[name] || [];
|
|
||||||
documentListeners[name].push(callback);
|
|
||||||
},
|
|
||||||
activeElement: null,
|
|
||||||
createElement(tagName) {
|
|
||||||
return new FakeElement(tagName);
|
|
||||||
},
|
|
||||||
dispatchEvent(event) {
|
|
||||||
for (const callback of documentListeners[event.type] || []) {
|
|
||||||
callback(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
querySelectorAll() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
global.localStorage = {
|
|
||||||
getItem() { return null; },
|
|
||||||
setItem() {},
|
|
||||||
removeItem() {},
|
|
||||||
};
|
|
||||||
global.window = { datasetteVersion: "test", location: { href: "" } };
|
|
||||||
|
|
||||||
vm.runInThisContext(
|
|
||||||
fs.readFileSync(datasetteManagerJs, "utf8"),
|
|
||||||
{ filename: "datasette-manager.js" }
|
|
||||||
);
|
|
||||||
for (const callback of documentListeners.DOMContentLoaded || []) {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
window.__DATASETTE__.registerPlugin("agent", {
|
|
||||||
version: "0.1",
|
|
||||||
makeJumpSections() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "agent-chat",
|
|
||||||
render(node, context) {
|
|
||||||
if (!context.navigationSearch) {
|
|
||||||
throw new Error("Expected navigationSearch in render context");
|
|
||||||
}
|
|
||||||
node.innerHTML = [
|
|
||||||
'<section class="agent-jump-start">',
|
|
||||||
'<button>Start a new agent chat</button>',
|
|
||||||
'</section>',
|
|
||||||
].join('');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
vm.runInThisContext(
|
|
||||||
fs.readFileSync(navigationSearchJs, "utf8"),
|
|
||||||
{ filename: "navigation-search.js" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const Component = customElements.registry.get("navigation-search");
|
|
||||||
const element = new Component();
|
|
||||||
element.shadowRoot.input.value = "";
|
|
||||||
element.renderResults();
|
|
||||||
|
|
||||||
const html = element.shadowRoot.results.innerHTML;
|
|
||||||
if (!html.includes("Start a new agent chat")) {
|
|
||||||
throw new Error(`Missing jump section content: ${html}`);
|
|
||||||
}
|
|
||||||
process.stdout.write("ok");
|
|
||||||
""")
|
|
||||||
.replace(
|
|
||||||
"__DATASETTE_MANAGER_JS__",
|
|
||||||
json.dumps(str(STATIC_DIR / "datasette-manager.js")),
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
"__NAVIGATION_SEARCH_JS__",
|
|
||||||
json.dumps(str(STATIC_DIR / "navigation-search.js")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
result = subprocess.run(
|
|
||||||
["node", "-e", script],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
text=True,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
assert result.stdout.endswith("ok")
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import collections
|
import collections
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette.cli import cli
|
from datasette.cli import cli
|
||||||
from datasette.default_permissions import restrictions_allow_action
|
from datasette.default_permissions import restrictions_allow_action
|
||||||
|
|
@ -431,6 +430,7 @@ async def test_permissions_debug(ds_client, filter_):
|
||||||
"result": True,
|
"result": True,
|
||||||
"actor": {"id": "root"},
|
"actor": {"id": "root"},
|
||||||
},
|
},
|
||||||
|
{"action": "debug-menu", "result": False, "actor": None},
|
||||||
{
|
{
|
||||||
"action": "view-instance",
|
"action": "view-instance",
|
||||||
"result": True,
|
"result": True,
|
||||||
|
|
@ -610,10 +610,6 @@ def test_padlocks_on_database_page(cascade_app_client):
|
||||||
previous_config = cascade_app_client.ds.config
|
previous_config = cascade_app_client.ds.config
|
||||||
try:
|
try:
|
||||||
cascade_app_client.ds.config = config
|
cascade_app_client.ds.config = config
|
||||||
async_to_sync(cascade_app_client.ds.invoke_startup)()
|
|
||||||
async_to_sync(cascade_app_client.ds.add_query)(
|
|
||||||
"fixtures", "query_two", "select 2", source="config"
|
|
||||||
)
|
|
||||||
response = cascade_app_client.get(
|
response = cascade_app_client.get(
|
||||||
"/fixtures",
|
"/fixtures",
|
||||||
cookies={"ds_actor": cascade_app_client.actor_cookie({"id": "test"})},
|
cookies={"ds_actor": cascade_app_client.actor_cookie({"id": "test"})},
|
||||||
|
|
@ -622,13 +618,13 @@ def test_padlocks_on_database_page(cascade_app_client):
|
||||||
assert ">123_starts_with_digits</a></h3>" in response.text
|
assert ">123_starts_with_digits</a></h3>" in response.text
|
||||||
assert ">Table With Space In Name</a> 🔒</h3>" in response.text
|
assert ">Table With Space In Name</a> 🔒</h3>" in response.text
|
||||||
# Queries
|
# Queries
|
||||||
|
assert ">from_async_hook</a> 🔒</li>" in response.text
|
||||||
assert ">query_two</a></li>" in response.text
|
assert ">query_two</a></li>" in response.text
|
||||||
# Views
|
# Views
|
||||||
assert ">paginated_view</a> 🔒</li>" in response.text
|
assert ">paginated_view</a> 🔒</li>" in response.text
|
||||||
assert ">simple_view</a></li>" in response.text
|
assert ">simple_view</a></li>" in response.text
|
||||||
finally:
|
finally:
|
||||||
cascade_app_client.ds.config = previous_config
|
cascade_app_client.ds.config = previous_config
|
||||||
async_to_sync(cascade_app_client.ds.remove_query)("fixtures", "query_two")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -890,7 +886,7 @@ PermConfigTestCase = collections.namedtuple(
|
||||||
resource=("perms_ds_one", "t1"),
|
resource=("perms_ds_one", "t1"),
|
||||||
expected_result=True,
|
expected_result=True,
|
||||||
),
|
),
|
||||||
# view-query on stored query, wrong actor
|
# view-query on canned query, wrong actor
|
||||||
PermConfigTestCase(
|
PermConfigTestCase(
|
||||||
config={
|
config={
|
||||||
"databases": {
|
"databases": {
|
||||||
|
|
@ -909,7 +905,7 @@ PermConfigTestCase = collections.namedtuple(
|
||||||
resource=("perms_ds_one", "q1"),
|
resource=("perms_ds_one", "q1"),
|
||||||
expected_result=False,
|
expected_result=False,
|
||||||
),
|
),
|
||||||
# view-query on stored query, right actor
|
# view-query on canned query, right actor
|
||||||
PermConfigTestCase(
|
PermConfigTestCase(
|
||||||
config={
|
config={
|
||||||
"databases": {
|
"databases": {
|
||||||
|
|
@ -937,24 +933,16 @@ async def test_permissions_in_config(
|
||||||
updated_config = copy.deepcopy(previous_config)
|
updated_config = copy.deepcopy(previous_config)
|
||||||
updated_config.update(config)
|
updated_config.update(config)
|
||||||
perms_ds.config = updated_config
|
perms_ds.config = updated_config
|
||||||
await perms_ds._save_queries_from_config()
|
|
||||||
try:
|
try:
|
||||||
# Convert old-style resource to Resource object
|
# Convert old-style resource to Resource object
|
||||||
from datasette.resources import DatabaseResource, QueryResource, TableResource
|
from datasette.resources import DatabaseResource, TableResource
|
||||||
|
|
||||||
resource_obj = None
|
resource_obj = None
|
||||||
if resource:
|
if resource:
|
||||||
if isinstance(resource, str):
|
if isinstance(resource, str):
|
||||||
resource_obj = DatabaseResource(database=resource)
|
resource_obj = DatabaseResource(database=resource)
|
||||||
elif isinstance(resource, tuple) and len(resource) == 2:
|
elif isinstance(resource, tuple) and len(resource) == 2:
|
||||||
if action == "view-query":
|
resource_obj = TableResource(database=resource[0], table=resource[1])
|
||||||
resource_obj = QueryResource(
|
|
||||||
database=resource[0], query=resource[1]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
resource_obj = TableResource(
|
|
||||||
database=resource[0], table=resource[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await perms_ds.allowed(
|
result = await perms_ds.allowed(
|
||||||
action=action, resource=resource_obj, actor=actor
|
action=action, resource=resource_obj, actor=actor
|
||||||
|
|
@ -964,24 +952,42 @@ async def test_permissions_in_config(
|
||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
finally:
|
finally:
|
||||||
perms_ds.config = previous_config
|
perms_ds.config = previous_config
|
||||||
await perms_ds._save_queries_from_config()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_allowed_resources_view_query_includes_actor_specific_query_permissions():
|
async def test_allowed_resources_view_query_includes_actor_specific_canned_queries():
|
||||||
|
"""
|
||||||
|
Actor-specific canned queries should be listed by allowed_resources("view-query").
|
||||||
|
|
||||||
|
This test is intentionally explicit about the previous bug:
|
||||||
|
- the canned query only exists for actor "alice"
|
||||||
|
- the permission rule only allows actor "alice" to view it
|
||||||
|
- allowed() succeeds for that specific query resource
|
||||||
|
- allowed_resources("view-query", actor) must include the same query
|
||||||
|
|
||||||
|
Before the fix, QueryResource.resources_sql() called canned_queries(..., actor=None),
|
||||||
|
so the query was omitted from resource enumeration and allowed_resources() returned
|
||||||
|
an empty list even though allowed() returned True.
|
||||||
|
"""
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.permissions import PermissionSQL
|
from datasette.permissions import PermissionSQL
|
||||||
from datasette.resources import QueryResource
|
from datasette.resources import QueryResource
|
||||||
|
|
||||||
class ActorSpecificQueryPermissionPlugin:
|
class ActorSpecificQueryPlugin:
|
||||||
__name__ = "ActorSpecificQueryPermissionPlugin"
|
__name__ = "ActorSpecificQueryPlugin"
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def canned_queries(self, datasette, database, actor):
|
||||||
|
if database == "testdb" and actor and actor.get("id") == "alice":
|
||||||
|
return {"user_only": {"sql": "select 1 as n"}}
|
||||||
|
return {}
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def permission_resources_sql(self, datasette, actor, action):
|
def permission_resources_sql(self, datasette, actor, action):
|
||||||
if action == "view-query" and actor and actor.get("id") == "alice":
|
if action == "view-query" and actor and actor.get("id") == "alice":
|
||||||
return PermissionSQL(sql="""
|
return PermissionSQL(sql="""
|
||||||
SELECT 'testdb' AS parent, 'user_only' AS child, 1 AS allow,
|
SELECT 'testdb' AS parent, 'user_only' AS child, 1 AS allow,
|
||||||
'alice can view this query' AS reason
|
'alice can view her actor-specific canned query' AS reason
|
||||||
""")
|
""")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -989,10 +995,9 @@ async def test_allowed_resources_view_query_includes_actor_specific_query_permis
|
||||||
await ds.invoke_startup()
|
await ds.invoke_startup()
|
||||||
ds.add_memory_database("testdb")
|
ds.add_memory_database("testdb")
|
||||||
await ds._refresh_schemas()
|
await ds._refresh_schemas()
|
||||||
await ds.add_query("testdb", "user_only", "select 1 as n")
|
|
||||||
|
|
||||||
plugin = ActorSpecificQueryPermissionPlugin()
|
plugin = ActorSpecificQueryPlugin()
|
||||||
ds.pm.register(plugin, name="actor_specific_query_permission_plugin")
|
ds.pm.register(plugin, name="actor_specific_query_plugin")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
actor = {"id": "alice"}
|
actor = {"id": "alice"}
|
||||||
|
|
@ -1008,7 +1013,7 @@ async def test_allowed_resources_view_query_includes_actor_specific_query_permis
|
||||||
("testdb", "user_only")
|
("testdb", "user_only")
|
||||||
]
|
]
|
||||||
finally:
|
finally:
|
||||||
ds.pm.unregister(name="actor_specific_query_permission_plugin")
|
ds.pm.unregister(name="actor_specific_query_plugin")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from bs4 import BeautifulSoup as Soup
|
from bs4 import BeautifulSoup as Soup
|
||||||
from .fixtures import (
|
from .fixtures import (
|
||||||
make_app_client,
|
make_app_client,
|
||||||
|
TABLES,
|
||||||
TEMP_PLUGIN_SECRET_FILE,
|
TEMP_PLUGIN_SECRET_FILE,
|
||||||
PLUGINS_DIR,
|
PLUGINS_DIR,
|
||||||
TestClient as _TestClient,
|
TestClient as _TestClient,
|
||||||
|
|
@ -8,7 +9,6 @@ from .fixtures import (
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
from datasette import cli, hookimpl
|
from datasette import cli, hookimpl
|
||||||
from datasette.fixtures import TABLES
|
|
||||||
from datasette.filters import FilterArguments
|
from datasette.filters import FilterArguments
|
||||||
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
|
from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm
|
||||||
from datasette.permissions import PermissionSQL, Action
|
from datasette.permissions import PermissionSQL, Action
|
||||||
|
|
@ -43,11 +43,6 @@ def test_plugin_hooks_have_tests(plugin_hook):
|
||||||
assert ok, f"Plugin hook is missing tests: {plugin_hook}"
|
assert ok, f"Plugin hook is missing tests: {plugin_hook}"
|
||||||
|
|
||||||
|
|
||||||
def test_hook_jump_items_sql():
|
|
||||||
# Detailed behavior is covered in tests/test_jump.py.
|
|
||||||
assert "jump_items_sql" in dir(pm.hook)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_hook_plugins_dir_plugin_prepare_connection(ds_client):
|
async def test_hook_plugins_dir_plugin_prepare_connection(ds_client):
|
||||||
response = await ds_client.get(
|
response = await ds_client.get(
|
||||||
|
|
@ -885,64 +880,40 @@ async def test_hook_startup_catalog_populated(ds_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_plugin_startup_can_add_queries():
|
async def test_hook_canned_queries(ds_client):
|
||||||
ds = Datasette(memory=True)
|
queries = (await ds_client.get("/fixtures.json")).json()["queries"]
|
||||||
ds.add_memory_database("plugin_startup_queries", name="data")
|
|
||||||
|
|
||||||
class AddQueriesPlugin:
|
|
||||||
__name__ = "AddQueriesPlugin"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
result = await datasette.get_database("data").execute("select 1 + 1")
|
|
||||||
await datasette.add_query(
|
|
||||||
"data",
|
|
||||||
"from_startup",
|
|
||||||
"select {}".format(result.first()[0]),
|
|
||||||
source="plugin",
|
|
||||||
)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds.pm.register(AddQueriesPlugin(), name="add_queries_plugin")
|
|
||||||
try:
|
|
||||||
response = await ds.client.get("/data.json")
|
|
||||||
finally:
|
|
||||||
ds.pm.unregister(name="add_queries_plugin")
|
|
||||||
|
|
||||||
queries = response.json()["queries"]
|
|
||||||
queries_by_name = {q["name"]: q for q in queries}
|
queries_by_name = {q["name"]: q for q in queries}
|
||||||
assert queries_by_name["from_startup"]["sql"] == "select 2"
|
assert {
|
||||||
assert queries_by_name["from_startup"]["private"] is False
|
"sql": "select 2",
|
||||||
|
"name": "from_async_hook",
|
||||||
|
"private": False,
|
||||||
|
} == queries_by_name["from_async_hook"]
|
||||||
|
assert {
|
||||||
|
"sql": "select 1, 'null' as actor_id",
|
||||||
|
"name": "from_hook",
|
||||||
|
"private": False,
|
||||||
|
} == queries_by_name["from_hook"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_plugin_startup_query_can_execute():
|
async def test_hook_canned_queries_non_async(ds_client):
|
||||||
ds = Datasette(memory=True)
|
response = await ds_client.get("/fixtures/from_hook.json?_shape=array")
|
||||||
ds.add_memory_database("plugin_startup_query_execute", name="data")
|
assert [{"1": 1, "actor_id": "null"}] == response.json()
|
||||||
|
|
||||||
class AddQueryPlugin:
|
|
||||||
__name__ = "AddQueryPlugin"
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def startup(self, datasette):
|
|
||||||
async def inner():
|
|
||||||
await datasette.add_query(
|
|
||||||
"data", "from_startup", "select 2", source="plugin"
|
|
||||||
)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
ds.pm.register(AddQueryPlugin(), name="add_query_plugin")
|
|
||||||
try:
|
|
||||||
response = await ds.client.get("/data/from_startup.json?_shape=array")
|
|
||||||
finally:
|
|
||||||
ds.pm.unregister(name="add_query_plugin")
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_canned_queries_async(ds_client):
|
||||||
|
response = await ds_client.get("/fixtures/from_async_hook.json?_shape=array")
|
||||||
assert [{"2": 2}] == response.json()
|
assert [{"2": 2}] == response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_canned_queries_actor(ds_client):
|
||||||
|
assert (
|
||||||
|
await ds_client.get("/fixtures/from_hook.json?_bot=1&_shape=array")
|
||||||
|
).json() == [{"1": 1, "actor_id": "bot"}]
|
||||||
|
|
||||||
|
|
||||||
def test_hook_register_magic_parameters(restore_working_directory):
|
def test_hook_register_magic_parameters(restore_working_directory):
|
||||||
with make_app_client(
|
with make_app_client(
|
||||||
extra_databases={"data.db": "create table logs (line text)"},
|
extra_databases={"data.db": "create table logs (line text)"},
|
||||||
|
|
@ -1486,10 +1457,8 @@ class SlotPlugin:
|
||||||
return "Xtop_query:{}:{}:{}".format(database, sql, request.args["z"])
|
return "Xtop_query:{}:{}:{}".format(database, sql, request.args["z"])
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def top_stored_query(self, request, database, query_name):
|
def top_canned_query(self, request, database, query_name):
|
||||||
return "Xtop_stored_query:{}:{}:{}".format(
|
return "Xtop_query:{}:{}:{}".format(database, query_name, request.args["z"])
|
||||||
database, query_name, request.args["z"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1550,12 +1519,12 @@ async def test_hook_top_query(ds_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_hook_top_stored_query(ds_client):
|
async def test_hook_top_canned_query(ds_client):
|
||||||
try:
|
try:
|
||||||
pm.register(SlotPlugin(), name="SlotPlugin")
|
pm.register(SlotPlugin(), name="SlotPlugin")
|
||||||
response = await ds_client.get("/fixtures/magic_parameters?z=xyz")
|
response = await ds_client.get("/fixtures/from_hook?z=xyz")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Xtop_stored_query:fixtures:magic_parameters:xyz" in response.text
|
assert "Xtop_query:fixtures:from_hook:xyz" in response.text
|
||||||
finally:
|
finally:
|
||||||
pm.unregister(name="SlotPlugin")
|
pm.unregister(name="SlotPlugin")
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue