mirror of
https://github.com/simonw/datasette.git
synced 2026-08-03 16:04:10 +02:00
Compare commits
11 commits
main
...
asg017/cod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec0f94ed8 | ||
|
|
866b2d34e1 | ||
|
|
343e8258fb | ||
|
|
174099b707 | ||
|
|
cc1a24fb4f | ||
|
|
49f1660dcd | ||
|
|
b9716d4278 | ||
|
|
7e555b01f6 | ||
|
|
d12f0d2c59 | ||
|
|
28d811320b | ||
|
|
79f6ac3f47 |
170 changed files with 3755 additions and 3497 deletions
|
|
@ -89,8 +89,7 @@ def pytest_runtest_protocol(item, nextitem):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
ds.close()
|
ds.close()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# Surfaced as a pytest warning; teardown must not fail the run
|
|
||||||
item.warn(
|
item.warn(
|
||||||
pytest.PytestUnraisableExceptionWarning(
|
pytest.PytestUnraisableExceptionWarning(
|
||||||
f"Error closing Datasette instance: {e!r}"
|
f"Error closing Datasette instance: {e!r}"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import time
|
|
||||||
|
|
||||||
from itsdangerous import BadSignature
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
from itsdangerous import BadSignature
|
||||||
from datasette.utils import baseconv
|
from datasette.utils import baseconv
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
|
||||||
373
datasette/app.py
373
datasette/app.py
|
|
@ -2,8 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextvars
|
import contextvars
|
||||||
from collections.abc import Iterable, Sequence
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.permissions import Resource
|
from datasette.permissions import Resource
|
||||||
|
|
@ -13,10 +12,11 @@ import dataclasses
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
import glob
|
import glob
|
||||||
|
import httpx
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import inspect
|
import inspect
|
||||||
|
from itsdangerous import BadSignature
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
|
@ -28,41 +28,91 @@ import urllib.parse
|
||||||
from concurrent import futures
|
from concurrent import futures
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
from markupsafe import Markup, escape
|
||||||
from itsdangerous import BadSignature, URLSafeSerializer
|
from itsdangerous import URLSafeSerializer
|
||||||
from jinja2 import (
|
from jinja2 import (
|
||||||
ChoiceLoader,
|
ChoiceLoader,
|
||||||
Environment,
|
Environment,
|
||||||
FileSystemLoader,
|
FileSystemLoader,
|
||||||
PrefixLoader,
|
|
||||||
pass_context,
|
pass_context,
|
||||||
|
PrefixLoader,
|
||||||
)
|
)
|
||||||
from jinja2.environment import Template
|
from jinja2.environment import Template
|
||||||
from jinja2.exceptions import TemplateNotFound
|
from jinja2.exceptions import TemplateNotFound
|
||||||
from markupsafe import Markup, escape
|
|
||||||
|
|
||||||
from . import stored_queries, write_sql
|
|
||||||
from .column_types import SQLiteType
|
|
||||||
from .csrf import CrossOriginProtectionMiddleware
|
|
||||||
from .database import Database, QueryInterrupted
|
|
||||||
from .events import Event
|
from .events import Event
|
||||||
from .plugins import DEFAULT_PLUGINS, get_plugins, pm
|
from .column_types import SQLiteType
|
||||||
|
from . import stored_queries, write_sql
|
||||||
|
from .views import Context
|
||||||
|
from .views.database import (
|
||||||
|
database_download,
|
||||||
|
DatabaseView,
|
||||||
|
QueryView,
|
||||||
|
)
|
||||||
|
from .views.table_create_alter import (
|
||||||
|
DatabaseForeignKeyTargetsView,
|
||||||
|
TableAlterView,
|
||||||
|
TableCreateView,
|
||||||
|
TableForeignKeySuggestionsView,
|
||||||
|
)
|
||||||
|
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
||||||
|
from .views.stored_queries import (
|
||||||
|
QueryCreateAnalyzeView,
|
||||||
|
QueryDeleteView,
|
||||||
|
QueryDefinitionView,
|
||||||
|
QueryEditView,
|
||||||
|
GlobalQueryListView,
|
||||||
|
QueryListView,
|
||||||
|
QueryParametersView,
|
||||||
|
QueryStoreView,
|
||||||
|
QueryUpdateView,
|
||||||
|
)
|
||||||
|
from .views.index import IndexView
|
||||||
|
from .views.special import (
|
||||||
|
JsonDataView,
|
||||||
|
PatternPortfolioView,
|
||||||
|
AutocompleteDebugView,
|
||||||
|
AuthTokenView,
|
||||||
|
ApiExplorerView,
|
||||||
|
CreateTokenView,
|
||||||
|
LogoutView,
|
||||||
|
AllowDebugView,
|
||||||
|
PermissionsDebugView,
|
||||||
|
MessagesDebugView,
|
||||||
|
AllowedResourcesView,
|
||||||
|
PermissionRulesView,
|
||||||
|
PermissionCheckView,
|
||||||
|
JumpView,
|
||||||
|
InstanceSchemaView,
|
||||||
|
DatabaseSchemaView,
|
||||||
|
DatabaseEditorSchemaView,
|
||||||
|
TableSchemaView,
|
||||||
|
)
|
||||||
|
from .views.table import (
|
||||||
|
TableAutocompleteView,
|
||||||
|
TableInsertView,
|
||||||
|
TableUpsertView,
|
||||||
|
TableSetColumnTypeView,
|
||||||
|
TableDropView,
|
||||||
|
TableFragmentView,
|
||||||
|
table_view,
|
||||||
|
)
|
||||||
|
from .views.row import RowView, RowDeleteView, RowUpdateView
|
||||||
from .renderer import json_renderer
|
from .renderer import json_renderer
|
||||||
from .resources import DatabaseResource, TableResource
|
|
||||||
from .tokens import TokenInvalid
|
|
||||||
from .tracer import AsgiTracer
|
|
||||||
from .url_builder import Urls
|
from .url_builder import Urls
|
||||||
|
from .database import Database, QueryInterrupted
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
SPATIALITE_FUNCTIONS,
|
|
||||||
PaginatedResources,
|
PaginatedResources,
|
||||||
PrefixedUrlString,
|
PrefixedUrlString,
|
||||||
|
SPATIALITE_FUNCTIONS,
|
||||||
StartupError,
|
StartupError,
|
||||||
add_cors_headers,
|
|
||||||
async_call_with_supported_arguments,
|
async_call_with_supported_arguments,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
baseconv,
|
baseconv,
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
detect_json1,
|
detect_json1,
|
||||||
|
add_cors_headers,
|
||||||
display_actor,
|
display_actor,
|
||||||
escape_css_string,
|
escape_css_string,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
|
|
@ -72,97 +122,47 @@ from .utils import (
|
||||||
move_plugins_and_allow,
|
move_plugins_and_allow,
|
||||||
move_table_config,
|
move_table_config,
|
||||||
parse_metadata,
|
parse_metadata,
|
||||||
redact_keys,
|
|
||||||
resolve_env_secrets,
|
resolve_env_secrets,
|
||||||
resolve_routes,
|
resolve_routes,
|
||||||
row_sql_params_pks,
|
|
||||||
sha256_file,
|
sha256_file,
|
||||||
tilde_decode,
|
tilde_decode,
|
||||||
tilde_encode,
|
tilde_encode,
|
||||||
to_css_class,
|
to_css_class,
|
||||||
urlsafe_components,
|
urlsafe_components,
|
||||||
|
redact_keys,
|
||||||
|
row_sql_params_pks,
|
||||||
)
|
)
|
||||||
|
from .tokens import TokenInvalid
|
||||||
from .utils.asgi import (
|
from .utils.asgi import (
|
||||||
AsgiLifespan,
|
AsgiLifespan,
|
||||||
AsgiRunOnFirstRequest,
|
|
||||||
BadRequest,
|
BadRequest,
|
||||||
DatabaseNotFound,
|
|
||||||
Forbidden,
|
Forbidden,
|
||||||
NotFound,
|
NotFound,
|
||||||
|
DatabaseNotFound,
|
||||||
|
TableNotFound,
|
||||||
|
RowNotFound,
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
RowNotFound,
|
AsgiRunOnFirstRequest,
|
||||||
TableNotFound,
|
asgi_static,
|
||||||
asgi_send,
|
asgi_send,
|
||||||
asgi_send_file,
|
asgi_send_file,
|
||||||
asgi_send_redirect,
|
asgi_send_redirect,
|
||||||
asgi_static,
|
|
||||||
)
|
)
|
||||||
|
from .csrf import CrossOriginProtectionMiddleware
|
||||||
from .utils.internal_db import init_internal_db, populate_schema_tables
|
from .utils.internal_db import init_internal_db, populate_schema_tables
|
||||||
from .utils.sqlite import (
|
from .utils.sqlite import (
|
||||||
sqlite3,
|
sqlite3,
|
||||||
using_pysqlite3,
|
using_pysqlite3,
|
||||||
)
|
)
|
||||||
|
from .tracer import AsgiTracer
|
||||||
|
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
||||||
from .version import __version__
|
from .version import __version__
|
||||||
from .views import Context
|
|
||||||
from .views.database import (
|
from .resources import DatabaseResource, TableResource
|
||||||
DatabaseView,
|
|
||||||
QueryView,
|
|
||||||
database_download,
|
|
||||||
)
|
|
||||||
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
|
||||||
from .views.index import IndexView
|
|
||||||
from .views.row import RowDeleteView, RowUpdateView, RowView
|
|
||||||
from .views.special import (
|
|
||||||
AllowDebugView,
|
|
||||||
AllowedResourcesView,
|
|
||||||
ApiExplorerView,
|
|
||||||
AuthTokenView,
|
|
||||||
AutocompleteDebugView,
|
|
||||||
CreateTokenView,
|
|
||||||
DatabaseSchemaView,
|
|
||||||
InstanceSchemaView,
|
|
||||||
JsonDataView,
|
|
||||||
JumpView,
|
|
||||||
LogoutView,
|
|
||||||
MessagesDebugView,
|
|
||||||
PatternPortfolioView,
|
|
||||||
PermissionCheckView,
|
|
||||||
PermissionRulesView,
|
|
||||||
PermissionsDebugView,
|
|
||||||
TableSchemaView,
|
|
||||||
)
|
|
||||||
from .views.stored_queries import (
|
|
||||||
GlobalQueryListView,
|
|
||||||
QueryCreateAnalyzeView,
|
|
||||||
QueryDefinitionView,
|
|
||||||
QueryDeleteView,
|
|
||||||
QueryEditView,
|
|
||||||
QueryListView,
|
|
||||||
QueryParametersView,
|
|
||||||
QueryStoreView,
|
|
||||||
QueryUpdateView,
|
|
||||||
)
|
|
||||||
from .views.table import (
|
|
||||||
TableAutocompleteView,
|
|
||||||
TableDropView,
|
|
||||||
TableFragmentView,
|
|
||||||
TableInsertView,
|
|
||||||
TableSetColumnTypeView,
|
|
||||||
TableUpsertView,
|
|
||||||
table_view,
|
|
||||||
)
|
|
||||||
from .views.table_create_alter import (
|
|
||||||
DatabaseForeignKeyTargetsView,
|
|
||||||
TableAlterView,
|
|
||||||
TableCreateView,
|
|
||||||
TableForeignKeySuggestionsView,
|
|
||||||
)
|
|
||||||
|
|
||||||
app_root = Path(__file__).parent.parent
|
app_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# Context variable to track when code is executing within a datasette.client request
|
# Context variable to track when code is executing within a datasette.client request
|
||||||
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
||||||
|
|
@ -185,7 +185,7 @@ class PermissionCheck:
|
||||||
"""Represents a logged permission check for debugging purposes."""
|
"""Represents a logged permission check for debugging purposes."""
|
||||||
|
|
||||||
when: str
|
when: str
|
||||||
actor: dict[str, Any] | None
|
actor: Dict[str, Any] | None
|
||||||
action: str
|
action: str
|
||||||
parent: str | None
|
parent: str | None
|
||||||
child: str | None
|
child: str | None
|
||||||
|
|
@ -435,7 +435,7 @@ class Datasette:
|
||||||
if config_dir:
|
if config_dir:
|
||||||
db_files = []
|
db_files = []
|
||||||
for ext in ("db", "sqlite", "sqlite3"):
|
for ext in ("db", "sqlite", "sqlite3"):
|
||||||
db_files.extend(config_dir.glob(f"*.{ext}"))
|
db_files.extend(config_dir.glob("*.{}".format(ext)))
|
||||||
self.files += tuple(str(f) for f in db_files)
|
self.files += tuple(str(f) for f in db_files)
|
||||||
if (
|
if (
|
||||||
config_dir
|
config_dir
|
||||||
|
|
@ -676,10 +676,10 @@ class Datasette:
|
||||||
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:
|
||||||
for hook_environment in pm.hook.jinja2_environment_from_request(
|
for environment in pm.hook.jinja2_environment_from_request(
|
||||||
datasette=self, request=request, env=environment
|
datasette=self, request=request, env=environment
|
||||||
):
|
):
|
||||||
environment = hook_environment
|
pass
|
||||||
return environment
|
return environment
|
||||||
|
|
||||||
def get_action(self, name_or_abbr: str):
|
def get_action(self, name_or_abbr: str):
|
||||||
|
|
@ -733,7 +733,7 @@ class Datasette:
|
||||||
catalog_database_names.update(
|
catalog_database_names.update(
|
||||||
row["database_name"]
|
row["database_name"]
|
||||||
for row in await internal_db.execute(
|
for row in await internal_db.execute(
|
||||||
f"select distinct database_name from {table}"
|
"select distinct database_name from {}".format(table)
|
||||||
)
|
)
|
||||||
if row["database_name"] is not None
|
if row["database_name"] is not None
|
||||||
)
|
)
|
||||||
|
|
@ -744,7 +744,7 @@ class Datasette:
|
||||||
for stale_db_name in stale_databases:
|
for stale_db_name in stale_databases:
|
||||||
for table in catalog_table_names:
|
for table in catalog_table_names:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"DELETE FROM {table} WHERE database_name = ?",
|
"DELETE FROM {} WHERE database_name = ?".format(table),
|
||||||
[stale_db_name],
|
[stale_db_name],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -754,7 +754,19 @@ class Datasette:
|
||||||
# Compare schema versions to see if we should skip it
|
# Compare schema versions to see if we should skip it
|
||||||
if schema_version == current_schema_versions.get(database_name):
|
if schema_version == current_schema_versions.get(database_name):
|
||||||
continue
|
continue
|
||||||
await populate_schema_tables(internal_db, db, schema_version)
|
placeholders = "(?, ?, ?, ?)"
|
||||||
|
values = [database_name, str(db.path), db.is_memory, schema_version]
|
||||||
|
if db.path is None:
|
||||||
|
placeholders = "(?, null, ?, ?)"
|
||||||
|
values = [database_name, db.is_memory, schema_version]
|
||||||
|
await internal_db.execute_write(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)
|
||||||
|
VALUES {}
|
||||||
|
""".format(placeholders),
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
await populate_schema_tables(internal_db, db)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def urls(self):
|
def urls(self):
|
||||||
|
|
@ -793,13 +805,17 @@ class Datasette:
|
||||||
action.name in action_names
|
action.name in action_names
|
||||||
and action != action_names[action.name]
|
and action != action_names[action.name]
|
||||||
):
|
):
|
||||||
raise StartupError(f"Duplicate action name: {action.name}")
|
raise StartupError(
|
||||||
|
"Duplicate action name: {}".format(action.name)
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
action.abbr
|
action.abbr
|
||||||
and action.abbr in action_abbrs
|
and action.abbr in action_abbrs
|
||||||
and action != action_abbrs[action.abbr]
|
and action != action_abbrs[action.abbr]
|
||||||
):
|
):
|
||||||
raise StartupError(f"Duplicate action abbr: {action.abbr}")
|
raise StartupError(
|
||||||
|
"Duplicate action abbr: {}".format(action.abbr)
|
||||||
|
)
|
||||||
action_names[action.name] = action
|
action_names[action.name] = action
|
||||||
if action.abbr:
|
if action.abbr:
|
||||||
action_abbrs[action.abbr] = action
|
action_abbrs[action.abbr] = action
|
||||||
|
|
@ -858,7 +874,7 @@ class Datasette:
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
*,
|
*,
|
||||||
expires_after: int | None = None,
|
expires_after: int | None = None,
|
||||||
restrictions: TokenRestrictions | None = None,
|
restrictions: "TokenRestrictions | None" = None,
|
||||||
handler: str | None = None,
|
handler: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -915,7 +931,7 @@ class Datasette:
|
||||||
raise KeyError
|
raise KeyError
|
||||||
return matches[0]
|
return matches[0]
|
||||||
if name is None:
|
if name is None:
|
||||||
name = next(iter(self.databases.keys()))
|
name = [key for key in self.databases.keys()][0]
|
||||||
return self.databases[name]
|
return self.databases[name]
|
||||||
|
|
||||||
def add_database(self, db, name=None, route=None):
|
def add_database(self, db, name=None, route=None):
|
||||||
|
|
@ -928,7 +944,7 @@ class Datasette:
|
||||||
suggestion = name
|
suggestion = name
|
||||||
i = 2
|
i = 2
|
||||||
while name in self.databases:
|
while name in self.databases:
|
||||||
name = f"{suggestion}_{i}"
|
name = "{}_{}".format(suggestion, i)
|
||||||
i += 1
|
i += 1
|
||||||
db.name = name
|
db.name = name
|
||||||
db.route = route or name
|
db.route = route or name
|
||||||
|
|
@ -963,14 +979,13 @@ class Datasette:
|
||||||
for db in dbs:
|
for db in dbs:
|
||||||
try:
|
try:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# Collect the first failure and re-raise after every close() has run
|
|
||||||
if first_exception is None:
|
if first_exception is None:
|
||||||
first_exception = e
|
first_exception = e
|
||||||
if self.executor is not None:
|
if self.executor is not None:
|
||||||
try:
|
try:
|
||||||
self.executor.shutdown(wait=True, cancel_futures=True)
|
self.executor.shutdown(wait=True, cancel_futures=True)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
if first_exception is None:
|
if first_exception is None:
|
||||||
first_exception = e
|
first_exception = e
|
||||||
if first_exception is not None:
|
if first_exception is not None:
|
||||||
|
|
@ -1319,15 +1334,24 @@ class Datasette:
|
||||||
actual = (
|
actual = (
|
||||||
actual_sqlite_type.value
|
actual_sqlite_type.value
|
||||||
if actual_sqlite_type is not None
|
if actual_sqlite_type is not None
|
||||||
else f"unrecognized {column_detail.type!r}"
|
else "unrecognized {!r}".format(column_detail.type)
|
||||||
)
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Column type {ct_cls.name!r} is only applicable to SQLite types {allowed} but {database}.{resource}.{column} "
|
"Column type {!r} is only applicable to SQLite types {} but {}.{}.{} "
|
||||||
f"has SQLite type {actual}"
|
"has SQLite type {}".format(
|
||||||
|
ct_cls.name,
|
||||||
|
allowed,
|
||||||
|
database,
|
||||||
|
resource,
|
||||||
|
column,
|
||||||
|
actual,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _apply_column_types_config(self):
|
async def _apply_column_types_config(self):
|
||||||
"""Load column_types from datasette.json config into the internal DB."""
|
"""Load column_types from datasette.json config into the internal DB."""
|
||||||
|
import logging
|
||||||
|
|
||||||
for db_name, db_conf in (self.config or {}).get("databases", {}).items():
|
for db_name, db_conf in (self.config or {}).get("databases", {}).items():
|
||||||
for table_name, table_conf in db_conf.get("tables", {}).items():
|
for table_name, table_conf in db_conf.get("tables", {}).items():
|
||||||
for col_name, ct in table_conf.get("column_types", {}).items():
|
for col_name, ct in table_conf.get("column_types", {}).items():
|
||||||
|
|
@ -1337,7 +1361,7 @@ class Datasette:
|
||||||
col_type = ct["type"]
|
col_type = ct["type"]
|
||||||
config = ct.get("config")
|
config = ct.get("config")
|
||||||
if col_type not in self._column_types:
|
if col_type not in self._column_types:
|
||||||
logger.warning(
|
logging.warning(
|
||||||
"column_types config references unknown type %r "
|
"column_types config references unknown type %r "
|
||||||
"for %s.%s.%s",
|
"for %s.%s.%s",
|
||||||
col_type,
|
col_type,
|
||||||
|
|
@ -1350,7 +1374,7 @@ class Datasette:
|
||||||
db_name, table_name, col_name, col_type, config
|
db_name, table_name, col_name, col_type, config
|
||||||
)
|
)
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
logger.warning(str(ex))
|
logging.warning(str(ex))
|
||||||
|
|
||||||
async def get_column_type(self, database: str, resource: str, column: str):
|
async def get_column_type(self, database: str, resource: str, column: str):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1403,7 +1427,7 @@ class Datasette:
|
||||||
resource: str,
|
resource: str,
|
||||||
column: str,
|
column: str,
|
||||||
column_type: str,
|
column_type: str,
|
||||||
config: dict | None = None,
|
config: dict = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Assign a column type. Overwrites any existing assignment."""
|
"""Assign a column type. Overwrites any existing assignment."""
|
||||||
ct_cls = self._column_types.get(column_type)
|
ct_cls = self._column_types.get(column_type)
|
||||||
|
|
@ -1487,7 +1511,9 @@ class Datasette:
|
||||||
possible_names = {plugin["name"], plugin["name"].replace("-", "_")}
|
possible_names = {plugin["name"], plugin["name"].replace("-", "_")}
|
||||||
if plugin_name in possible_names:
|
if plugin_name in possible_names:
|
||||||
return _resolve_static_asset_path(plugin["static_path"], path)
|
return _resolve_static_asset_path(plugin["static_path"], path)
|
||||||
raise FileNotFoundError(f"No static assets found for plugin {plugin_name}")
|
raise FileNotFoundError(
|
||||||
|
"No static assets found for plugin {}".format(plugin_name)
|
||||||
|
)
|
||||||
|
|
||||||
def _static_mounted_asset(self, mount_name, path):
|
def _static_mounted_asset(self, mount_name, path):
|
||||||
mount_name = mount_name.strip("/")
|
mount_name = mount_name.strip("/")
|
||||||
|
|
@ -1497,7 +1523,7 @@ class Datasette:
|
||||||
_resolve_static_asset_path(dirname, path),
|
_resolve_static_asset_path(dirname, path),
|
||||||
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))),
|
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))),
|
||||||
)
|
)
|
||||||
raise FileNotFoundError(f"No static mount found for {mount_name}")
|
raise FileNotFoundError("No static mount found for {}".format(mount_name))
|
||||||
|
|
||||||
def _static_asset_hash(self, filepath):
|
def _static_asset_hash(self, filepath):
|
||||||
filepath = Path(filepath)
|
filepath = Path(filepath)
|
||||||
|
|
@ -1588,17 +1614,18 @@ class Datasette:
|
||||||
if await self.allowed(action="view-instance", actor=actor):
|
if await self.allowed(action="view-instance", actor=actor):
|
||||||
crumbs.append({"href": self.urls.instance(), "label": "home"})
|
crumbs.append({"href": self.urls.instance(), "label": "home"})
|
||||||
# Database link
|
# Database link
|
||||||
if database and await self.allowed(
|
if database:
|
||||||
action="view-database",
|
if await self.allowed(
|
||||||
resource=DatabaseResource(database=database),
|
action="view-database",
|
||||||
actor=actor,
|
resource=DatabaseResource(database=database),
|
||||||
):
|
actor=actor,
|
||||||
crumbs.append(
|
):
|
||||||
{
|
crumbs.append(
|
||||||
"href": self.urls.database(database),
|
{
|
||||||
"label": database,
|
"href": self.urls.database(database),
|
||||||
}
|
"label": database,
|
||||||
)
|
}
|
||||||
|
)
|
||||||
# Table link
|
# Table link
|
||||||
if table:
|
if table:
|
||||||
assert database, "table= requires database="
|
assert database, "table= requires database="
|
||||||
|
|
@ -1617,7 +1644,7 @@ class Datasette:
|
||||||
|
|
||||||
async def actors_from_ids(
|
async def actors_from_ids(
|
||||||
self, actor_ids: Iterable[str | int]
|
self, actor_ids: Iterable[str | int]
|
||||||
) -> dict[int | str, dict]:
|
) -> Dict[int | str, Dict]:
|
||||||
result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids)
|
result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids)
|
||||||
if result is None:
|
if result is None:
|
||||||
# Do the default thing
|
# Do the default thing
|
||||||
|
|
@ -1626,9 +1653,9 @@ class Datasette:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def track_event(self, event: Event):
|
async def track_event(self, event: Event):
|
||||||
assert isinstance(
|
assert isinstance(event, self.event_classes), "Invalid event type: {}".format(
|
||||||
event, self.event_classes
|
type(event)
|
||||||
), f"Invalid event type: {type(event)}"
|
)
|
||||||
for hook in pm.hook.track_event(datasette=self, event=event):
|
for hook in pm.hook.track_event(datasette=self, event=event):
|
||||||
await await_me_maybe(hook)
|
await await_me_maybe(hook)
|
||||||
|
|
||||||
|
|
@ -1665,7 +1692,7 @@ class Datasette:
|
||||||
self,
|
self,
|
||||||
actor: dict,
|
actor: dict,
|
||||||
action: str,
|
action: str,
|
||||||
resource: Resource | None = None,
|
resource: "Resource" | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Check if actor can see a resource and if it's private.
|
Check if actor can see a resource and if it's private.
|
||||||
|
|
@ -1864,7 +1891,10 @@ class Datasette:
|
||||||
if truncated and resources:
|
if truncated and resources:
|
||||||
last_resource = resources[-1]
|
last_resource = resources[-1]
|
||||||
# Use tilde-encoding like table pagination
|
# Use tilde-encoding like table pagination
|
||||||
next_token = f"{tilde_encode(str(last_resource.parent))},{tilde_encode(str(last_resource.child))}"
|
next_token = "{},{}".format(
|
||||||
|
tilde_encode(str(last_resource.parent)),
|
||||||
|
tilde_encode(str(last_resource.child)),
|
||||||
|
)
|
||||||
|
|
||||||
return PaginatedResources(
|
return PaginatedResources(
|
||||||
resources=resources,
|
resources=resources,
|
||||||
|
|
@ -1882,7 +1912,7 @@ class Datasette:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
action: str,
|
action: str,
|
||||||
resource: Resource = None,
|
resource: "Resource" = None,
|
||||||
actor: dict | None = None,
|
actor: dict | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1913,7 +1943,7 @@ class Datasette:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
actions: Sequence[str],
|
actions: Sequence[str],
|
||||||
resource: Resource = None,
|
resource: "Resource" = None,
|
||||||
actor: dict | None = None,
|
actor: dict | None = None,
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1934,11 +1964,11 @@ class Datasette:
|
||||||
)
|
)
|
||||||
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
||||||
"""
|
"""
|
||||||
|
from datasette.utils.actions_sql import check_permissions_for_actions
|
||||||
from datasette.permissions import (
|
from datasette.permissions import (
|
||||||
_permission_check_cache,
|
_permission_check_cache,
|
||||||
_skip_permission_checks,
|
_skip_permission_checks,
|
||||||
)
|
)
|
||||||
from datasette.utils.actions_sql import check_permissions_for_actions
|
|
||||||
|
|
||||||
# For global actions, resource is None
|
# For global actions, resource is None
|
||||||
parent = resource.parent if resource else None
|
parent = resource.parent if resource else None
|
||||||
|
|
@ -2027,7 +2057,7 @@ class Datasette:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
action: str,
|
action: str,
|
||||||
resource: Resource = None,
|
resource: "Resource" = None,
|
||||||
actor: dict | None = None,
|
actor: dict | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -2081,15 +2111,13 @@ class Datasette:
|
||||||
db = self.databases[database]
|
db = self.databases[database]
|
||||||
foreign_keys = await db.foreign_keys_for_table(table)
|
foreign_keys = await db.foreign_keys_for_table(table)
|
||||||
# Find the foreign_key for this column
|
# Find the foreign_key for this column
|
||||||
fk = next(
|
try:
|
||||||
(
|
fk = [
|
||||||
foreign_key
|
foreign_key
|
||||||
for foreign_key in foreign_keys
|
for foreign_key in foreign_keys
|
||||||
if foreign_key["column"] == column
|
if foreign_key["column"] == column
|
||||||
),
|
][0]
|
||||||
None,
|
except IndexError:
|
||||||
)
|
|
||||||
if fk is None:
|
|
||||||
return {}
|
return {}
|
||||||
# Ensure user has permission to view the referenced table
|
# Ensure user has permission to view the referenced table
|
||||||
from datasette.resources import TableResource
|
from datasette.resources import TableResource
|
||||||
|
|
@ -2177,17 +2205,16 @@ class Datasette:
|
||||||
sqlite_extensions[extension] = result.fetchone()[0]
|
sqlite_extensions[extension] = result.fetchone()[0]
|
||||||
else:
|
else:
|
||||||
sqlite_extensions[extension] = None
|
sqlite_extensions[extension] = None
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Probing for optional SQLite extensions - absence is the normal case
|
|
||||||
pass
|
pass
|
||||||
# More details on SpatiaLite
|
# More details on SpatiaLite
|
||||||
if "spatialite" in sqlite_extensions:
|
if "spatialite" in sqlite_extensions:
|
||||||
spatialite_details = {}
|
spatialite_details = {}
|
||||||
for fn in SPATIALITE_FUNCTIONS:
|
for fn in SPATIALITE_FUNCTIONS:
|
||||||
try:
|
try:
|
||||||
result = conn.execute(f"select {fn}()")
|
result = conn.execute("select {}()".format(fn))
|
||||||
spatialite_details[fn] = result.fetchone()[0]
|
spatialite_details[fn] = result.fetchone()[0]
|
||||||
except sqlite3.Error as e:
|
except Exception as e:
|
||||||
spatialite_details[fn] = {"error": str(e)}
|
spatialite_details[fn] = {"error": str(e)}
|
||||||
sqlite_extensions["spatialite"] = spatialite_details
|
sqlite_extensions["spatialite"] = spatialite_details
|
||||||
|
|
||||||
|
|
@ -2195,7 +2222,9 @@ class Datasette:
|
||||||
fts_versions = []
|
fts_versions = []
|
||||||
for fts in ("FTS5", "FTS4", "FTS3"):
|
for fts in ("FTS5", "FTS4", "FTS3"):
|
||||||
try:
|
try:
|
||||||
conn.execute(f"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)")
|
conn.execute(
|
||||||
|
"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(fts=fts)
|
||||||
|
)
|
||||||
fts_versions.append(fts)
|
fts_versions.append(fts)
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
continue
|
continue
|
||||||
|
|
@ -2254,7 +2283,7 @@ class Datasette:
|
||||||
"static": p["static_path"] is not None,
|
"static": p["static_path"] is not None,
|
||||||
"templates": p["templates_path"] is not None,
|
"templates": p["templates_path"] is not None,
|
||||||
"version": p.get("version"),
|
"version": p.get("version"),
|
||||||
"hooks": sorted(set(p["hooks"])),
|
"hooks": list(sorted(set(p["hooks"]))),
|
||||||
}
|
}
|
||||||
for p in ps
|
for p in ps
|
||||||
]
|
]
|
||||||
|
|
@ -2330,15 +2359,13 @@ class Datasette:
|
||||||
|
|
||||||
async def render_template(
|
async def render_template(
|
||||||
self,
|
self,
|
||||||
templates: list[str] | str | Template,
|
templates: List[str] | str | Template,
|
||||||
context: dict[str, Any] | Context | None = None,
|
context: Dict[str, Any] | Context | None = None,
|
||||||
request: Request | None = None,
|
request: Request | None = None,
|
||||||
view_name: str | None = None,
|
view_name: str | None = None,
|
||||||
):
|
):
|
||||||
if not self._startup_invoked:
|
if not self._startup_invoked:
|
||||||
raise RuntimeError(
|
raise Exception("render_template() called before await ds.invoke_startup()")
|
||||||
"render_template() called before await ds.invoke_startup()"
|
|
||||||
)
|
|
||||||
context = context or {}
|
context = context or {}
|
||||||
if isinstance(templates, Template):
|
if isinstance(templates, Template):
|
||||||
template = templates
|
template = templates
|
||||||
|
|
@ -2384,9 +2411,9 @@ class Datasette:
|
||||||
datasette=self,
|
datasette=self,
|
||||||
):
|
):
|
||||||
extra_vars = await await_me_maybe(extra_vars)
|
extra_vars = await await_me_maybe(extra_vars)
|
||||||
assert isinstance(
|
assert isinstance(extra_vars, dict), "extra_vars is of type {}".format(
|
||||||
extra_vars, dict
|
type(extra_vars)
|
||||||
), f"extra_vars is of type {type(extra_vars)}"
|
)
|
||||||
extra_template_vars.update(extra_vars)
|
extra_template_vars.update(extra_vars)
|
||||||
|
|
||||||
async def menu_links():
|
async def menu_links():
|
||||||
|
|
@ -2405,27 +2432,29 @@ class Datasette:
|
||||||
# the contract tests fail otherwise
|
# the contract tests fail otherwise
|
||||||
template_context = {
|
template_context = {
|
||||||
**context,
|
**context,
|
||||||
"request": request,
|
**{
|
||||||
"crumb_items": self._crumb_items,
|
"request": request,
|
||||||
"urls": self.urls,
|
"crumb_items": self._crumb_items,
|
||||||
"actor": request.actor if request else None,
|
"urls": self.urls,
|
||||||
"menu_links": menu_links,
|
"actor": request.actor if request else None,
|
||||||
"display_actor": display_actor,
|
"menu_links": menu_links,
|
||||||
"show_logout": request is not None
|
"display_actor": display_actor,
|
||||||
and "ds_actor" in request.cookies
|
"show_logout": request is not None
|
||||||
and request.actor,
|
and "ds_actor" in request.cookies
|
||||||
"zip": zip,
|
and request.actor,
|
||||||
"body_scripts": body_scripts,
|
"zip": zip,
|
||||||
"format_bytes": format_bytes,
|
"body_scripts": body_scripts,
|
||||||
"show_messages": lambda: self._show_messages(request),
|
"format_bytes": format_bytes,
|
||||||
"extra_css_urls": await self._asset_urls(
|
"show_messages": lambda: self._show_messages(request),
|
||||||
"extra_css_urls", template, context, request, view_name
|
"extra_css_urls": await self._asset_urls(
|
||||||
),
|
"extra_css_urls", template, context, request, view_name
|
||||||
"extra_js_urls": await self._asset_urls(
|
),
|
||||||
"extra_js_urls", template, context, request, view_name
|
"extra_js_urls": await self._asset_urls(
|
||||||
),
|
"extra_js_urls", template, context, request, view_name
|
||||||
"base_url": self.setting("base_url"),
|
),
|
||||||
"datasette_version": __version__,
|
"base_url": self.setting("base_url"),
|
||||||
|
"datasette_version": __version__,
|
||||||
|
},
|
||||||
**extra_template_vars,
|
**extra_template_vars,
|
||||||
}
|
}
|
||||||
if request and request.args.get("_context") and self.setting("template_debug"):
|
if request and request.args.get("_context") and self.setting("template_debug"):
|
||||||
|
|
@ -2547,7 +2576,7 @@ class Datasette:
|
||||||
JsonDataView.as_view(
|
JsonDataView.as_view(
|
||||||
self,
|
self,
|
||||||
"plugins.json",
|
"plugins.json",
|
||||||
self._plugins,
|
lambda request: {"plugins": self._plugins(request)},
|
||||||
needs_request=True,
|
needs_request=True,
|
||||||
),
|
),
|
||||||
r"/-/plugins(\.(?P<format>json))?$",
|
r"/-/plugins(\.(?P<format>json))?$",
|
||||||
|
|
@ -2688,6 +2717,10 @@ class Datasette:
|
||||||
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(
|
||||||
|
DatabaseEditorSchemaView.as_view(self),
|
||||||
|
r"/(?P<database>[^\/\.]+)/-/editor-schema\.json$",
|
||||||
|
)
|
||||||
add_route(
|
add_route(
|
||||||
QueryParametersView.as_view(self),
|
QueryParametersView.as_view(self),
|
||||||
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
|
r"/(?P<database>[^\/\.]+)/-/query/parameters$",
|
||||||
|
|
@ -2925,8 +2958,7 @@ class DatasetteRouter:
|
||||||
custom_response
|
custom_response
|
||||||
), "Default forbidden() hook should have been called"
|
), "Default forbidden() hook should have been called"
|
||||||
return await custom_response.asgi_send(send)
|
return await custom_response.asgi_send(send)
|
||||||
except Exception as exception: # noqa: BLE001
|
except Exception as exception:
|
||||||
# This IS the top-level error handler - it must catch everything
|
|
||||||
return await self.handle_exception(request, send, exception)
|
return await self.handle_exception(request, send, exception)
|
||||||
|
|
||||||
async def handle_401(self, request, send, exception):
|
async def handle_401(self, request, send, exception):
|
||||||
|
|
@ -2948,7 +2980,7 @@ class DatasetteRouter:
|
||||||
request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E")
|
request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E")
|
||||||
)
|
)
|
||||||
if request.query_string:
|
if request.query_string:
|
||||||
new_path += f"?{request.query_string}"
|
new_path += "?{}".format(request.query_string)
|
||||||
await asgi_send_redirect(send, new_path)
|
await asgi_send_redirect(send, new_path)
|
||||||
return
|
return
|
||||||
# If URL has a trailing slash, redirect to URL without it
|
# If URL has a trailing slash, redirect to URL without it
|
||||||
|
|
@ -3158,7 +3190,8 @@ _curly_re = re.compile(r"({.*?})")
|
||||||
|
|
||||||
def route_pattern_from_filepath(filepath):
|
def route_pattern_from_filepath(filepath):
|
||||||
# Drop the ".html" suffix
|
# Drop the ".html" suffix
|
||||||
filepath = filepath.removesuffix(".html")
|
if filepath.endswith(".html"):
|
||||||
|
filepath = filepath[: -len(".html")]
|
||||||
re_bits = ["/"]
|
re_bits = ["/"]
|
||||||
for bit in _curly_re.split(filepath):
|
for bit in _curly_re.split(filepath):
|
||||||
if _curly_re.match(bit):
|
if _curly_re.match(bit):
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
|
from datasette.utils.asgi import Response, BadRequest
|
||||||
from datasette.utils import to_css_class
|
from datasette.utils import to_css_class
|
||||||
from datasette.utils.asgi import BadRequest, Response
|
import hashlib
|
||||||
|
|
||||||
_BLOB_COLUMN = "_blob_column"
|
_BLOB_COLUMN = "_blob_column"
|
||||||
_BLOB_HASH = "_blob_hash"
|
_BLOB_HASH = "_blob_hash"
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,43 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import uvicorn
|
||||||
|
import click
|
||||||
|
from click import formatting
|
||||||
|
from click.types import CompositeParamType
|
||||||
|
from click_default_group import DefaultGroup
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from runpy import run_module
|
||||||
import shutil
|
import shutil
|
||||||
|
from subprocess import call
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from runpy import run_module
|
|
||||||
from subprocess import call
|
|
||||||
|
|
||||||
import click
|
|
||||||
import uvicorn
|
|
||||||
from click import formatting
|
|
||||||
from click.types import CompositeParamType
|
|
||||||
from click_default_group import DefaultGroup
|
|
||||||
|
|
||||||
from .app import (
|
from .app import (
|
||||||
|
Datasette,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SQLITE_LIMIT_ATTACHED,
|
SQLITE_LIMIT_ATTACHED,
|
||||||
Datasette,
|
|
||||||
pm,
|
pm,
|
||||||
)
|
)
|
||||||
from .inspect import inspect_tables
|
from .inspect import inspect_tables
|
||||||
from .utils import (
|
from .utils import (
|
||||||
ConnectionProblem,
|
|
||||||
LoadExtension,
|
LoadExtension,
|
||||||
SpatialiteConnectionProblem,
|
|
||||||
SpatialiteNotFound,
|
|
||||||
StartupError,
|
StartupError,
|
||||||
StaticMount,
|
|
||||||
ValueAsBooleanError,
|
|
||||||
check_connection,
|
check_connection,
|
||||||
deep_dict_update,
|
deep_dict_update,
|
||||||
find_spatialite,
|
find_spatialite,
|
||||||
|
parse_metadata,
|
||||||
|
ConnectionProblem,
|
||||||
|
SpatialiteConnectionProblem,
|
||||||
initial_path_for_datasette,
|
initial_path_for_datasette,
|
||||||
pairs_to_nested_config,
|
pairs_to_nested_config,
|
||||||
parse_metadata,
|
|
||||||
temporary_docker_directory,
|
temporary_docker_directory,
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
|
SpatialiteNotFound,
|
||||||
|
StaticMount,
|
||||||
|
ValueAsBooleanError,
|
||||||
)
|
)
|
||||||
from .utils.sqlite import sqlite3
|
from .utils.sqlite import sqlite3
|
||||||
from .utils.testing import TestClient
|
from .utils.testing import TestClient
|
||||||
|
|
@ -77,7 +75,7 @@ class Setting(CompositeParamType):
|
||||||
# Datasette 1.0, we turn bare setting names into setting.name
|
# Datasette 1.0, we turn bare setting names into setting.name
|
||||||
# Type checking for those older settings
|
# Type checking for those older settings
|
||||||
default = DEFAULT_SETTINGS[name]
|
default = DEFAULT_SETTINGS[name]
|
||||||
name = f"settings.{name}"
|
name = "settings.{}".format(name)
|
||||||
if isinstance(default, bool):
|
if isinstance(default, bool):
|
||||||
try:
|
try:
|
||||||
return name, "true" if value_as_boolean(value) else "false"
|
return name, "true" if value_as_boolean(value) else "false"
|
||||||
|
|
@ -173,6 +171,7 @@ async def inspect_(files, sqlite_extensions):
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def publish():
|
def publish():
|
||||||
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Register publish plugins
|
# Register publish plugins
|
||||||
|
|
@ -579,27 +578,27 @@ def serve(
|
||||||
# https://github.com/simonw/datasette/issues/2389
|
# https://github.com/simonw/datasette/issues/2389
|
||||||
deep_dict_update(config_data, settings_updates)
|
deep_dict_update(config_data, settings_updates)
|
||||||
|
|
||||||
kwargs = {
|
kwargs = dict(
|
||||||
"immutables": immutable,
|
immutables=immutable,
|
||||||
"cache_headers": not reload,
|
cache_headers=not reload,
|
||||||
"cors": cors,
|
cors=cors,
|
||||||
"inspect_data": inspect_data,
|
inspect_data=inspect_data,
|
||||||
"config": config_data,
|
config=config_data,
|
||||||
"metadata": metadata_data,
|
metadata=metadata_data,
|
||||||
"sqlite_extensions": sqlite_extensions,
|
sqlite_extensions=sqlite_extensions,
|
||||||
"template_dir": template_dir,
|
template_dir=template_dir,
|
||||||
"plugins_dir": plugins_dir,
|
plugins_dir=plugins_dir,
|
||||||
"static_mounts": static,
|
static_mounts=static,
|
||||||
"settings": None, # These are passed in config= now
|
settings=None, # These are passed in config= now
|
||||||
"memory": memory,
|
memory=memory,
|
||||||
"secret": secret,
|
secret=secret,
|
||||||
"version_note": version_note,
|
version_note=version_note,
|
||||||
"pdb": pdb,
|
pdb=pdb,
|
||||||
"crossdb": crossdb,
|
crossdb=crossdb,
|
||||||
"nolock": nolock,
|
nolock=nolock,
|
||||||
"internal": internal,
|
internal=internal,
|
||||||
"default_deny": default_deny,
|
default_deny=default_deny,
|
||||||
}
|
)
|
||||||
|
|
||||||
# Separate directories from files
|
# Separate directories from files
|
||||||
directories = [f for f in files if os.path.isdir(f)]
|
directories = [f for f in files if os.path.isdir(f)]
|
||||||
|
|
@ -622,7 +621,9 @@ def serve(
|
||||||
conn.close()
|
conn.close()
|
||||||
else:
|
else:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
f"Invalid value for '[FILES]...': Path '{file}' does not exist."
|
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
|
||||||
|
file
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicate files by resolving all paths to their absolute forms
|
# Check for duplicate files by resolving all paths to their absolute forms
|
||||||
|
|
@ -683,7 +684,7 @@ def serve(
|
||||||
client = TestClient(ds)
|
client = TestClient(ds)
|
||||||
request_headers = {}
|
request_headers = {}
|
||||||
if token:
|
if token:
|
||||||
request_headers["Authorization"] = f"Bearer {token}"
|
request_headers["Authorization"] = "Bearer {}".format(token)
|
||||||
cookies = {}
|
cookies = {}
|
||||||
if actor:
|
if actor:
|
||||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||||
|
|
@ -718,13 +719,9 @@ def serve(
|
||||||
path = run_sync(lambda: initial_path_for_datasette(ds))
|
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||||
url = f"http://{host}:{port}{path}"
|
url = f"http://{host}:{port}{path}"
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
uvicorn_kwargs = {
|
uvicorn_kwargs = dict(
|
||||||
"host": host,
|
host=host, port=port, log_level="info", lifespan="on", workers=1
|
||||||
"port": port,
|
)
|
||||||
"log_level": "info",
|
|
||||||
"lifespan": "on",
|
|
||||||
"workers": 1,
|
|
||||||
}
|
|
||||||
if uds:
|
if uds:
|
||||||
uvicorn_kwargs["uds"] = uds
|
uvicorn_kwargs["uds"] = uds
|
||||||
if ssl_keyfile:
|
if ssl_keyfile:
|
||||||
|
|
@ -888,7 +885,7 @@ async def check_databases(ds):
|
||||||
)
|
)
|
||||||
except ConnectionProblem as e:
|
except ConnectionProblem as e:
|
||||||
raise click.UsageError(
|
raise click.UsageError(
|
||||||
f"Connection to {database.path} failed check: {e.args[0]!s}"
|
f"Connection to {database.path} failed check: {str(e.args[0])}"
|
||||||
)
|
)
|
||||||
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
|
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
|
||||||
if (
|
if (
|
||||||
|
|
@ -896,5 +893,9 @@ async def check_databases(ds):
|
||||||
and len([db for db in ds.databases.values() if not db.is_memory])
|
and len([db for db in ds.databases.values() if not db.is_memory])
|
||||||
> SQLITE_LIMIT_ATTACHED
|
> SQLITE_LIMIT_ATTACHED
|
||||||
):
|
):
|
||||||
msg = f"Warning: --crossdb only works with the first {SQLITE_LIMIT_ATTACHED} attached databases"
|
msg = (
|
||||||
|
"Warning: --crossdb only works with the first {} attached databases".format(
|
||||||
|
SQLITE_LIMIT_ATTACHED
|
||||||
|
)
|
||||||
|
)
|
||||||
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)
|
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)
|
||||||
|
|
|
||||||
|
|
@ -64,14 +64,14 @@ class ColumnType:
|
||||||
Return an HTML string to render this cell value, or None to
|
Return an HTML string to render this cell value, or None to
|
||||||
fall through to the default render_cell plugin hook chain.
|
fall through to the default render_cell plugin hook chain.
|
||||||
"""
|
"""
|
||||||
return
|
return None
|
||||||
|
|
||||||
async def validate(self, value, datasette):
|
async def validate(self, value, datasette):
|
||||||
"""
|
"""
|
||||||
Validate a value before it is written. Return None if valid,
|
Validate a value before it is written. Return None if valid,
|
||||||
or a string error message if invalid.
|
or a string error message if invalid.
|
||||||
"""
|
"""
|
||||||
return
|
return None
|
||||||
|
|
||||||
async def transform_value(self, value, datasette):
|
async def transform_value(self, value, datasette):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@ def _origin_tuple(value):
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
if not scheme or not host:
|
if not scheme or not host:
|
||||||
raise ValueError(f"missing scheme or host in {value!r}")
|
raise ValueError("missing scheme or host in {!r}".format(value))
|
||||||
port = parsed.port # may raise ValueError on bad ports
|
port = parsed.port # may raise ValueError on bad ports
|
||||||
if port is None:
|
if port is None:
|
||||||
port = DEFAULT_PORTS.get(scheme)
|
port = DEFAULT_PORTS.get(scheme)
|
||||||
if port is None:
|
if port is None:
|
||||||
raise ValueError(f"unknown default port for scheme {scheme!r}")
|
raise ValueError("unknown default port for scheme {!r}".format(scheme))
|
||||||
return scheme, host, port
|
return scheme, host, port
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -125,7 +125,9 @@ class CrossOriginProtectionMiddleware:
|
||||||
return
|
return
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
send,
|
||||||
f"Sec-Fetch-Site was {sec_fetch_site!r}, expected 'same-origin' or 'none'",
|
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
|
||||||
|
sec_fetch_site
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -139,11 +141,11 @@ class CrossOriginProtectionMiddleware:
|
||||||
request_scheme = self._request_scheme(scope)
|
request_scheme = self._request_scheme(scope)
|
||||||
try:
|
try:
|
||||||
origin_tuple = _origin_tuple(origin)
|
origin_tuple = _origin_tuple(origin)
|
||||||
expected_tuple = _origin_tuple(f"{request_scheme}://{host}")
|
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
send,
|
||||||
f"Malformed Origin {origin!r} or Host {host!r}",
|
"Malformed Origin {!r} or Host {!r}".format(origin, host),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -153,7 +155,7 @@ class CrossOriginProtectionMiddleware:
|
||||||
|
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
send,
|
||||||
f"Origin {origin!r} does not match Host {host!r}",
|
"Origin {!r} does not match Host {!r}".format(origin, host),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request_scheme(self, scope):
|
def _request_scheme(self, scope):
|
||||||
|
|
@ -161,8 +163,7 @@ class CrossOriginProtectionMiddleware:
|
||||||
try:
|
try:
|
||||||
if self.datasette.setting("force_https_urls"):
|
if self.datasette.setting("force_https_urls"):
|
||||||
return "https"
|
return "https"
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Settings may not be readable this early; fall back to the ASGI scheme
|
|
||||||
pass
|
pass
|
||||||
return scope.get("scheme") or "http"
|
return scope.get("scheme") or "http"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,33 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
|
from collections import namedtuple
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import queue
|
import queue
|
||||||
|
import sqlite_utils
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from collections import namedtuple
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import sqlite_utils
|
|
||||||
|
|
||||||
from .inspect import inspect_hash
|
|
||||||
from .tracer import trace
|
from .tracer import trace
|
||||||
from .utils import (
|
from .utils import (
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
detect_fts,
|
detect_fts,
|
||||||
detect_primary_keys,
|
detect_primary_keys,
|
||||||
detect_spatialite,
|
detect_spatialite,
|
||||||
escape_sqlite,
|
|
||||||
get_all_foreign_keys,
|
get_all_foreign_keys,
|
||||||
get_outbound_foreign_keys,
|
get_outbound_foreign_keys,
|
||||||
md5_not_usedforsecurity,
|
md5_not_usedforsecurity,
|
||||||
sqlite3,
|
|
||||||
sqlite_timelimit,
|
sqlite_timelimit,
|
||||||
table_column_details,
|
sqlite3,
|
||||||
table_columns,
|
table_columns,
|
||||||
|
table_column_details,
|
||||||
)
|
)
|
||||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||||
from .utils.sqlite import sqlite_hidden_table_names
|
from .utils.sqlite import sqlite_hidden_table_names
|
||||||
|
from .inspect import inspect_hash
|
||||||
|
|
||||||
connections = threading.local()
|
connections = threading.local()
|
||||||
|
|
||||||
|
|
@ -100,7 +98,9 @@ class Database:
|
||||||
|
|
||||||
def _check_not_closed(self):
|
def _check_not_closed(self):
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise DatasetteClosedError(f"Database {self.name!r} has been closed")
|
raise DatasetteClosedError(
|
||||||
|
"Database {!r} has been closed".format(self.name)
|
||||||
|
)
|
||||||
|
|
||||||
def _remove_pending_execute_future(self, future):
|
def _remove_pending_execute_future(self, future):
|
||||||
with self._pending_execute_futures_lock:
|
with self._pending_execute_futures_lock:
|
||||||
|
|
@ -139,7 +139,7 @@ class Database:
|
||||||
if write:
|
if write:
|
||||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||||
if self.memory_name:
|
if self.memory_name:
|
||||||
uri = f"file:{self.memory_name}?mode=memory&cache=shared"
|
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||||
)
|
)
|
||||||
|
|
@ -192,20 +192,21 @@ class Database:
|
||||||
write_thread.join(timeout=10)
|
write_thread.join(timeout=10)
|
||||||
if write_thread.is_alive():
|
if write_thread.is_alive():
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"Datasette: write thread for {self.name!r} did not exit within 10s\n"
|
"Datasette: write thread for {!r} did not exit within 10s\n".format(
|
||||||
|
self.name
|
||||||
|
)
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
for future in pending_execute_futures:
|
for future in pending_execute_futures:
|
||||||
try:
|
try:
|
||||||
future.result()
|
future.result()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Shutdown teardown - a failed pending write must not block close()
|
|
||||||
pass
|
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:
|
||||||
connection.close()
|
connection.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._all_file_connections = []
|
self._all_file_connections = []
|
||||||
# Drop per-thread cached read connections we can reach
|
# Drop per-thread cached read connections we can reach
|
||||||
|
|
@ -217,13 +218,13 @@ class Database:
|
||||||
if self._read_connection is not None:
|
if self._read_connection is not None:
|
||||||
try:
|
try:
|
||||||
self._read_connection.close()
|
self._read_connection.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._read_connection = None
|
self._read_connection = None
|
||||||
if self._write_connection is not None:
|
if self._write_connection is not None:
|
||||||
try:
|
try:
|
||||||
self._write_connection.close()
|
self._write_connection.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._write_connection = None
|
self._write_connection = None
|
||||||
if self.is_temp_disk:
|
if self.is_temp_disk:
|
||||||
|
|
@ -245,7 +246,6 @@ class Database:
|
||||||
request=None,
|
request=None,
|
||||||
return_all=False,
|
return_all=False,
|
||||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||||
transaction=True,
|
|
||||||
):
|
):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
if returning_limit < 0:
|
if returning_limit < 0:
|
||||||
|
|
@ -258,9 +258,7 @@ class Database:
|
||||||
)
|
)
|
||||||
|
|
||||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||||
results = await self.execute_write_fn(
|
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||||
_inner, block=block, request=request, transaction=transaction
|
|
||||||
)
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def execute_write_script(self, sql, block=True, request=None):
|
async def execute_write_script(self, sql, block=True, request=None):
|
||||||
|
|
@ -350,7 +348,6 @@ class Database:
|
||||||
self.ds._prepare_connection(self._write_connection, self.name)
|
self.ds._prepare_connection(self._write_connection, self.name)
|
||||||
if transaction:
|
if transaction:
|
||||||
with self._write_connection:
|
with self._write_connection:
|
||||||
self._write_connection.execute("BEGIN IMMEDIATE")
|
|
||||||
result = fn(self._write_connection)
|
result = fn(self._write_connection)
|
||||||
else:
|
else:
|
||||||
result = fn(self._write_connection)
|
result = fn(self._write_connection)
|
||||||
|
|
@ -369,8 +366,7 @@ class Database:
|
||||||
async def _dispatch_events_after_write():
|
async def _dispatch_events_after_write():
|
||||||
try:
|
try:
|
||||||
await reply_future
|
await reply_future
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
# The write failed; skip success events regardless of why
|
|
||||||
# if the write failed, don't emit success events
|
# if the write failed, don't emit success events
|
||||||
return
|
return
|
||||||
for event in pending_events:
|
for event in pending_events:
|
||||||
|
|
@ -423,7 +419,9 @@ class Database:
|
||||||
self._write_thread = threading.Thread(
|
self._write_thread = threading.Thread(
|
||||||
target=self._execute_writes, daemon=True
|
target=self._execute_writes, daemon=True
|
||||||
)
|
)
|
||||||
self._write_thread.name = f"_execute_writes for database {self.name}"
|
self._write_thread.name = "_execute_writes for database {}".format(
|
||||||
|
self.name
|
||||||
|
)
|
||||||
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()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
@ -444,8 +442,7 @@ class Database:
|
||||||
try:
|
try:
|
||||||
conn = self.connect(write=True)
|
conn = self.connect(write=True)
|
||||||
self.ds._prepare_connection(conn, self.name)
|
self.ds._prepare_connection(conn, self.name)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# Stored and re-raised to whoever queues the next write
|
|
||||||
conn_exception = e
|
conn_exception = e
|
||||||
while True:
|
while True:
|
||||||
task = self._write_queue.get()
|
task = self._write_queue.get()
|
||||||
|
|
@ -453,8 +450,7 @@ class Database:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Best-effort close as the write thread exits
|
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
exception = None
|
exception = None
|
||||||
|
|
@ -473,21 +469,19 @@ class Database:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Was probably a memory connection
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# Write thread must survive any task failure or the database wedges
|
sys.stderr.write("{}\n".format(e))
|
||||||
sys.stderr.write(f"{e}\n")
|
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
exception = e
|
exception = e
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
if task.transaction:
|
if task.transaction:
|
||||||
with conn:
|
with conn:
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
|
||||||
result = task.fn(conn)
|
result = task.fn(conn)
|
||||||
else:
|
else:
|
||||||
result = task.fn(conn)
|
result = task.fn(conn)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
sys.stderr.write(f"{e}\n")
|
sys.stderr.write("{}\n".format(e))
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
exception = e
|
exception = e
|
||||||
_deliver_write_result(task, result, exception)
|
_deliver_write_result(task, result, exception)
|
||||||
|
|
@ -554,7 +548,9 @@ class Database:
|
||||||
raise QueryInterrupted(e, sql, params)
|
raise QueryInterrupted(e, sql, params)
|
||||||
if log_sql_errors:
|
if log_sql_errors:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n"
|
"ERROR: conn={}, sql = {}, params = {}: {}\n".format(
|
||||||
|
conn, repr(sql), params, e
|
||||||
|
)
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
raise
|
raise
|
||||||
|
|
@ -607,7 +603,7 @@ class Database:
|
||||||
try:
|
try:
|
||||||
table_count = (
|
table_count = (
|
||||||
await self.execute(
|
await self.execute(
|
||||||
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
|
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
|
||||||
custom_time_limit=limit,
|
custom_time_limit=limit,
|
||||||
)
|
)
|
||||||
).rows[0][0]
|
).rows[0][0]
|
||||||
|
|
@ -711,9 +707,9 @@ class Database:
|
||||||
column_names
|
column_names
|
||||||
and len(column_names) == 2
|
and len(column_names) == 2
|
||||||
and ("id" in column_names or "pk" in column_names)
|
and ("id" in column_names or "pk" in column_names)
|
||||||
and set(column_names) != {"id", "pk"}
|
and not set(column_names) == {"id", "pk"}
|
||||||
):
|
):
|
||||||
return next(c for c in column_names if c not in ("id", "pk"))
|
return [c for c in column_names if c not in ("id", "pk")][0]
|
||||||
# Couldn't find a label:
|
# Couldn't find a label:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -855,10 +851,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||||
class WriteTask:
|
class WriteTask:
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"fn",
|
"fn",
|
||||||
"isolated_connection",
|
"task_id",
|
||||||
"loop",
|
"loop",
|
||||||
"reply_future",
|
"reply_future",
|
||||||
"task_id",
|
"isolated_connection",
|
||||||
"transaction",
|
"transaction",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -899,7 +895,7 @@ class QueryInterrupted(Exception):
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"QueryInterrupted: {self.e}"
|
return "QueryInterrupted: {}".format(self.e)
|
||||||
|
|
||||||
|
|
||||||
class MultipleValues(Exception):
|
class MultipleValues(Exception):
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ from datasette import hookimpl
|
||||||
from datasette.permissions import Action
|
from datasette.permissions import Action
|
||||||
from datasette.resources import (
|
from datasette.resources import (
|
||||||
DatabaseResource,
|
DatabaseResource,
|
||||||
QueryResource,
|
|
||||||
TableResource,
|
TableResource,
|
||||||
|
QueryResource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
|
from datasette import hookimpl
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
|
|
||||||
def header(key, request):
|
def header(key, request):
|
||||||
key = key.replace("_", "-").encode("utf-8")
|
key = key.replace("_", "-").encode("utf-8")
|
||||||
|
|
|
||||||
|
|
@ -17,29 +17,18 @@ UNION/INTERSECT operations. The order of evaluation is:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .config import config_permissions_sql as config_permissions_sql
|
|
||||||
from .defaults import (
|
|
||||||
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
|
||||||
)
|
|
||||||
from .defaults import (
|
|
||||||
default_action_permissions_sql as default_action_permissions_sql,
|
|
||||||
)
|
|
||||||
from .defaults import (
|
|
||||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
|
||||||
default_allow_sql_check as default_allow_sql_check,
|
|
||||||
)
|
|
||||||
from .defaults import (
|
|
||||||
default_query_permissions_sql as default_query_permissions_sql,
|
|
||||||
)
|
|
||||||
from .restrictions import (
|
|
||||||
ActorRestrictions as ActorRestrictions,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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,
|
||||||
)
|
|
||||||
from .restrictions import (
|
|
||||||
restrictions_allow_action as restrictions_allow_action,
|
restrictions_allow_action as restrictions_allow_action,
|
||||||
|
ActorRestrictions as ActorRestrictions,
|
||||||
)
|
)
|
||||||
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 .defaults import (
|
||||||
|
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
||||||
|
default_allow_sql_check as default_allow_sql_check,
|
||||||
|
default_action_permissions_sql as default_action_permissions_sql,
|
||||||
|
default_query_permissions_sql as default_query_permissions_sql,
|
||||||
|
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
):
|
):
|
||||||
self.datasette = datasette
|
self.datasette = datasette
|
||||||
|
|
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
|
||||||
self.restrictions = actor.get("_r", {}) if actor else {}
|
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||||
|
|
||||||
# Pre-compute restriction info for efficiency
|
# Pre-compute restriction info for efficiency
|
||||||
self.restricted_databases: set[str] = set()
|
self.restricted_databases: Set[str] = set()
|
||||||
self.restricted_tables: set[tuple[str, str]] = set()
|
self.restricted_tables: Set[Tuple[str, str]] = set()
|
||||||
|
|
||||||
if self.has_restrictions:
|
if self.has_restrictions:
|
||||||
self.restricted_databases = {
|
self.restricted_databases = {
|
||||||
|
|
@ -92,20 +92,16 @@ class ConfigPermissionProcessor:
|
||||||
# Tables implicitly reference their parent databases
|
# Tables implicitly reference their parent databases
|
||||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
||||||
|
|
||||||
def evaluate_allow_block(self, allow_block: Any) -> bool | None:
|
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
|
||||||
"""Evaluate an allow block against the current actor."""
|
"""Evaluate an allow block against the current actor."""
|
||||||
if allow_block is None:
|
if allow_block is None:
|
||||||
return None
|
return None
|
||||||
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
|
|
||||||
# integers, but should retain the CLI's boolean 1/0 behavior.
|
|
||||||
if isinstance(allow_block, int) and allow_block in (0, 1):
|
|
||||||
return bool(allow_block)
|
|
||||||
return actor_matches_allow(self.actor, allow_block)
|
return actor_matches_allow(self.actor, allow_block)
|
||||||
|
|
||||||
def is_in_restriction_allowlist(
|
def is_in_restriction_allowlist(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if resource is allowed by actor restrictions."""
|
"""Check if resource is allowed by actor restrictions."""
|
||||||
if not self.has_restrictions:
|
if not self.has_restrictions:
|
||||||
|
|
@ -147,9 +143,9 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def add_permissions_rule(
|
def add_permissions_rule(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
permissions_block: dict | None,
|
permissions_block: Optional[dict],
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a rule from a permissions:{action} block."""
|
"""Add a rule from a permissions:{action} block."""
|
||||||
|
|
@ -169,8 +165,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def add_allow_block_rule(
|
def add_allow_block_rule(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
allow_block: Any,
|
allow_block: Any,
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -202,8 +198,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def _add_restriction_gate_denies(
|
def _add_restriction_gate_denies(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
is_allowed: bool,
|
is_allowed: bool,
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -235,7 +231,7 @@ class ConfigPermissionProcessor:
|
||||||
if db_name == parent:
|
if db_name == parent:
|
||||||
self.collector.add(db_name, table_name, False, reason)
|
self.collector.add(db_name, table_name, False, reason)
|
||||||
|
|
||||||
def process(self) -> PermissionSQL | None:
|
def process(self) -> Optional[PermissionSQL]:
|
||||||
"""Process all config rules and return combined PermissionSQL."""
|
"""Process all config rules and return combined PermissionSQL."""
|
||||||
self._process_root_permissions()
|
self._process_root_permissions()
|
||||||
self._process_databases()
|
self._process_databases()
|
||||||
|
|
@ -425,10 +421,10 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def config_permissions_sql(
|
async def config_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> list[PermissionSQL] | None:
|
) -> Optional[List[PermissionSQL]]:
|
||||||
"""
|
"""
|
||||||
Apply permission rules from datasette.yaml configuration.
|
Apply permission rules from datasette.yaml configuration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Provides default allow rules for standard view/execute actions.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -29,28 +29,29 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_allow_sql_check(
|
async def default_allow_sql_check(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
"""
|
"""
|
||||||
Enforce the default_allow_sql setting.
|
Enforce the default_allow_sql setting.
|
||||||
|
|
||||||
When default_allow_sql is false (the default), execute-sql is denied
|
When default_allow_sql is false (the default), execute-sql is denied
|
||||||
unless explicitly allowed by config or other rules.
|
unless explicitly allowed by config or other rules.
|
||||||
"""
|
"""
|
||||||
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
if action == "execute-sql":
|
||||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
if not datasette.setting("default_allow_sql"):
|
||||||
|
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_action_permissions_sql(
|
async def default_action_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
"""
|
"""
|
||||||
Provide default allow rules for standard view/execute actions.
|
Provide default allow rules for standard view/execute actions.
|
||||||
|
|
||||||
|
|
@ -70,10 +71,10 @@ async def default_action_permissions_sql(
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_query_permissions_sql(
|
async def default_query_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||||
|
|
||||||
if action not in {"view-query", "update-query", "delete-query"}:
|
if action not in {"view-query", "update-query", "delete-query"}:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, List, Optional, Set
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||||
from datasette.permissions import PermissionSQL
|
from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
|
|
||||||
def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
|
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
|
||||||
"""
|
"""
|
||||||
Get all name variants for an action (full name and abbreviation).
|
Get all name variants for an action (full name and abbreviation).
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
|
||||||
return variants
|
return variants
|
||||||
|
|
||||||
|
|
||||||
def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool:
|
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
|
||||||
"""Check if an action (or its abbreviation) is in a list."""
|
"""Check if an action (or its abbreviation) is in a list."""
|
||||||
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
||||||
|
|
||||||
|
|
@ -36,8 +36,8 @@ def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool
|
||||||
class PermissionRow:
|
class PermissionRow:
|
||||||
"""A single permission rule row."""
|
"""A single permission rule row."""
|
||||||
|
|
||||||
parent: str | None
|
parent: Optional[str]
|
||||||
child: str | None
|
child: Optional[str]
|
||||||
allow: bool
|
allow: bool
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
@ -46,14 +46,14 @@ class PermissionRowCollector:
|
||||||
"""Collects permission rows and converts them to PermissionSQL."""
|
"""Collects permission rows and converts them to PermissionSQL."""
|
||||||
|
|
||||||
def __init__(self, prefix: str = "row"):
|
def __init__(self, prefix: str = "row"):
|
||||||
self.rows: list[PermissionRow] = []
|
self.rows: List[PermissionRow] = []
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
|
|
||||||
def add(
|
def add(
|
||||||
self,
|
self,
|
||||||
parent: str | None,
|
parent: Optional[str],
|
||||||
child: str | None,
|
child: Optional[str],
|
||||||
allow: bool | None,
|
allow: Optional[bool],
|
||||||
reason: str,
|
reason: str,
|
||||||
if_not_none: bool = False,
|
if_not_none: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -62,7 +62,7 @@ class PermissionRowCollector:
|
||||||
return
|
return
|
||||||
self.rows.append(PermissionRow(parent, child, allow, reason))
|
self.rows.append(PermissionRow(parent, child, allow, reason))
|
||||||
|
|
||||||
def to_permission_sql(self) -> PermissionSQL | None:
|
def to_permission_sql(self) -> Optional[PermissionSQL]:
|
||||||
"""Convert collected rows to a PermissionSQL object."""
|
"""Convert collected rows to a PermissionSQL object."""
|
||||||
if not self.rows:
|
if not self.rows:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
|
||||||
class ActorRestrictions:
|
class ActorRestrictions:
|
||||||
"""Parsed actor restrictions from the _r key."""
|
"""Parsed actor restrictions from the _r key."""
|
||||||
|
|
||||||
global_actions: list[str] # _r.a - globally allowed actions
|
global_actions: List[str] # _r.a - globally allowed actions
|
||||||
database_actions: dict # _r.d - {db_name: [actions]}
|
database_actions: dict # _r.d - {db_name: [actions]}
|
||||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_actor(cls, actor: dict | None) -> ActorRestrictions | None:
|
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
|
||||||
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
||||||
if not actor:
|
if not actor:
|
||||||
return None
|
return None
|
||||||
|
|
@ -44,11 +44,11 @@ class ActorRestrictions:
|
||||||
table_actions=restrictions.get("r", {}),
|
table_actions=restrictions.get("r", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_action_globally_allowed(self, datasette: Datasette, action: str) -> bool:
|
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
|
||||||
"""Check if action is in the global allowlist."""
|
"""Check if action is in the global allowlist."""
|
||||||
return action_in_list(datasette, action, self.global_actions)
|
return action_in_list(datasette, action, self.global_actions)
|
||||||
|
|
||||||
def get_allowed_databases(self, datasette: Datasette, action: str) -> set[str]:
|
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
|
||||||
"""Get database names where this action is allowed."""
|
"""Get database names where this action is allowed."""
|
||||||
allowed = set()
|
allowed = set()
|
||||||
for db_name, db_actions in self.database_actions.items():
|
for db_name, db_actions in self.database_actions.items():
|
||||||
|
|
@ -57,8 +57,8 @@ class ActorRestrictions:
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
def get_allowed_tables(
|
def get_allowed_tables(
|
||||||
self, datasette: Datasette, action: str
|
self, datasette: "Datasette", action: str
|
||||||
) -> set[tuple[str, str]]:
|
) -> Set[Tuple[str, str]]:
|
||||||
"""Get (database, table) pairs where this action is allowed."""
|
"""Get (database, table) pairs where this action is allowed."""
|
||||||
allowed = set()
|
allowed = set()
|
||||||
for db_name, tables in self.table_actions.items():
|
for db_name, tables in self.table_actions.items():
|
||||||
|
|
@ -70,10 +70,10 @@ class ActorRestrictions:
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def actor_restrictions_sql(
|
async def actor_restrictions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
action: str,
|
action: str,
|
||||||
) -> list[PermissionSQL] | None:
|
) -> Optional[List[PermissionSQL]]:
|
||||||
"""
|
"""
|
||||||
Handle actor restriction-based permission rules.
|
Handle actor restriction-based permission rules.
|
||||||
|
|
||||||
|
|
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
|
||||||
|
|
||||||
|
|
||||||
def restrictions_allow_action(
|
def restrictions_allow_action(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
restrictions: dict,
|
restrictions: dict,
|
||||||
action: str,
|
action: str,
|
||||||
resource: str | tuple[str, str] | None,
|
resource: Optional[str | Tuple[str, str]],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if restrictions allow the requested action on the requested resource.
|
Check if restrictions allow the requested action on the requested resource.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def root_user_permissions_sql(
|
async def root_user_permissions_sql(
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor: dict | None,
|
actor: Optional[dict],
|
||||||
) -> PermissionSQL | None:
|
) -> Optional[PermissionSQL]:
|
||||||
"""
|
"""
|
||||||
Grant root user full permissions when --root flag is used.
|
Grant root user full permissions when --root flag is used.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -17,13 +17,15 @@ from datasette.tokens import SignedTokenHandler
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def register_token_handler(datasette: Datasette):
|
def register_token_handler(datasette: "Datasette"):
|
||||||
"""Register the default signed token handler."""
|
"""Register the default signed token handler."""
|
||||||
return SignedTokenHandler()
|
return SignedTokenHandler()
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(specname="actor_from_request")
|
@hookimpl(specname="actor_from_request")
|
||||||
async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None:
|
async def actor_from_signed_api_token(
|
||||||
|
datasette: "Datasette", request
|
||||||
|
) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Authenticate requests using API tokens by delegating to all registered
|
Authenticate requests using API tokens by delegating to all registered
|
||||||
token handlers via datasette.verify_token().
|
token handlers via datasette.verify_token().
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ def table_actions(datasette, actor, database, table, request):
|
||||||
"label": "Alter table",
|
"label": "Alter table",
|
||||||
"description": "Change columns and primary key for this table.",
|
"description": "Change columns and primary key for this table.",
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"aria-label": f"Alter table {table}",
|
"aria-label": "Alter table {}".format(table),
|
||||||
"data-table-action": "alter-table",
|
"data-table-action": "alter-table",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
from abc import ABC, abstractproperty
|
from abc import ABC, abstractproperty
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from datasette.hookspecs import hookimpl
|
from datasette.hookspecs import hookimpl
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import json
|
import json
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
detect_json1,
|
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
|
detect_json1,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -31,7 +30,7 @@ def load_facet_configs(request, table_config):
|
||||||
assert (
|
assert (
|
||||||
len(facet_config.values()) == 1
|
len(facet_config.values()) == 1
|
||||||
), "Metadata config dicts should be {type: config}"
|
), "Metadata config dicts should be {type: config}"
|
||||||
type, facet_config = next(iter(facet_config.items()))
|
type, facet_config = list(facet_config.items())[0]
|
||||||
if isinstance(facet_config, str):
|
if isinstance(facet_config, str):
|
||||||
facet_config = {"simple": facet_config}
|
facet_config = {"simple": facet_config}
|
||||||
facet_configs.setdefault(type, []).append(
|
facet_configs.setdefault(type, []).append(
|
||||||
|
|
@ -86,7 +85,7 @@ class Facet:
|
||||||
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. stored SQL queries:
|
||||||
self.table = table
|
self.table = table
|
||||||
self.sql = sql or f"select * from {escape_sqlite(table)}"
|
self.sql = sql or f"select * from [{table}]"
|
||||||
self.params = params or []
|
self.params = params or []
|
||||||
self.table_config = table_config
|
self.table_config = table_config
|
||||||
# row_count can be None, in which case we calculate it ourselves:
|
# row_count can be None, in which case we calculate it ourselves:
|
||||||
|
|
@ -161,13 +160,18 @@ class ColumnFacet(Facet):
|
||||||
for column in columns:
|
for column in columns:
|
||||||
if column in already_enabled:
|
if column in already_enabled:
|
||||||
continue
|
continue
|
||||||
suggested_facet_sql = f"""
|
suggested_facet_sql = """
|
||||||
with limited as (select * from ({self.sql}) limit {self.suggest_consider})
|
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||||
select {escape_sqlite(column)} as value, count(*) as n from limited
|
select {column} as value, count(*) as n from limited
|
||||||
where value is not null
|
where value is not null
|
||||||
group by value
|
group by value
|
||||||
limit {facet_size + 1}
|
limit {limit}
|
||||||
"""
|
""".format(
|
||||||
|
column=escape_sqlite(column),
|
||||||
|
sql=self.sql,
|
||||||
|
limit=facet_size + 1,
|
||||||
|
suggest_consider=self.suggest_consider,
|
||||||
|
)
|
||||||
distinct_values = None
|
distinct_values = None
|
||||||
try:
|
try:
|
||||||
distinct_values = await self.ds.execute(
|
distinct_values = await self.ds.execute(
|
||||||
|
|
@ -263,7 +267,7 @@ class ColumnFacet(Facet):
|
||||||
for row in facet_rows:
|
for row in facet_rows:
|
||||||
column_qs = column
|
column_qs = column
|
||||||
if column.startswith("_"):
|
if column.startswith("_"):
|
||||||
column_qs = f"{column}__exact"
|
column_qs = "{}__exact".format(column)
|
||||||
selected = (column_qs, str(row["value"])) in qs_pairs
|
selected = (column_qs, str(row["value"])) in qs_pairs
|
||||||
if selected:
|
if selected:
|
||||||
toggle_path = path_with_removed_args(
|
toggle_path = path_with_removed_args(
|
||||||
|
|
@ -338,12 +342,12 @@ class ArrayFacet(Facet):
|
||||||
for v in await self.ds.execute(
|
for v in await self.ds.execute(
|
||||||
self.database,
|
self.database,
|
||||||
(
|
(
|
||||||
f"select {escape_sqlite(column)} from ({self.sql}) "
|
"select {column} from ({sql}) "
|
||||||
f"where {escape_sqlite(column)} is not null "
|
"where {column} is not null "
|
||||||
f"and {escape_sqlite(column)} != '' "
|
"and {column} != '' "
|
||||||
f"and json_array_length({escape_sqlite(column)}) > 0 "
|
"and json_array_length({column}) > 0 "
|
||||||
"limit 100"
|
"limit 100"
|
||||||
),
|
).format(column=escape_sqlite(column), sql=self.sql),
|
||||||
self.params,
|
self.params,
|
||||||
truncate=False,
|
truncate=False,
|
||||||
custom_time_limit=self.ds.setting(
|
custom_time_limit=self.ds.setting(
|
||||||
|
|
@ -384,14 +388,14 @@ class ArrayFacet(Facet):
|
||||||
source = source_and_config["source"]
|
source = source_and_config["source"]
|
||||||
column = config.get("column") or config["simple"]
|
column = config.get("column") or config["simple"]
|
||||||
# https://github.com/simonw/datasette/issues/448
|
# https://github.com/simonw/datasette/issues/448
|
||||||
facet_sql = f"""
|
facet_sql = """
|
||||||
with inner as ({self.sql}),
|
with inner as ({sql}),
|
||||||
deduped_array_items as (
|
deduped_array_items as (
|
||||||
select
|
select
|
||||||
distinct j.value,
|
distinct j.value,
|
||||||
inner.*
|
inner.*
|
||||||
from
|
from
|
||||||
json_each([inner].{escape_sqlite(column)}) j
|
json_each([inner].{col}) j
|
||||||
join inner
|
join inner
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
|
|
@ -402,8 +406,12 @@ class ArrayFacet(Facet):
|
||||||
group by
|
group by
|
||||||
value
|
value
|
||||||
order by
|
order by
|
||||||
count(*) desc, value limit {facet_size + 1}
|
count(*) desc, value limit {limit}
|
||||||
"""
|
""".format(
|
||||||
|
col=escape_sqlite(column),
|
||||||
|
sql=self.sql,
|
||||||
|
limit=facet_size + 1,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
facet_rows_results = await self.ds.execute(
|
facet_rows_results = await self.ds.execute(
|
||||||
self.database,
|
self.database,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import json
|
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.resources import DatabaseResource
|
from datasette.resources import DatabaseResource
|
||||||
from datasette.utils.asgi import BadRequest
|
|
||||||
from datasette.views.base import DatasetteError
|
from datasette.views.base import DatasetteError
|
||||||
|
from datasette.utils.asgi import BadRequest
|
||||||
|
import json
|
||||||
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,9 +99,9 @@ def search_filters(request, database, table, datasette):
|
||||||
fts_table=escape_sqlite(fts_table),
|
fts_table=escape_sqlite(fts_table),
|
||||||
search_col=escape_sqlite(search_col),
|
search_col=escape_sqlite(search_col),
|
||||||
match_clause=(
|
match_clause=(
|
||||||
f":search_{i}"
|
":search_{}".format(i)
|
||||||
if search_mode_raw
|
if search_mode_raw
|
||||||
else f"escape_fts(:search_{i})"
|
else "escape_fts(:search_{})".format(i)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -137,11 +134,11 @@ def through_filters(request, database, table, datasette):
|
||||||
value = through_data["value"]
|
value = through_data["value"]
|
||||||
db = datasette.get_database(database)
|
db = datasette.get_database(database)
|
||||||
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
||||||
fk_to_us = next(
|
try:
|
||||||
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table),
|
fk_to_us = [
|
||||||
None,
|
fk for fk in outgoing_foreign_keys if fk["other_table"] == table
|
||||||
)
|
][0]
|
||||||
if fk_to_us is None:
|
except IndexError:
|
||||||
raise DatasetteError(
|
raise DatasetteError(
|
||||||
"Invalid _through - could not find corresponding foreign key"
|
"Invalid _through - could not find corresponding foreign key"
|
||||||
)
|
)
|
||||||
|
|
@ -368,7 +365,7 @@ class Filters:
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
_filters_by_key: ClassVar[dict[str, Filter]] = {f.key: f for f in _filters}
|
_filters_by_key = {f.key: f for f in _filters}
|
||||||
|
|
||||||
def __init__(self, pairs):
|
def __init__(self, pairs):
|
||||||
self.pairs = pairs
|
self.pairs = pairs
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
|
from datasette.utils.sqlite import sqlite3
|
||||||
|
from datasette.utils import documented
|
||||||
import itertools
|
import itertools
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
|
||||||
from datasette.utils import documented
|
|
||||||
from datasette.utils.sqlite import sqlite3
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EXTRA_DATABASE_SQL",
|
"EXTRA_DATABASE_SQL",
|
||||||
"TABLES",
|
"TABLES",
|
||||||
|
|
@ -347,7 +346,9 @@ CREATE VIEW searchable_view_configured_by_metadata AS
|
||||||
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
||||||
+ "\n".join(
|
+ "\n".join(
|
||||||
[
|
[
|
||||||
f'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'
|
'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)
|
for a, b, c, content in generate_compound_rows(1001)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from datasette import Response, hookimpl
|
from datasette import hookimpl, Response
|
||||||
|
|
||||||
from .utils import add_cors_headers
|
from .utils import add_cors_headers
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,16 @@
|
||||||
import traceback
|
from datasette import hookimpl, Response
|
||||||
|
|
||||||
from markupsafe import Markup
|
|
||||||
|
|
||||||
from datasette import Response, hookimpl
|
|
||||||
|
|
||||||
from .utils import add_cors_headers, error_body
|
from .utils import add_cors_headers, error_body
|
||||||
from .utils.asgi import (
|
from .utils.asgi import (
|
||||||
Base400,
|
Base400,
|
||||||
)
|
)
|
||||||
from .views.base import DatasetteError
|
from .views.base import DatasetteError
|
||||||
|
from markupsafe import Markup
|
||||||
|
import traceback
|
||||||
|
|
||||||
# Debugger imports are deliberate - they back the "pdb" setting, which drops
|
|
||||||
# into a debugger on unhandled exceptions
|
|
||||||
try:
|
try:
|
||||||
import ipdb as pdb # noqa: T100
|
import ipdb as pdb
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import pdb # noqa: T100
|
import pdb
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import rich
|
import rich
|
||||||
|
|
@ -74,7 +69,7 @@ def handle_exception(datasette, request, exception):
|
||||||
dict(
|
dict(
|
||||||
info,
|
info,
|
||||||
urls=datasette.urls,
|
urls=datasette.urls,
|
||||||
menu_links=list,
|
menu_links=lambda: [],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
status=status,
|
status=status,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from pluggy import HookimplMarker, HookspecMarker
|
from pluggy import HookimplMarker
|
||||||
|
from pluggy import HookspecMarker
|
||||||
|
|
||||||
hookspec = HookspecMarker("datasette")
|
hookspec = HookspecMarker("datasette")
|
||||||
hookimpl = HookimplMarker("datasette")
|
hookimpl = HookimplMarker("datasette")
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
|
detect_spatialite,
|
||||||
detect_fts,
|
detect_fts,
|
||||||
detect_primary_keys,
|
detect_primary_keys,
|
||||||
detect_spatialite,
|
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
get_all_foreign_keys,
|
get_all_foreign_keys,
|
||||||
sqlite3,
|
|
||||||
table_columns,
|
table_columns,
|
||||||
|
sqlite3,
|
||||||
)
|
)
|
||||||
|
|
||||||
HASH_BLOCK_SIZE = 1024 * 1024
|
HASH_BLOCK_SIZE = 1024 * 1024
|
||||||
|
|
@ -95,10 +95,10 @@ def inspect_tables(conn, database_metadata):
|
||||||
""")
|
""")
|
||||||
]
|
]
|
||||||
|
|
||||||
for t, table_info in tables.items():
|
for t in tables.keys():
|
||||||
for hidden_table in hidden_tables:
|
for hidden_table in hidden_tables:
|
||||||
if t == hidden_table or t.startswith(hidden_table):
|
if t == hidden_table or t.startswith(hidden_table):
|
||||||
table_info["hidden"] = True
|
tables[t]["hidden"] = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return tables
|
return tables
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class JumpSQL:
|
||||||
search_text: str | None = None,
|
search_text: str | None = None,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
item_type: str = "menu",
|
item_type: str = "menu",
|
||||||
) -> JumpSQL:
|
) -> "JumpSQL":
|
||||||
if search_text is None:
|
if search_text is None:
|
||||||
search_text = " ".join(
|
search_text = " ".join(
|
||||||
text for text in (label, display_name, description) if text is not None
|
text for text in (label, display_name, description) if text is not None
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import contextvars
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
|
import contextvars
|
||||||
|
|
||||||
# Context variable to track when permission checks should be skipped
|
# Context variable to track when permission checks should be skipped
|
||||||
_skip_permission_checks = contextvars.ContextVar(
|
_skip_permission_checks = contextvars.ContextVar(
|
||||||
|
|
@ -72,8 +72,8 @@ class Resource(ABC):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return "{}(parent={!r}, child={!r})".format(
|
||||||
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})"
|
self.__class__.__name__, self.parent, self.child
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -129,6 +129,7 @@ class Resource(ABC):
|
||||||
|
|
||||||
Must return two columns: parent, child
|
Must return two columns: parent, child
|
||||||
"""
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AllowedResource(NamedTuple):
|
class AllowedResource(NamedTuple):
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata as importlib_metadata
|
|
||||||
import importlib.resources as importlib_resources
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
|
from pprint import pprint
|
||||||
|
import sys
|
||||||
from . import hookspecs
|
from . import hookspecs
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 9):
|
||||||
|
import importlib.resources as importlib_resources
|
||||||
|
else:
|
||||||
|
import importlib_resources
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
import importlib.metadata as importlib_metadata
|
||||||
|
else:
|
||||||
|
import importlib_metadata
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PLUGINS = (
|
DEFAULT_PLUGINS = (
|
||||||
"datasette.publish.heroku",
|
"datasette.publish.heroku",
|
||||||
"datasette.publish.cloudrun",
|
"datasette.publish.cloudrun",
|
||||||
|
|
@ -79,7 +85,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
|
||||||
# Ensure name can be found in plugin_to_distinfo later:
|
# Ensure name can be found in plugin_to_distinfo later:
|
||||||
pm._plugin_distinfo.append((mod, distribution))
|
pm._plugin_distinfo.append((mod, distribution))
|
||||||
except importlib_metadata.PackageNotFoundError:
|
except importlib_metadata.PackageNotFoundError:
|
||||||
sys.stderr.write(f"Plugin {package_name} could not be found\n")
|
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
|
||||||
|
|
||||||
|
|
||||||
# Load default plugins
|
# Load default plugins
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
|
from datasette import hookimpl
|
||||||
|
import click
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from subprocess import CalledProcessError, check_call, check_output
|
from subprocess import CalledProcessError, check_call, check_output
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
from ..utils import temporary_docker_directory
|
|
||||||
from .common import (
|
from .common import (
|
||||||
add_common_publish_arguments_and_options,
|
add_common_publish_arguments_and_options,
|
||||||
fail_if_publish_binary_not_installed,
|
fail_if_publish_binary_not_installed,
|
||||||
)
|
)
|
||||||
|
from ..utils import temporary_docker_directory
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -221,7 +219,7 @@ def publish_subcommand(publish):
|
||||||
|
|
||||||
check_call(
|
check_call(
|
||||||
"gcloud builds submit --tag {}{}".format(
|
"gcloud builds submit --tag {}{}".format(
|
||||||
image_id, f" --timeout {timeout}" if timeout else ""
|
image_id, " --timeout {}".format(timeout) if timeout else ""
|
||||||
),
|
),
|
||||||
shell=True,
|
shell=True,
|
||||||
)
|
)
|
||||||
|
|
@ -233,7 +231,7 @@ def publish_subcommand(publish):
|
||||||
("--min-instances", min_instances),
|
("--min-instances", min_instances),
|
||||||
):
|
):
|
||||||
if value is not None:
|
if value is not None:
|
||||||
extra_deploy_options.append(f"{option} {value}")
|
extra_deploy_options.append("{} {}".format(option, value))
|
||||||
check_call(
|
check_call(
|
||||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||||
image_id,
|
image_id,
|
||||||
|
|
@ -260,16 +258,24 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
describe_cmd = (
|
describe_cmd = (
|
||||||
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} "
|
"gcloud artifacts repositories describe {repo} --project {project} "
|
||||||
f"--location {artifact_region} --quiet"
|
"--location {location} --quiet"
|
||||||
|
).format(
|
||||||
|
repo=artifact_repository,
|
||||||
|
project=artifact_project,
|
||||||
|
location=artifact_region,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
check_call(describe_cmd, shell=True)
|
check_call(describe_cmd, shell=True)
|
||||||
return
|
return
|
||||||
except CalledProcessError:
|
except CalledProcessError:
|
||||||
create_cmd = (
|
create_cmd = (
|
||||||
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker "
|
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
||||||
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet'
|
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
||||||
|
).format(
|
||||||
|
repo=artifact_repository,
|
||||||
|
location=artifact_region,
|
||||||
|
project=artifact_project,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
check_call(create_cmd, shell=True)
|
check_call(create_cmd, shell=True)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
|
from ..utils import StaticMount
|
||||||
|
import click
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from ..utils import StaticMount
|
|
||||||
|
|
||||||
|
|
||||||
def add_common_publish_arguments_and_options(subcommand):
|
def add_common_publish_arguments_and_options(subcommand):
|
||||||
for decorator in reversed(
|
for decorator in reversed(
|
||||||
|
|
@ -78,7 +76,9 @@ def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
|
||||||
"""Exit (with error message) if ``binary` isn't installed"""
|
"""Exit (with error message) if ``binary` isn't installed"""
|
||||||
if not shutil.which(binary):
|
if not shutil.which(binary):
|
||||||
click.secho(
|
click.secho(
|
||||||
f"Publishing to {publish_target} requires {binary} to be installed and configured",
|
"Publishing to {publish_target} requires {binary} to be installed and configured".format(
|
||||||
|
publish_target=publish_target, binary=binary
|
||||||
|
),
|
||||||
bg="red",
|
bg="red",
|
||||||
fg="white",
|
fg="white",
|
||||||
bold=True,
|
bold=True,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datasette import hookimpl
|
||||||
|
import click
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from subprocess import call, check_output
|
from subprocess import call, check_output
|
||||||
|
import tempfile
|
||||||
import click
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
add_common_publish_arguments_and_options,
|
add_common_publish_arguments_and_options,
|
||||||
fail_if_publish_binary_not_installed,
|
fail_if_publish_binary_not_installed,
|
||||||
)
|
)
|
||||||
|
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -236,7 +234,7 @@ def temporary_heroku_directory(
|
||||||
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
||||||
|
|
||||||
quoted_files = " ".join(
|
quoted_files = " ".join(
|
||||||
[f"-i {shlex.quote(file_name)}" for file_name in file_names]
|
["-i {}".format(shlex.quote(file_name)) for file_name in file_names]
|
||||||
)
|
)
|
||||||
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
|
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
|
||||||
quoted_files=quoted_files, extras=" ".join(extras)
|
quoted_files=quoted_files, extras=" ".join(extras)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from datasette.extras import extra_names_from_request
|
from datasette.extras import extra_names_from_request
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
CustomJSONEncoder,
|
|
||||||
error_body,
|
error_body,
|
||||||
path_from_row_pks,
|
|
||||||
remove_infinites,
|
|
||||||
sqlite3,
|
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
|
remove_infinites,
|
||||||
|
CustomJSONEncoder,
|
||||||
|
path_from_row_pks,
|
||||||
|
sqlite3,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,74 +0,0 @@
|
||||||
import { EditorView, basicSetup } from "codemirror";
|
|
||||||
import { keymap } from "@codemirror/view";
|
|
||||||
import { sql, SQLDialect } from "@codemirror/lang-sql";
|
|
||||||
|
|
||||||
// A variation of SQLite from lang-sql https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
|
|
||||||
const SQLite = SQLDialect.define({
|
|
||||||
// Based on https://www.sqlite.org/lang_keywords.html based on likely keywords to be used in select queries
|
|
||||||
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
|
|
||||||
keywords:
|
|
||||||
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
|
|
||||||
// https://www.sqlite.org/datatype3.html
|
|
||||||
types: "null integer real text blob",
|
|
||||||
builtin: "",
|
|
||||||
operatorChars: "*+-%<>!=&|/~",
|
|
||||||
identifierQuotes: '`"',
|
|
||||||
specialVar: "@:?$",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Utility function from https://codemirror.net/docs/migration/
|
|
||||||
export function editorFromTextArea(textarea, conf = {}) {
|
|
||||||
// This could also be configured with a set of tables and columns for better autocomplete:
|
|
||||||
// https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables
|
|
||||||
let view = new EditorView({
|
|
||||||
doc: textarea.value,
|
|
||||||
extensions: [
|
|
||||||
keymap.of([
|
|
||||||
{
|
|
||||||
key: "Shift-Enter",
|
|
||||||
run: function () {
|
|
||||||
textarea.value = view.state.doc.toString();
|
|
||||||
textarea.form.submit();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Meta-Enter",
|
|
||||||
run: function () {
|
|
||||||
textarea.value = view.state.doc.toString();
|
|
||||||
textarea.form.submit();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
// This has to be after the keymap or else the basicSetup keys will prevent
|
|
||||||
// Meta-Enter from running
|
|
||||||
basicSetup,
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
sql({
|
|
||||||
dialect: SQLite,
|
|
||||||
schema: conf.schema,
|
|
||||||
tables: conf.tables,
|
|
||||||
defaultTableName: conf.defaultTableName,
|
|
||||||
defaultSchemaName: conf.defaultSchemaName,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
|
|
||||||
// Using CSS resize: both and scheduling a measurement when the element changes.
|
|
||||||
let editorDOM = view.contentDOM.closest(".cm-editor");
|
|
||||||
let observer = new ResizeObserver(function () {
|
|
||||||
view.requestMeasure();
|
|
||||||
});
|
|
||||||
observer.observe(editorDOM, { attributes: true });
|
|
||||||
|
|
||||||
textarea.parentNode.insertBefore(view.dom, textarea);
|
|
||||||
textarea.style.display = "none";
|
|
||||||
if (textarea.form) {
|
|
||||||
textarea.form.addEventListener("submit", () => {
|
|
||||||
textarea.value = view.state.doc.toString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
1
datasette/static/cm-editor.bundle.js
Normal file
1
datasette/static/cm-editor.bundle.js
Normal file
File diff suppressed because one or more lines are too long
51
datasette/static/cm-editor.js
Normal file
51
datasette/static/cm-editor.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// IIFE entry point for Datasette's own SQL editor pages. Built as
|
||||||
|
// cm-editor.bundle.js (global name `cm`) and included by _codemirror.html.
|
||||||
|
//
|
||||||
|
// This is a thin consumer of the datasette-sql-editor.js primitives so there is
|
||||||
|
// a single CodeMirror implementation. rollup inlines the shared module into this
|
||||||
|
// bundle.
|
||||||
|
import { createSqlEditor, SQLiteDialect } from "./datasette-sql-editor.js";
|
||||||
|
|
||||||
|
// Re-exported so plugins/pages using the IIFE global can reach the curated
|
||||||
|
// dialect (cm.SQLiteDialect) without a second CodeMirror instance.
|
||||||
|
export { SQLiteDialect };
|
||||||
|
|
||||||
|
// Utility function from https://codemirror.net/docs/migration/. Wraps a textarea
|
||||||
|
// with a CodeMirror SQL editor, mirroring the textarea's value back on submit.
|
||||||
|
// Returns the EditorView (with an added updateSchema method) for backwards
|
||||||
|
// compatibility with existing callers (window.editor).
|
||||||
|
export function editorFromTextArea(textarea, conf = {}) {
|
||||||
|
const submit = (view) => {
|
||||||
|
textarea.value = view.state.doc.toString();
|
||||||
|
textarea.form.submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = createSqlEditor(null, {
|
||||||
|
doc: textarea.value,
|
||||||
|
schema: conf.schema,
|
||||||
|
defaultTable: conf.defaultTable,
|
||||||
|
defaultSchema: conf.defaultSchema,
|
||||||
|
onSubmit: submit,
|
||||||
|
});
|
||||||
|
const view = handle.view;
|
||||||
|
|
||||||
|
// Preserve the historical public surface: callers use view.updateSchema(conf).
|
||||||
|
view.updateSchema = handle.updateSchema;
|
||||||
|
|
||||||
|
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
|
||||||
|
// Using CSS resize: both and scheduling a measurement when the element changes.
|
||||||
|
let editorDOM = view.contentDOM.closest(".cm-editor");
|
||||||
|
let observer = new ResizeObserver(function () {
|
||||||
|
view.requestMeasure();
|
||||||
|
});
|
||||||
|
observer.observe(editorDOM, { attributes: true });
|
||||||
|
|
||||||
|
textarea.parentNode.insertBefore(view.dom, textarea);
|
||||||
|
textarea.style.display = "none";
|
||||||
|
if (textarea.form) {
|
||||||
|
textarea.form.addEventListener("submit", () => {
|
||||||
|
textarea.value = view.state.doc.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
1
datasette/static/datasette-sql-editor.bundle.js
Normal file
1
datasette/static/datasette-sql-editor.bundle.js
Normal file
File diff suppressed because one or more lines are too long
675
datasette/static/datasette-sql-editor.js
Normal file
675
datasette/static/datasette-sql-editor.js
Normal file
|
|
@ -0,0 +1,675 @@
|
||||||
|
// datasette-sql-editor: ESM primitives for embedding Datasette's SQL editor.
|
||||||
|
//
|
||||||
|
// This is the single source of truth for Datasette's CodeMirror setup. The IIFE
|
||||||
|
// entry point (cm-editor.js, served as cm-editor.bundle.js for Datasette's own
|
||||||
|
// pages) is a thin consumer of these primitives, and plugin authors can import
|
||||||
|
// this module directly from /-/static/datasette-sql-editor.js to get a SQL
|
||||||
|
// editor that shares ONE CodeMirror instance per page (no duplicate
|
||||||
|
// @codemirror/state bug).
|
||||||
|
//
|
||||||
|
// Built by rollup.config.mjs into datasette-sql-editor.bundle.js.
|
||||||
|
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
highlightActiveLineGutter,
|
||||||
|
highlightSpecialChars,
|
||||||
|
drawSelection,
|
||||||
|
dropCursor,
|
||||||
|
rectangularSelection,
|
||||||
|
crosshairCursor,
|
||||||
|
highlightActiveLine,
|
||||||
|
tooltips,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { EditorState, Compartment, Annotation, Prec } from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
foldGutter,
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
defaultHighlightStyle,
|
||||||
|
bracketMatching,
|
||||||
|
foldKeymap,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { history, defaultKeymap, historyKeymap } from "@codemirror/commands";
|
||||||
|
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
||||||
|
import {
|
||||||
|
closeBrackets,
|
||||||
|
autocompletion,
|
||||||
|
closeBracketsKeymap,
|
||||||
|
completionKeymap,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import { lintKeymap } from "@codemirror/lint";
|
||||||
|
import { sql, SQLDialect } from "@codemirror/lang-sql";
|
||||||
|
|
||||||
|
// A curated variation of SQLite from lang-sql:
|
||||||
|
// https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
|
||||||
|
export const SQLiteDialect = SQLDialect.define({
|
||||||
|
// Based on https://www.sqlite.org/lang_keywords.html, restricted to likely
|
||||||
|
// keywords used in select queries.
|
||||||
|
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
|
||||||
|
keywords:
|
||||||
|
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
|
||||||
|
// https://www.sqlite.org/datatype3.html
|
||||||
|
types: "null integer real text blob",
|
||||||
|
builtin: "",
|
||||||
|
operatorChars: "*+-%<>!=&|/~",
|
||||||
|
identifierQuotes: '`"',
|
||||||
|
specialVar: "@:?$",
|
||||||
|
caseInsensitiveIdentifiers: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Annotation used to tag host-originated changes (e.g. a ProseMirror/collab host
|
||||||
|
// pushing edits into the editor, or the exported `value` setter). Changes tagged
|
||||||
|
// with this annotation do NOT re-fire onChange, so hosts can suppress the echo of
|
||||||
|
// their own edits. Mirrors datasette-paper's `fromPM` pattern.
|
||||||
|
export const hostChange = Annotation.define();
|
||||||
|
|
||||||
|
// Builds the sql() language extension from a {schema, defaultTable, defaultSchema}
|
||||||
|
// conf object. Undefined fields are fine - lang-sql ignores them.
|
||||||
|
function sqlExtension(conf = {}) {
|
||||||
|
return sql({
|
||||||
|
dialect: SQLiteDialect,
|
||||||
|
schema: conf.schema,
|
||||||
|
defaultTable: conf.defaultTable,
|
||||||
|
defaultSchema: conf.defaultSchema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replicates codemirror's basicSetup (node_modules/codemirror/dist/index.js) as a
|
||||||
|
// plain array so we can drop the undo history when `withHistory` is false. When
|
||||||
|
// history is off we optionally forward Mod-z / Mod-y / Mod-Shift-z to the host so
|
||||||
|
// an external undo stack (ProseMirror, collab) can own undo/redo.
|
||||||
|
function baseSetup(withHistory, onHostUndo, onHostRedo) {
|
||||||
|
const setup = [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
highlightSpecialChars(),
|
||||||
|
foldGutter(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
EditorState.allowMultipleSelections.of(true),
|
||||||
|
indentOnInput(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
bracketMatching(),
|
||||||
|
closeBrackets(),
|
||||||
|
autocompletion(),
|
||||||
|
rectangularSelection(),
|
||||||
|
crosshairCursor(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
];
|
||||||
|
const bindings = [
|
||||||
|
...closeBracketsKeymap,
|
||||||
|
...defaultKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
...foldKeymap,
|
||||||
|
...completionKeymap,
|
||||||
|
...lintKeymap,
|
||||||
|
];
|
||||||
|
if (withHistory) {
|
||||||
|
setup.push(history());
|
||||||
|
bindings.push(...historyKeymap);
|
||||||
|
} else {
|
||||||
|
if (onHostUndo) {
|
||||||
|
bindings.push({
|
||||||
|
key: "Mod-z",
|
||||||
|
preventDefault: true,
|
||||||
|
run: () => {
|
||||||
|
onHostUndo();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onHostRedo) {
|
||||||
|
bindings.push(
|
||||||
|
{
|
||||||
|
key: "Mod-y",
|
||||||
|
mac: "Mod-Shift-z",
|
||||||
|
preventDefault: true,
|
||||||
|
run: () => {
|
||||||
|
onHostRedo();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Mod-Shift-z",
|
||||||
|
preventDefault: true,
|
||||||
|
run: () => {
|
||||||
|
onHostRedo();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setup.push(keymap.of(bindings));
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSqlEditor(parent, opts) -> handle
|
||||||
|
//
|
||||||
|
// opts:
|
||||||
|
// doc initial document string (default "")
|
||||||
|
// schema lang-sql SQLNamespace for autocomplete
|
||||||
|
// defaultTable unqualified-column default table
|
||||||
|
// defaultSchema default schema/attached-database name
|
||||||
|
// history include CM undo history (default true); false forwards
|
||||||
|
// undo/redo to onHostUndo/onHostRedo
|
||||||
|
// onHostUndo called on Mod-z when history is false
|
||||||
|
// onHostRedo called on Mod-y / Mod-Shift-z when history is false
|
||||||
|
// extensions extra CodeMirror extensions to append (default [])
|
||||||
|
// fixedTooltips use position:"fixed" tooltips for overflow-clipped containers
|
||||||
|
// onChange called (update) on user edits; host-annotated changes are
|
||||||
|
// suppressed
|
||||||
|
// onSubmit called (view) on Mod-Enter / Shift-Enter (highest precedence)
|
||||||
|
// onEscape called (view) on Escape
|
||||||
|
// lineWrapping soft-wrap long lines (default true)
|
||||||
|
//
|
||||||
|
// handle: {view, updateSchema(conf), destroy(), get value(), set value(v)}
|
||||||
|
export function createSqlEditor(parent, opts = {}) {
|
||||||
|
const {
|
||||||
|
doc = "",
|
||||||
|
schema,
|
||||||
|
defaultTable,
|
||||||
|
defaultSchema,
|
||||||
|
history: withHistory = true,
|
||||||
|
onHostUndo,
|
||||||
|
onHostRedo,
|
||||||
|
extensions = [],
|
||||||
|
fixedTooltips = false,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onEscape,
|
||||||
|
lineWrapping = true,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const sqlCompartment = new Compartment();
|
||||||
|
|
||||||
|
// Highest-precedence keymap so submit/escape win over the basic keymap.
|
||||||
|
const priorityBindings = [];
|
||||||
|
if (onSubmit) {
|
||||||
|
const runSubmit = () => {
|
||||||
|
onSubmit(view);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
priorityBindings.push(
|
||||||
|
{ key: "Mod-Enter", run: runSubmit },
|
||||||
|
{ key: "Shift-Enter", run: runSubmit },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (onEscape) {
|
||||||
|
priorityBindings.push({
|
||||||
|
key: "Escape",
|
||||||
|
run: () => {
|
||||||
|
onEscape(view);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorExtensions = [
|
||||||
|
Prec.highest(keymap.of(priorityBindings)),
|
||||||
|
...baseSetup(withHistory, onHostUndo, onHostRedo),
|
||||||
|
lineWrapping ? EditorView.lineWrapping : [],
|
||||||
|
fixedTooltips ? tooltips({ position: "fixed" }) : [],
|
||||||
|
sqlCompartment.of(sqlExtension({ schema, defaultTable, defaultSchema })),
|
||||||
|
onChange
|
||||||
|
? EditorView.updateListener.of((update) => {
|
||||||
|
if (!update.docChanged) return;
|
||||||
|
// Suppress echoes of host-originated changes.
|
||||||
|
if (update.transactions.some((tr) => tr.annotation(hostChange))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(update);
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
...extensions,
|
||||||
|
];
|
||||||
|
|
||||||
|
let view = new EditorView({
|
||||||
|
doc,
|
||||||
|
extensions: editorExtensions,
|
||||||
|
...(parent ? { parent } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
// Swap out the schema/defaultTable/defaultSchema used for autocomplete after
|
||||||
|
// the editor has been created.
|
||||||
|
// https://codemirror.net/examples/config/#dynamic-configuration
|
||||||
|
updateSchema(conf) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: sqlCompartment.reconfigure(sqlExtension(conf)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
view.destroy();
|
||||||
|
},
|
||||||
|
get value() {
|
||||||
|
return view.state.doc.toString();
|
||||||
|
},
|
||||||
|
// Host-originated: tagged with hostChange so it does not re-fire onChange.
|
||||||
|
set value(newValue) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: newValue },
|
||||||
|
annotations: hostChange.of(true),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps ticket 05's neutral editor-schema shape
|
||||||
|
// {tables: [{name, view: bool, columns: [{name, type}]}]}
|
||||||
|
// to a lang-sql SQLNamespace of Completion objects. Kept identical to
|
||||||
|
// _editor_schema() / _column_completion() in datasette/views/query_helpers.py so
|
||||||
|
// server-inlined and client-fetched schemas behave the same.
|
||||||
|
function columnCompletion(name, type) {
|
||||||
|
const completion = { label: name, type: "property", boost: 10 };
|
||||||
|
if (type) {
|
||||||
|
completion.detail = type;
|
||||||
|
}
|
||||||
|
return completion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schemaFromTables(tables) {
|
||||||
|
const schema = {};
|
||||||
|
for (const table of tables || []) {
|
||||||
|
const completions = (table.columns || []).map((column) =>
|
||||||
|
columnCompletion(column.name, column.type),
|
||||||
|
);
|
||||||
|
if (table.view) {
|
||||||
|
schema[table.name] = {
|
||||||
|
self: { label: table.name, type: "class", detail: "view" },
|
||||||
|
children: completions,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
schema[table.name] = completions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// datasetteSchema(baseUrl, database) -> Promise<SQLNamespace>
|
||||||
|
//
|
||||||
|
// Fetches GET {baseUrl}/{database}/-/editor-schema.json (ticket 05) and maps the
|
||||||
|
// neutral payload to a lang-sql SQLNamespace ready to pass as opts.schema /
|
||||||
|
// updateSchema({schema}). baseUrl is Datasette's base_url (may be "" or "/" or a
|
||||||
|
// mount prefix). Throws a descriptive Error on a non-200 response.
|
||||||
|
export async function datasetteSchema(baseUrl, database) {
|
||||||
|
const base = (baseUrl || "").replace(/\/+$/, "");
|
||||||
|
const url = `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
|
||||||
|
const response = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`datasetteSchema: failed to fetch ${url} (${response.status} ${response.statusText})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return schemaFromTables(data.tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOnlyState(ro) -> extensions that toggle editability. EditorState.readOnly
|
||||||
|
// blocks document edits; EditorView.editable additionally drops the
|
||||||
|
// contenteditable attribute so screen readers and the cursor reflect the
|
||||||
|
// read-only state.
|
||||||
|
function readOnlyState(ro) {
|
||||||
|
return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme that plumbs a small set of CSS custom properties into the editor so
|
||||||
|
// embedders can restyle without reaching into CodeMirror internals. The fallbacks
|
||||||
|
// reproduce Datasette's current editor appearance (monospace family, the
|
||||||
|
// page-inherited font size, a transparent background over the page/plugin
|
||||||
|
// background) so mounting the element on an existing Datasette page is visually a
|
||||||
|
// no-op — including in dark mode, which Datasette implements entirely through the
|
||||||
|
// surrounding page's colors, not editor-specific CSS. CodeMirror themes are static
|
||||||
|
// CSS-in-JS, but var() references pass straight through to the generated rules and
|
||||||
|
// resolve at render time, so an embedder's :root / @media (prefers-color-scheme)
|
||||||
|
// overrides just work.
|
||||||
|
const sqlEditorTheme = EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
fontSize: "var(--datasette-sql-editor-font-size, inherit)",
|
||||||
|
background: "var(--datasette-sql-editor-bg, transparent)",
|
||||||
|
},
|
||||||
|
".cm-content, .cm-gutters": {
|
||||||
|
fontFamily: "var(--datasette-sql-editor-font-family, monospace)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// <datasette-sql-editor> — a form-associated, light-DOM custom element wrapping
|
||||||
|
// createSqlEditor(). The module auto-registers the default tag on import (see the
|
||||||
|
// bottom of this file); call registerSqlEditorElement("my-tag") to also/instead
|
||||||
|
// register it under a different name.
|
||||||
|
//
|
||||||
|
// Attributes (all optional):
|
||||||
|
// name form-field name for the submitted SQL (form participation)
|
||||||
|
// database Datasette database name; when set and schema-url is absent the
|
||||||
|
// schema URL is derived as
|
||||||
|
// {base-url}/{database}/-/editor-schema.json
|
||||||
|
// base-url Datasette base_url prefix used for the derived schema URL ("")
|
||||||
|
// schema-url explicit URL returning the neutral {tables:[...]} schema payload
|
||||||
|
// default-table unqualified-column default table for autocomplete
|
||||||
|
// readonly boolean; mounts the editor read-only
|
||||||
|
// autofocus boolean; focuses the editor once mounted
|
||||||
|
// The initial document is, in priority order: a programmatically-set value; the
|
||||||
|
// value of a light-DOM <textarea> first child (a progressive-enhancement form
|
||||||
|
// field that keeps working with JS disabled - it is adopted then removed); or
|
||||||
|
// the element's trimmed textContent. The light DOM is cleared on mount.
|
||||||
|
//
|
||||||
|
// Properties:
|
||||||
|
// value get/set the document (set is host-tagged: no "input" event)
|
||||||
|
// schema set -> updateSchema({schema, defaultTable})
|
||||||
|
// view get the raw EditorView escape hatch (null before mount)
|
||||||
|
// readOnly get/set via a Compartment
|
||||||
|
// extensions get/set extra CodeMirror extensions; honored ONLY before the
|
||||||
|
// element connects (createSqlEditor builds the extension set once)
|
||||||
|
// Methods: focus(), updateSchema(conf), format().
|
||||||
|
// Events (all bubble):
|
||||||
|
// input {detail:{origin:"user"}} on user edits (host edits suppressed)
|
||||||
|
// submit cancelable; default action requestSubmit()s internals.form
|
||||||
|
// ready once mounted (schema may still be fetching — see below)
|
||||||
|
// editor-escape on Escape at the editor top level
|
||||||
|
export class DatasetteSqlEditorElement extends HTMLElement {
|
||||||
|
static formAssociated = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._handle = null;
|
||||||
|
this._internals = null;
|
||||||
|
this._readOnly = false;
|
||||||
|
this._readOnlyCompartment = new Compartment();
|
||||||
|
this._extensions = [];
|
||||||
|
this._pendingDoc = null;
|
||||||
|
this._initialDoc = "";
|
||||||
|
// attachInternals is guarded: Safari < 16.4 has no ElementInternals. Without
|
||||||
|
// it the editor still works fully, but form participation
|
||||||
|
// (setFormValue/reset) is a no-op, so this field degrades to contributing
|
||||||
|
// nothing on submit. Documented as an accepted graceful degradation.
|
||||||
|
try {
|
||||||
|
this._internals = this.attachInternals ? this.attachInternals() : null;
|
||||||
|
} catch (err) {
|
||||||
|
this._internals = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this._handle) return; // already mounted (e.g. move within the DOM)
|
||||||
|
|
||||||
|
// Parser-timing guard. When this element's definition loads BEFORE the parser
|
||||||
|
// reaches the element (e.g. a non-deferred <script> in <head>, which is how
|
||||||
|
// Datasette's own pages load the bundle), the browser upgrades and connects
|
||||||
|
// the element at its start tag - before its light-DOM children (the initial
|
||||||
|
// document / fallback <textarea>) have been parsed. Reading them now would
|
||||||
|
// yield an empty document. Detect that case - still parsing, nothing set
|
||||||
|
// programmatically, no children yet - and defer mounting to DOMContentLoaded,
|
||||||
|
// by which point the element's subtree is fully parsed. The listener is
|
||||||
|
// registered as the parser sees this element (before any later inline
|
||||||
|
// script's DOMContentLoaded handler), so consumers reading .view on
|
||||||
|
// DOMContentLoaded still observe a mounted editor.
|
||||||
|
if (
|
||||||
|
this.ownerDocument.readyState === "loading" &&
|
||||||
|
this._pendingDoc == null &&
|
||||||
|
!this.firstChild
|
||||||
|
) {
|
||||||
|
this.ownerDocument.addEventListener(
|
||||||
|
"DOMContentLoaded",
|
||||||
|
() => this.connectedCallback(),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progressive-enhancement fallback: a light-DOM <textarea> first child is a
|
||||||
|
// real form field that keeps working with JavaScript disabled. When present,
|
||||||
|
// adopt its value as the initial document and remove it, so it does not also
|
||||||
|
// submit a duplicate field alongside the value the element contributes via
|
||||||
|
// setFormValue.
|
||||||
|
const firstEl = this.firstElementChild;
|
||||||
|
const fallbackTextarea =
|
||||||
|
firstEl && firstEl.tagName === "TEXTAREA" ? firstEl : null;
|
||||||
|
let fallbackDoc = null;
|
||||||
|
if (fallbackTextarea) {
|
||||||
|
fallbackDoc = fallbackTextarea.value;
|
||||||
|
fallbackTextarea.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialDoc =
|
||||||
|
this._pendingDoc != null
|
||||||
|
? this._pendingDoc
|
||||||
|
: fallbackDoc != null
|
||||||
|
? fallbackDoc
|
||||||
|
: this.textContent.trim();
|
||||||
|
this._initialDoc = initialDoc;
|
||||||
|
this._pendingDoc = null;
|
||||||
|
// Clear the light-DOM text so it doesn't render behind the editor.
|
||||||
|
this.textContent = "";
|
||||||
|
|
||||||
|
this._readOnly = this.hasAttribute("readonly");
|
||||||
|
const defaultTable = this.getAttribute("default-table") || undefined;
|
||||||
|
|
||||||
|
this._handle = createSqlEditor(this, {
|
||||||
|
doc: initialDoc,
|
||||||
|
defaultTable,
|
||||||
|
extensions: [
|
||||||
|
sqlEditorTheme,
|
||||||
|
this._readOnlyCompartment.of(readOnlyState(this._readOnly)),
|
||||||
|
...(this._extensions || []),
|
||||||
|
],
|
||||||
|
onChange: () => {
|
||||||
|
this._syncFormValue();
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("input", {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { origin: "user" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSubmit: () => {
|
||||||
|
const proceed = this.dispatchEvent(
|
||||||
|
new CustomEvent("submit", { bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
if (!proceed) return; // default prevented
|
||||||
|
const form = this._internals && this._internals.form;
|
||||||
|
if (!form) return;
|
||||||
|
// requestSubmit() runs constraint validation and submit handlers, exactly
|
||||||
|
// like clicking a submit button; fall back to submit() where unsupported.
|
||||||
|
if (typeof form.requestSubmit === "function") {
|
||||||
|
form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEscape: () => {
|
||||||
|
this.dispatchEvent(new CustomEvent("editor-escape", { bubbles: true }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this._syncFormValue();
|
||||||
|
|
||||||
|
// Fetch schema (if configured) without blocking the editor: a failure
|
||||||
|
// downgrades to keyword-only completion and never breaks editing.
|
||||||
|
const schemaUrl = this._resolveSchemaUrl();
|
||||||
|
if (schemaUrl) {
|
||||||
|
fetch(schemaUrl, { credentials: "same-origin" })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`schema fetch ${schemaUrl} -> ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
this.updateSchema({ schema: schemaFromTables(data.tables) });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
"datasette-sql-editor: schema fetch failed; keyword-only completion",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasAttribute("autofocus")) {
|
||||||
|
this._handle.view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "ready" fires after mount; schema may still be in flight (it applies later
|
||||||
|
// via updateSchema). Dispatched synchronously so listeners attached before the
|
||||||
|
// element is inserted observe it.
|
||||||
|
this.dispatchEvent(new CustomEvent("ready", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (this._handle) {
|
||||||
|
// Preserve the document across DOM moves (disconnect + reconnect):
|
||||||
|
// connectedCallback prefers _pendingDoc over textContent, and the dead
|
||||||
|
// editor's DOM must not be left behind to be misread as initial content.
|
||||||
|
this._pendingDoc = this._handle.value;
|
||||||
|
this._handle.destroy();
|
||||||
|
this._handle = null;
|
||||||
|
this.replaceChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formResetCallback() {
|
||||||
|
if (!this._handle) return;
|
||||||
|
this._handle.value = this._initialDoc; // hostChange-tagged: no "input" event
|
||||||
|
this._syncFormValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveSchemaUrl() {
|
||||||
|
const explicit = this.getAttribute("schema-url");
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const database = this.getAttribute("database");
|
||||||
|
if (!database) return null;
|
||||||
|
const base = (this.getAttribute("base-url") || "").replace(/\/+$/, "");
|
||||||
|
return `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncFormValue() {
|
||||||
|
if (this._internals && this._internals.setFormValue) {
|
||||||
|
this._internals.setFormValue(this.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- properties -------------------------------------------------------
|
||||||
|
get value() {
|
||||||
|
return this._handle ? this._handle.value : this._pendingDoc || "";
|
||||||
|
}
|
||||||
|
set value(newValue) {
|
||||||
|
const v = newValue == null ? "" : String(newValue);
|
||||||
|
if (this._handle) {
|
||||||
|
this._handle.value = v; // hostChange-tagged: suppresses the "input" event
|
||||||
|
this._syncFormValue();
|
||||||
|
} else {
|
||||||
|
this._pendingDoc = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set schema(ns) {
|
||||||
|
this.updateSchema({ schema: ns });
|
||||||
|
}
|
||||||
|
|
||||||
|
get view() {
|
||||||
|
return this._handle ? this._handle.view : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readOnly() {
|
||||||
|
return this._readOnly;
|
||||||
|
}
|
||||||
|
set readOnly(value) {
|
||||||
|
this._readOnly = !!value;
|
||||||
|
if (this._handle) {
|
||||||
|
this._handle.view.dispatch({
|
||||||
|
effects: this._readOnlyCompartment.reconfigure(
|
||||||
|
readOnlyState(this._readOnly),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get extensions() {
|
||||||
|
return this._extensions;
|
||||||
|
}
|
||||||
|
set extensions(exts) {
|
||||||
|
if (this._handle) {
|
||||||
|
console.warn(
|
||||||
|
"datasette-sql-editor: .extensions must be set before the element connects; ignoring",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._extensions = exts || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- methods ----------------------------------------------------------
|
||||||
|
focus() {
|
||||||
|
if (this._handle) this._handle.view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSchema(conf = {}) {
|
||||||
|
if (!this._handle) return;
|
||||||
|
// Merge in default-table so a bare {schema} update doesn't drop it (the
|
||||||
|
// compartment reconfigure replaces the whole sql() extension).
|
||||||
|
this._handle.updateSchema({
|
||||||
|
defaultTable: this.getAttribute("default-table") || undefined,
|
||||||
|
...conf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
format() {
|
||||||
|
const formatter =
|
||||||
|
typeof window !== "undefined" ? window.sqlFormatter : undefined;
|
||||||
|
if (!formatter || typeof formatter.format !== "function") {
|
||||||
|
console.warn(
|
||||||
|
"datasette-sql-editor: window.sqlFormatter is not loaded; format() is a no-op",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._handle) return;
|
||||||
|
const formatted = formatter.format(this.value);
|
||||||
|
this._handle.value = formatted; // hostChange-tagged full replace
|
||||||
|
this._syncFormValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerSqlEditorElement(tagName) — defines the element under tagName, guarding
|
||||||
|
// against double registration (customElements.define throws on a duplicate). A
|
||||||
|
// no-op in non-DOM contexts. Returns the tag name.
|
||||||
|
export function registerSqlEditorElement(tagName = "datasette-sql-editor") {
|
||||||
|
if (typeof customElements === "undefined") return tagName;
|
||||||
|
if (!customElements.get(tagName)) {
|
||||||
|
customElements.define(tagName, DatasetteSqlEditorElement);
|
||||||
|
}
|
||||||
|
return tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the CodeMirror pieces callers need so plugin code shares this
|
||||||
|
// module's single CM instance instead of bundling its own.
|
||||||
|
export {
|
||||||
|
EditorView,
|
||||||
|
EditorState,
|
||||||
|
Compartment,
|
||||||
|
Annotation,
|
||||||
|
Prec,
|
||||||
|
keymap,
|
||||||
|
tooltips,
|
||||||
|
sql,
|
||||||
|
SQLDialect,
|
||||||
|
autocompletion,
|
||||||
|
completionKeymap,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-register the default <datasette-sql-editor> tag on import. Guarded so
|
||||||
|
// importing this module in a non-DOM context (SSR/tests) or after another copy of
|
||||||
|
// the module already claimed the tag is a harmless no-op. This gives template and
|
||||||
|
// dogfood usage a zero-config element; plugins that want to compose the primitives
|
||||||
|
// without the element simply don't touch the tag. Register a differently-named tag
|
||||||
|
// with registerSqlEditorElement("my-tag").
|
||||||
|
if (
|
||||||
|
typeof customElements !== "undefined" &&
|
||||||
|
!customElements.get("datasette-sql-editor")
|
||||||
|
) {
|
||||||
|
registerSqlEditorElement("datasette-sql-editor");
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
import json
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from .utils import tilde_encode, urlsafe_components
|
from .utils import tilde_encode, urlsafe_components
|
||||||
|
|
||||||
|
|
@ -387,7 +386,7 @@ async def count_queries(
|
||||||
OR q.sql LIKE :query_search
|
OR q.sql LIKE :query_search
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
params["query_search"] = f"%{q}%"
|
params["query_search"] = "%{}%".format(q)
|
||||||
if is_write is not None:
|
if is_write is not None:
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
where_clauses.append("q.is_write = :query_is_write")
|
||||||
params["query_is_write"] = int(bool(is_write))
|
params["query_is_write"] = int(bool(is_write))
|
||||||
|
|
@ -463,7 +462,7 @@ async def list_queries(
|
||||||
except ValueError:
|
except ValueError:
|
||||||
components = []
|
components = []
|
||||||
if database is None and len(components) == 3:
|
if database is None and len(components) == 3:
|
||||||
where_clauses.append(f"""
|
where_clauses.append("""
|
||||||
(
|
(
|
||||||
q.database_name > :cursor_database
|
q.database_name > :cursor_database
|
||||||
OR (
|
OR (
|
||||||
|
|
@ -477,12 +476,12 @@ async def list_queries(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""")
|
""".format(sort_key_sql=sort_key_sql))
|
||||||
params["cursor_database"] = components[0]
|
params["cursor_database"] = components[0]
|
||||||
params["cursor_sort_key"] = components[1]
|
params["cursor_sort_key"] = components[1]
|
||||||
params["cursor_name"] = components[2]
|
params["cursor_name"] = components[2]
|
||||||
elif database is not None and len(components) == 2:
|
elif database is not None and len(components) == 2:
|
||||||
where_clauses.append(f"""
|
where_clauses.append("""
|
||||||
(
|
(
|
||||||
{sort_key_sql} > :cursor_sort_key
|
{sort_key_sql} > :cursor_sort_key
|
||||||
OR (
|
OR (
|
||||||
|
|
@ -490,7 +489,7 @@ async def list_queries(
|
||||||
AND q.name > :cursor_name
|
AND q.name > :cursor_name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""")
|
""".format(sort_key_sql=sort_key_sql))
|
||||||
params["cursor_sort_key"] = components[0]
|
params["cursor_sort_key"] = components[0]
|
||||||
params["cursor_name"] = components[1]
|
params["cursor_name"] = components[1]
|
||||||
|
|
||||||
|
|
@ -503,7 +502,7 @@ async def list_queries(
|
||||||
OR q.sql LIKE :query_search
|
OR q.sql LIKE :query_search
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
params["query_search"] = f"%{q}%"
|
params["query_search"] = "%{}%".format(q)
|
||||||
if is_write is not None:
|
if is_write is not None:
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
where_clauses.append("q.is_write = :query_is_write")
|
||||||
params["query_is_write"] = int(bool(is_write))
|
params["query_is_write"] = int(bool(is_write))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
|
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
|
||||||
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
|
<script src="{{ static('cm-editor.bundle.js') }}"></script>
|
||||||
<style>
|
<style>
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
resize: both;
|
resize: both;
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,21 @@
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
const sqlFormat = document.querySelector("button#sql-format");
|
const sqlFormat = document.querySelector("button#sql-format");
|
||||||
const readOnly = document.querySelector("pre#sql-query");
|
const readOnly = document.querySelector("pre#sql-query");
|
||||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
const editorElement = document.querySelector("datasette-sql-editor#sql-editor");
|
||||||
if (sqlFormat && !readOnly) {
|
if (sqlFormat && !readOnly) {
|
||||||
sqlFormat.hidden = false;
|
sqlFormat.hidden = false;
|
||||||
}
|
}
|
||||||
if (sqlInput) {
|
if (editorElement) {
|
||||||
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
|
// Rich lang-sql schema inlined server-side (see const schema above): drives
|
||||||
schema,
|
// first-paint autocomplete with no extra HTTP request and no flash of
|
||||||
}));
|
// no-completions. The default table is carried on the element attribute.
|
||||||
|
if (Object.keys(schema).length) {
|
||||||
|
editorElement.schema = schema;
|
||||||
|
}
|
||||||
|
// Back-compat: plugins and inline scripts reach the raw EditorView here.
|
||||||
|
window.editor = editorElement.view;
|
||||||
if (sqlFormat) {
|
if (sqlFormat) {
|
||||||
sqlFormat.addEventListener("click", (ev) => {
|
sqlFormat.addEventListener("click", () => editorElement.format());
|
||||||
const formatted = sqlFormatter.format(editor.state.doc.toString());
|
|
||||||
editor.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: editor.state.doc.length,
|
|
||||||
insert: formatted,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (sqlFormat && readOnly) {
|
if (sqlFormat && readOnly) {
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,8 @@
|
||||||
padding: 1.5em;
|
padding: 1.5em;
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
}
|
}
|
||||||
.permission-form form {
|
|
||||||
max-width: 60rem;
|
|
||||||
}
|
|
||||||
.permission-form-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.5rem;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
.permission-form-result {
|
|
||||||
margin-top: 1rem;
|
|
||||||
max-width: 60rem;
|
|
||||||
}
|
|
||||||
.form-section {
|
.form-section {
|
||||||
margin-bottom: 1.25em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
.form-section label {
|
.form-section label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -27,51 +15,22 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.form-section input[type="text"],
|
.form-section input[type="text"],
|
||||||
.form-section input[type="number"],
|
|
||||||
.form-section select,
|
|
||||||
.permission-textarea {
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #aaa;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
||||||
color: #222;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
max-width: none;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.form-section input[type="text"] {
|
|
||||||
height: 3rem;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
}
|
|
||||||
.form-section input[type="number"] {
|
|
||||||
height: 3rem;
|
|
||||||
max-width: 7rem;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
}
|
|
||||||
.form-section select {
|
.form-section select {
|
||||||
height: 3rem;
|
width: 100%;
|
||||||
padding: 0.6rem 0.75rem;
|
max-width: 500px;
|
||||||
}
|
padding: 0.5em;
|
||||||
.permission-textarea {
|
box-sizing: border-box;
|
||||||
font-family: monospace;
|
border: 1px solid #ccc;
|
||||||
min-height: 12rem;
|
border-radius: 3px;
|
||||||
padding: 0.75rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
}
|
||||||
.form-section input[type="text"]:focus,
|
.form-section input[type="text"]:focus,
|
||||||
.form-section input[type="number"]:focus,
|
.form-section select:focus {
|
||||||
.form-section select:focus,
|
outline: 2px solid #0066cc;
|
||||||
.permission-textarea:focus {
|
|
||||||
border-color: #0066cc;
|
border-color: #0066cc;
|
||||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
.form-section small {
|
.form-section small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.45em;
|
margin-top: 0.3em;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
|
|
@ -183,9 +142,4 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
@media only screen and (max-width: 576px) {
|
|
||||||
.permission-form-grid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,10 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<nav class="permissions-debug-tabs">
|
<nav class="permissions-debug-tabs">
|
||||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a>
|
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
|
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
|
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
|
||||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
|
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
|
||||||
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
||||||
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ form.sql .query-create-sql {
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
}
|
}
|
||||||
.query-create-sql .cm-editor,
|
.query-create-sql .cm-editor,
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
form.sql .query-create-sql textarea[name="sql"] {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +127,7 @@ form.sql .query-create-sql textarea#sql-editor {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.query-create-sql .cm-editor,
|
.query-create-sql .cm-editor,
|
||||||
form.sql .query-create-sql textarea#sql-editor {
|
form.sql .query-create-sql textarea[name="sql"] {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
.query-create-options,
|
.query-create-options,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ window.datasetteSqlParameters = (() => {
|
||||||
if (window.editor) {
|
if (window.editor) {
|
||||||
return window.editor.state.doc.toString();
|
return window.editor.state.doc.toString();
|
||||||
}
|
}
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
|
const sqlInput = form.querySelector(
|
||||||
|
"#sql-editor, textarea[name=sql], input[name=sql]"
|
||||||
|
);
|
||||||
return sqlInput ? sqlInput.value : "";
|
return sqlInput ? sqlInput.value : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +203,7 @@ window.datasetteSqlParameters = (() => {
|
||||||
editorElement.addEventListener("input", callback);
|
editorElement.addEventListener("input", callback);
|
||||||
}
|
}
|
||||||
if (!window.editor) {
|
if (!window.editor) {
|
||||||
const sqlInput = form.querySelector("textarea#sql-editor");
|
const sqlInput = form.querySelector("#sql-editor");
|
||||||
if (sqlInput) {
|
if (sqlInput) {
|
||||||
sqlInput.addEventListener("input", callback);
|
sqlInput.addEventListener("input", callback);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
form.sql .sql-editor {
|
form.sql .sql-editor {
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
}
|
}
|
||||||
form.sql .sql-editor textarea#sql-editor {
|
form.sql .sql-editor textarea[name="sql"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
form.sql .sql-parameters-section {
|
form.sql .sql-parameters-section {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,29 @@
|
||||||
{% block title %}Debug allow rules{% endblock %}
|
{% block title %}Debug allow rules{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% include "_permission_ui_styles.html" %}
|
|
||||||
<style>
|
<style>
|
||||||
|
textarea {
|
||||||
|
height: 10em;
|
||||||
|
width: 95%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5em;
|
||||||
|
border: 2px dotted black;
|
||||||
|
}
|
||||||
|
.two-col {
|
||||||
|
display: inline-block;
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
.two-col label {
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
p.message-warning {
|
p.message-warning {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
.two-col {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -20,28 +38,24 @@ p.message-warning {
|
||||||
|
|
||||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||||
|
|
||||||
<div class="permission-form">
|
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
|
<div class="two-col">
|
||||||
<div class="permission-form-grid">
|
<p><label>Allow block</label></p>
|
||||||
<div class="form-section">
|
<textarea name="allow">{{ allow_input }}</textarea>
|
||||||
<label for="allow-block">Allow block</label>
|
</div>
|
||||||
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
|
<div class="two-col">
|
||||||
</div>
|
<p><label>Actor</label></p>
|
||||||
<div class="form-section">
|
<textarea name="actor">{{ actor_input }}</textarea>
|
||||||
<label for="allow-actor">Actor</label>
|
</div>
|
||||||
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
|
<div style="margin-top: 1em;">
|
||||||
</div>
|
<input type="submit" value="Apply allow block to actor">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
</form>
|
||||||
<button type="submit" class="submit-btn">Apply allow block to actor</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
|
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
|
||||||
|
|
||||||
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
|
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
|
||||||
|
|
||||||
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
|
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
{% 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" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||||
<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 class="sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></datasette-sql-editor></p>
|
||||||
{% set parameter_names = [] %}
|
{% set parameter_names = [] %}
|
||||||
{% set parameter_values = {} %}
|
{% set parameter_values = {} %}
|
||||||
{% set sql_parameters_allow_expand = false %}
|
{% set sql_parameters_allow_expand = false %}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Explain a permission decision{% endblock %}
|
{% block title %}Permission Check{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
|
|
@ -13,35 +13,29 @@
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
#output.allowed {
|
#output.allowed {
|
||||||
background-color: #f3fbf4;
|
background-color: #e8f5e9;
|
||||||
border: 2px solid #4caf50;
|
border: 2px solid #4caf50;
|
||||||
}
|
}
|
||||||
#output.denied {
|
#output.denied {
|
||||||
background-color: #fff7f7;
|
background-color: #ffebee;
|
||||||
border: 2px solid #f44336;
|
border: 2px solid #f44336;
|
||||||
}
|
}
|
||||||
#output h2 {
|
#output h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
#output h3 {
|
#output .result-badge {
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
#output .result-badge,
|
|
||||||
.effect-badge,
|
|
||||||
.rule-status {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.2em 0.5em;
|
padding: 0.3em 0.8em;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
#output .allowed-badge,
|
#output .allowed-badge {
|
||||||
.effect-allow {
|
background-color: #4caf50;
|
||||||
background-color: #2e7d32;
|
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
#output .denied-badge,
|
#output .denied-badge {
|
||||||
.effect-deny {
|
background-color: #f44336;
|
||||||
background-color: #c62828;
|
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.details-section {
|
.details-section {
|
||||||
|
|
@ -54,130 +48,70 @@
|
||||||
.details-section dd {
|
.details-section dd {
|
||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
}
|
}
|
||||||
.explanation-section {
|
|
||||||
background: rgba(255, 255, 255, 0.75);
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-top: 1em;
|
|
||||||
padding: 0 1em 1em;
|
|
||||||
}
|
|
||||||
.rules-table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.rules-table th,
|
|
||||||
.rules-table td {
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
padding: 0.5em;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.rule-status {
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #1b5e20;
|
|
||||||
}
|
|
||||||
.rule-ignored {
|
|
||||||
background: #eee;
|
|
||||||
color: #555;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.requirement-allowed {
|
|
||||||
color: #1b5e20;
|
|
||||||
}
|
|
||||||
.requirement-denied {
|
|
||||||
color: #b71c1c;
|
|
||||||
}
|
|
||||||
@media only screen and (max-width: 576px) {
|
|
||||||
.rules-table,
|
|
||||||
.rules-table tbody,
|
|
||||||
.rules-table tr,
|
|
||||||
.rules-table td {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.rules-table thead {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.rules-table td::before {
|
|
||||||
content: attr(data-label) ": ";
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Explain a permission decision</h1>
|
<h1>Permission check</h1>
|
||||||
|
|
||||||
{% set current_tab = "check" %}
|
{% set current_tab = "check" %}
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% include "_permissions_debug_tabs.html" %}
|
||||||
|
|
||||||
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p>
|
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||||
|
|
||||||
|
{% if request.actor %}
|
||||||
|
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||||
|
{% else %}
|
||||||
|
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="permission-form">
|
<div class="permission-form">
|
||||||
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
|
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="actor">Actor JSON:</label>
|
<label for="action">Action (permission name):</label>
|
||||||
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
|
|
||||||
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<label for="action">Action:</label>
|
|
||||||
<select id="action" name="action" required>
|
<select id="action" name="action" required>
|
||||||
<option value="">Select an action...</option>
|
<option value="">Select an action...</option>
|
||||||
{% for action in actions %}
|
{% for action_name in sorted_actions %}
|
||||||
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
|
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<small id="action-help">The operation to evaluate</small>
|
<small>The permission action to check</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section" id="parent-section">
|
<div class="form-section">
|
||||||
<label for="parent">Parent resource:</label>
|
<label for="parent">Parent resource (optional):</label>
|
||||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||||
<small>The database or other parent resource</small>
|
<small>For database-level permissions, specify the database name</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section" id="child-section">
|
<div class="form-section">
|
||||||
<label for="child">Child resource:</label>
|
<label for="child">Child resource (optional):</label>
|
||||||
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
|
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||||
<small>The table, query or other child resource</small>
|
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
|
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="output" style="display: none;">
|
<div id="output" style="display: none;">
|
||||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||||
<p id="result-summary"></p>
|
|
||||||
|
|
||||||
<dl class="details-section">
|
<dl class="details-section">
|
||||||
<dt>Actor:</dt>
|
|
||||||
<dd><code id="result-actor"></code></dd>
|
|
||||||
<dt>Action:</dt>
|
<dt>Action:</dt>
|
||||||
<dd><code id="result-action"></code></dd>
|
<dd id="result-action"></dd>
|
||||||
<dt>Resource:</dt>
|
|
||||||
<dd><code id="result-resource"></code></dd>
|
<dt>Resource Path:</dt>
|
||||||
|
<dd id="result-resource"></dd>
|
||||||
|
|
||||||
|
<dt>Actor ID:</dt>
|
||||||
|
<dd id="result-actor"></dd>
|
||||||
|
|
||||||
|
<div id="additional-details"></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<section class="explanation-section">
|
|
||||||
<h3>Matching rules</h3>
|
|
||||||
<div id="matching-rules"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="explanation-section" id="restrictions-section">
|
|
||||||
<h3>Actor restrictions</h3>
|
|
||||||
<div id="restriction-results"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="explanation-section" id="requirements-section">
|
|
||||||
<h3>Required actions</h3>
|
|
||||||
<div id="requirement-results"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details style="margin-top: 1em;">
|
<details style="margin-top: 1em;">
|
||||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||||
|
|
@ -185,134 +119,152 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
|
|
||||||
const form = document.getElementById('check-form');
|
const form = document.getElementById('check-form');
|
||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
const actionSelect = document.getElementById('action');
|
|
||||||
|
|
||||||
function updateResourceFields() {
|
|
||||||
const action = actions[actionSelect.value];
|
|
||||||
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
|
|
||||||
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
|
|
||||||
let help = action && action.description ? action.description : 'The operation to evaluate';
|
|
||||||
if (action && action.also_requires) {
|
|
||||||
help += `; also requires ${action.also_requires}`;
|
|
||||||
}
|
|
||||||
document.getElementById('action-help').textContent = help;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performCheck() {
|
async function performCheck() {
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Explaining...';
|
submitBtn.textContent = 'Checking...';
|
||||||
const params = new URLSearchParams(new FormData(form));
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||||
headers: {'Accept': 'application/json'}
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
displayResult(data);
|
displayResult(data);
|
||||||
} else {
|
} else {
|
||||||
displayError(data);
|
displayError(data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
displayError({error: error.message});
|
alert('Error: ' + error.message);
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = 'Explain decision';
|
submitBtn.textContent = 'Check Permission';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate form on initial load
|
||||||
|
(function() {
|
||||||
|
const params = populateFormFromURL();
|
||||||
|
const action = params.get('action');
|
||||||
|
if (action) {
|
||||||
|
performCheck();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
function displayResult(data) {
|
function displayResult(data) {
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
|
|
||||||
|
// Set badge and styling
|
||||||
const resultBadge = document.getElementById('result-badge');
|
const resultBadge = document.getElementById('result-badge');
|
||||||
output.className = data.allowed ? 'allowed' : 'denied';
|
if (data.allowed) {
|
||||||
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
|
output.className = 'allowed';
|
||||||
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
resultBadge.className = 'result-badge allowed-badge';
|
||||||
document.getElementById('result-summary').textContent = data.explanation.summary;
|
resultBadge.textContent = 'ALLOWED ✓';
|
||||||
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
|
} else {
|
||||||
document.getElementById('result-action').textContent = data.action;
|
output.className = 'denied';
|
||||||
document.getElementById('result-resource').textContent = data.resource.path;
|
resultBadge.className = 'result-badge denied-badge';
|
||||||
displayRules(data.explanation);
|
resultBadge.textContent = 'DENIED ✗';
|
||||||
displayRestrictions(data.explanation.restrictions);
|
}
|
||||||
displayRequirements(data.explanation.required_actions);
|
|
||||||
|
// Basic details
|
||||||
|
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||||
|
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||||
|
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||||
|
|
||||||
|
// Additional details
|
||||||
|
const additionalDetails = document.getElementById('additional-details');
|
||||||
|
additionalDetails.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.reason !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Reason:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.reason || 'N/A';
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.source_plugin !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Source Plugin:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.source_plugin || 'N/A';
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.used_default !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Used Default:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.depth !== undefined) {
|
||||||
|
const dt = document.createElement('dt');
|
||||||
|
dt.textContent = 'Depth:';
|
||||||
|
const dd = document.createElement('dd');
|
||||||
|
dd.textContent = data.depth;
|
||||||
|
additionalDetails.appendChild(dt);
|
||||||
|
additionalDetails.appendChild(dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw JSON
|
||||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
}
|
|
||||||
|
|
||||||
function displayRules(explanation) {
|
// Scroll to output
|
||||||
const container = document.getElementById('matching-rules');
|
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
if (!explanation.matched_rules.length) {
|
|
||||||
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
|
|
||||||
for (const rule of explanation.matched_rules) {
|
|
||||||
const status = rule.decisive
|
|
||||||
? '<span class="rule-status">Decisive</span>'
|
|
||||||
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
|
|
||||||
html += '<tr>';
|
|
||||||
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
|
|
||||||
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
|
|
||||||
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
|
|
||||||
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
|
|
||||||
html += `<td data-label="Role in decision">${status}</td>`;
|
|
||||||
html += '</tr>';
|
|
||||||
}
|
|
||||||
container.innerHTML = html + '</tbody></table>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayRestrictions(restrictions) {
|
|
||||||
const section = document.getElementById('restrictions-section');
|
|
||||||
const container = document.getElementById('restriction-results');
|
|
||||||
section.style.display = restrictions.length ? 'block' : 'none';
|
|
||||||
container.innerHTML = restrictions.map(restriction => {
|
|
||||||
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
|
|
||||||
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
|
|
||||||
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayRequirements(requirements) {
|
|
||||||
const section = document.getElementById('requirements-section');
|
|
||||||
const container = document.getElementById('requirement-results');
|
|
||||||
section.style.display = requirements.length ? 'block' : 'none';
|
|
||||||
container.innerHTML = requirements.map(requirement => {
|
|
||||||
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
|
|
||||||
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
|
||||||
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayError(data) {
|
function displayError(data) {
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
output.className = 'denied';
|
output.className = 'denied';
|
||||||
|
|
||||||
const resultBadge = document.getElementById('result-badge');
|
const resultBadge = document.getElementById('result-badge');
|
||||||
resultBadge.className = 'result-badge denied-badge';
|
resultBadge.className = 'result-badge denied-badge';
|
||||||
resultBadge.textContent = 'ERROR';
|
resultBadge.textContent = 'ERROR';
|
||||||
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
|
|
||||||
document.getElementById('result-actor').textContent = '—';
|
document.getElementById('result-action').textContent = 'N/A';
|
||||||
document.getElementById('result-action').textContent = '—';
|
document.getElementById('result-resource').textContent = 'N/A';
|
||||||
document.getElementById('result-resource').textContent = '—';
|
document.getElementById('result-actor').textContent = 'N/A';
|
||||||
document.getElementById('matching-rules').innerHTML = '';
|
|
||||||
document.getElementById('restrictions-section').style.display = 'none';
|
const additionalDetails = document.getElementById('additional-details');
|
||||||
document.getElementById('requirements-section').style.display = 'none';
|
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||||
|
|
||||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
|
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', event => {
|
// Disable child input if parent is empty
|
||||||
event.preventDefault();
|
const parentInput = document.getElementById('parent');
|
||||||
performCheck();
|
const childInput = document.getElementById('child');
|
||||||
});
|
|
||||||
actionSelect.addEventListener('change', updateResourceFields);
|
|
||||||
|
|
||||||
(function initializeFromUrl() {
|
childInput.addEventListener('focus', () => {
|
||||||
const params = populateFormFromURL();
|
if (!parentInput.value) {
|
||||||
updateResourceFields();
|
alert('Please specify a parent resource first before adding a child resource.');
|
||||||
if (params.get('action')) {
|
parentInput.focus();
|
||||||
performCheck();
|
|
||||||
}
|
}
|
||||||
})();
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Permission activity{% endblock %}
|
{% block title %}Debug permissions{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
|
|
@ -20,45 +20,60 @@
|
||||||
.check-action, .check-when, .check-result {
|
.check-action, .check-when, .check-result {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
|
textarea {
|
||||||
|
height: 10em;
|
||||||
|
width: 95%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5em;
|
||||||
|
border: 2px dotted black;
|
||||||
|
}
|
||||||
|
.two-col {
|
||||||
|
display: inline-block;
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
.two-col label {
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
.two-col {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Permission activity</h1>
|
<h1>Permission playground</h1>
|
||||||
|
|
||||||
{% set current_tab = "permissions" %}
|
{% set current_tab = "permissions" %}
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% include "_permissions_debug_tabs.html" %}
|
||||||
|
|
||||||
<h2>Raw simulator</h2>
|
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||||
|
|
||||||
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
|
|
||||||
|
|
||||||
<div class="permission-form">
|
<div class="permission-form">
|
||||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||||
<div class="permission-form-grid">
|
<div class="two-col">
|
||||||
<div>
|
<div class="form-section">
|
||||||
<div class="form-section">
|
<label>Actor</label>
|
||||||
<label for="activity-actor">Actor</label>
|
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||||
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div class="form-section">
|
<div class="two-col" style="vertical-align: top">
|
||||||
<label for="permission">Action</label>
|
<div class="form-section">
|
||||||
<select name="permission" id="permission">
|
<label for="permission">Action</label>
|
||||||
{% for permission in permissions %}
|
<select name="permission" id="permission">
|
||||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
{% for permission in permissions %}
|
||||||
{% endfor %}
|
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
<div class="form-section">
|
</div>
|
||||||
<label for="resource_1">Parent</label>
|
<div class="form-section">
|
||||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
<label for="resource_1">Parent</label>
|
||||||
</div>
|
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||||
<div class="form-section">
|
</div>
|
||||||
<label for="resource_2">Child</label>
|
<div class="form-section">
|
||||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
<label for="resource_2">Child</label>
|
||||||
</div>
|
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|
@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h2>Recent permission checks</h2>
|
<h1>Recent permissions checks</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
||||||
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
|
||||||
|
|
||||||
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
||||||
{% set sql_parameters_allow_expand = true %}
|
{% set sql_parameters_allow_expand = true %}
|
||||||
|
|
@ -175,7 +175,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
const executeWriteSqlInput = document.querySelector("#sql-editor");
|
||||||
const form = document.querySelector("form.sql.core");
|
const form = document.querySelector("form.sql.core");
|
||||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
||||||
const submitButton = form
|
const submitButton = form
|
||||||
|
|
@ -261,7 +261,7 @@ window.addEventListener("DOMContentLoaded", () => {
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
const tableSelect = document.querySelector("#execute-write-template-table");
|
const tableSelect = document.querySelector("#execute-write-template-table");
|
||||||
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
||||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
const sqlInput = document.querySelector("#sql-editor");
|
||||||
|
|
||||||
function dataKey(operation) {
|
function dataKey(operation) {
|
||||||
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
|
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@
|
||||||
{% 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 class="sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea 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 }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></datasette-sql-editor></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 %}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></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>
|
</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-sql sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
|
||||||
|
|
||||||
<p class="query-create-options">
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></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>
|
</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-sql sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
|
||||||
|
|
||||||
<p class="query-create-options">
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if query.sql and allow_execute_sql %}
|
{% if query.sql and allow_execute_sql %}
|
||||||
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql, '_table': table}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
import itsdangerous
|
import itsdangerous
|
||||||
|
|
||||||
|
|
@ -50,24 +50,24 @@ class TokenRestrictions:
|
||||||
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
||||||
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
|
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
|
||||||
|
|
||||||
def allow_all(self, action: str) -> TokenRestrictions:
|
def allow_all(self, action: str) -> "TokenRestrictions":
|
||||||
"""Allow an action across all databases and resources."""
|
"""Allow an action across all databases and resources."""
|
||||||
self.all.append(action)
|
self.all.append(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def allow_database(self, database: str, action: str) -> TokenRestrictions:
|
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
|
||||||
"""Allow an action on a specific database."""
|
"""Allow an action on a specific database."""
|
||||||
self.database.setdefault(database, []).append(action)
|
self.database.setdefault(database, []).append(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def allow_resource(
|
def allow_resource(
|
||||||
self, database: str, resource: str, action: str
|
self, database: str, resource: str, action: str
|
||||||
) -> TokenRestrictions:
|
) -> "TokenRestrictions":
|
||||||
"""Allow an action on a specific resource within a database."""
|
"""Allow an action on a specific resource within a database."""
|
||||||
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) -> dict | None:
|
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Return the abbreviated ``_r`` dictionary shape for this set of
|
Return the abbreviated ``_r`` dictionary shape for this set of
|
||||||
restrictions, using action abbreviations registered with ``datasette``.
|
restrictions, using action abbreviations registered with ``datasette``.
|
||||||
|
|
@ -112,16 +112,16 @@ class TokenHandler:
|
||||||
|
|
||||||
async def create_token(
|
async def create_token(
|
||||||
self,
|
self,
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
*,
|
*,
|
||||||
expires_after: int | None = None,
|
expires_after: Optional[int] = None,
|
||||||
restrictions: TokenRestrictions | None = None,
|
restrictions: Optional[TokenRestrictions] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create and return a token string for the given actor."""
|
"""Create and return a token string for the given actor."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
|
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Verify a token and return an actor dict.
|
Verify a token and return an actor dict.
|
||||||
|
|
||||||
|
|
@ -142,11 +142,11 @@ class SignedTokenHandler(TokenHandler):
|
||||||
|
|
||||||
async def create_token(
|
async def create_token(
|
||||||
self,
|
self,
|
||||||
datasette: Datasette,
|
datasette: "Datasette",
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
*,
|
*,
|
||||||
expires_after: int | None = None,
|
expires_after: Optional[int] = None,
|
||||||
restrictions: TokenRestrictions | None = None,
|
restrictions: Optional[TokenRestrictions] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not datasette.setting("allow_signed_tokens"):
|
if not datasette.setting("allow_signed_tokens"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -163,7 +163,7 @@ class SignedTokenHandler(TokenHandler):
|
||||||
token["_r"] = abbreviated
|
token["_r"] = abbreviated
|
||||||
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) -> dict | None:
|
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||||
prefix = "dstok_"
|
prefix = "dstok_"
|
||||||
|
|
||||||
if not token.startswith(prefix):
|
if not token.startswith(prefix):
|
||||||
|
|
@ -200,8 +200,9 @@ class SignedTokenHandler(TokenHandler):
|
||||||
):
|
):
|
||||||
duration = max_signed_tokens_ttl
|
duration = max_signed_tokens_ttl
|
||||||
|
|
||||||
if duration and time.time() - created > duration:
|
if duration:
|
||||||
raise TokenInvalid("Token has expired")
|
if time.time() - created > duration:
|
||||||
|
raise TokenInvalid("Token has expired")
|
||||||
|
|
||||||
actor = {"id": decoded["a"], "token": "dstok"}
|
actor = {"id": decoded["a"], "token": "dstok"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
|
||||||
tracers = {}
|
tracers = {}
|
||||||
|
|
||||||
|
|
@ -133,17 +132,17 @@ class AsgiTracer:
|
||||||
"num_traces": len(traces),
|
"num_traces": len(traces),
|
||||||
"traces": traces,
|
"traces": traces,
|
||||||
}
|
}
|
||||||
content_type = next(
|
try:
|
||||||
(
|
content_type = [
|
||||||
v.decode("utf8")
|
v.decode("utf8")
|
||||||
for k, v in response_headers
|
for k, v in response_headers
|
||||||
if k.lower() == b"content-type"
|
if k.lower() == b"content-type"
|
||||||
),
|
][0]
|
||||||
"",
|
except IndexError:
|
||||||
)
|
content_type = ""
|
||||||
if "text/html" in content_type and b"</body>" in accumulated_body:
|
if "text/html" in content_type and b"</body>" in accumulated_body:
|
||||||
extra = escape(json.dumps(trace_info, indent=2))
|
extra = escape(json.dumps(trace_info, indent=2))
|
||||||
extra_html = f"<pre>{extra}</pre></body>".encode()
|
extra_html = f"<pre>{extra}</pre></body>".encode("utf8")
|
||||||
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
|
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
|
||||||
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
||||||
data = json.loads(accumulated_body.decode("utf8"))
|
data = json.loads(accumulated_body.decode("utf8"))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
from .utils import tilde_encode, path_with_format, PrefixedUrlString
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from .utils import PrefixedUrlString, path_with_format, tilde_encode
|
|
||||||
|
|
||||||
|
|
||||||
class Urls:
|
class Urls:
|
||||||
def __init__(self, ds):
|
def __init__(self, ds):
|
||||||
|
|
@ -9,7 +8,8 @@ class Urls:
|
||||||
|
|
||||||
def path(self, path, format=None):
|
def path(self, path, format=None):
|
||||||
if not isinstance(path, PrefixedUrlString):
|
if not isinstance(path, PrefixedUrlString):
|
||||||
path = path.removeprefix("/")
|
if path.startswith("/"):
|
||||||
|
path = path[1:]
|
||||||
path = self.ds.setting("base_url") + path
|
path = self.ds.setting("base_url") + path
|
||||||
if format is not None:
|
if format is not None:
|
||||||
path = path_with_format(path=path, format=format)
|
path = path_with_format(path=path, format=format)
|
||||||
|
|
@ -56,7 +56,6 @@ class Urls:
|
||||||
return PrefixedUrlString(path)
|
return PrefixedUrlString(path)
|
||||||
|
|
||||||
def row_blob(self, database, table, row_path, column):
|
def row_blob(self, database, table, row_path, column):
|
||||||
return (
|
return self.table(database, table) + "/{}.blob?_blob_column={}".format(
|
||||||
self.table(database, table)
|
row_path, urllib.parse.quote_plus(column)
|
||||||
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,29 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
import binascii
|
import binascii
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import aiofiles
|
||||||
|
import click
|
||||||
|
from collections import OrderedDict, namedtuple, Counter
|
||||||
import copy
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import shlex
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import types
|
|
||||||
import typing
|
|
||||||
import urllib
|
|
||||||
from collections import Counter, OrderedDict, namedtuple
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
import click
|
|
||||||
import markupsafe
|
import markupsafe
|
||||||
import mergedeep
|
import mergedeep
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import tempfile
|
||||||
|
import typing
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
from typing import Iterable, List, Tuple
|
||||||
|
import urllib
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .shutil_backport import copytree
|
from .shutil_backport import copytree
|
||||||
from .sqlite import sqlite3, supports_table_xinfo
|
from .sqlite import sqlite3, supports_table_xinfo
|
||||||
|
|
||||||
|
|
@ -38,7 +36,7 @@ if typing.TYPE_CHECKING:
|
||||||
class PaginatedResources:
|
class PaginatedResources:
|
||||||
"""Paginated results from allowed_resources query."""
|
"""Paginated results from allowed_resources query."""
|
||||||
|
|
||||||
resources: list["Resource"]
|
resources: List["Resource"]
|
||||||
next: str | None # Keyset token for next page (None if no more results)
|
next: str | None # Keyset token for next page (None if no more results)
|
||||||
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
||||||
_action: str = dataclasses.field(default=None, repr=False)
|
_action: str = dataclasses.field(default=None, repr=False)
|
||||||
|
|
@ -85,132 +83,22 @@ class PaginatedResources:
|
||||||
|
|
||||||
|
|
||||||
# From https://www.sqlite.org/lang_keywords.html
|
# From https://www.sqlite.org/lang_keywords.html
|
||||||
reserved_words = {
|
reserved_words = set(
|
||||||
"abort",
|
(
|
||||||
"action",
|
"abort action add after all alter analyze and as asc attach autoincrement "
|
||||||
"add",
|
"before begin between by cascade case cast check collate column commit "
|
||||||
"after",
|
"conflict constraint create cross current_date current_time "
|
||||||
"all",
|
"current_timestamp database default deferrable deferred delete desc detach "
|
||||||
"alter",
|
"distinct drop each else end escape except exclusive exists explain fail "
|
||||||
"analyze",
|
"for foreign from full glob group having if ignore immediate in index "
|
||||||
"and",
|
"indexed initially inner insert instead intersect into is isnull join key "
|
||||||
"as",
|
"left like limit match natural no not notnull null of offset on or order "
|
||||||
"asc",
|
"outer plan pragma primary query raise recursive references regexp reindex "
|
||||||
"attach",
|
"release rename replace restrict right rollback row savepoint select set "
|
||||||
"autoincrement",
|
"table temp temporary then to transaction trigger union unique update using "
|
||||||
"before",
|
"vacuum values view virtual when where with without"
|
||||||
"begin",
|
).split()
|
||||||
"between",
|
)
|
||||||
"by",
|
|
||||||
"cascade",
|
|
||||||
"case",
|
|
||||||
"cast",
|
|
||||||
"check",
|
|
||||||
"collate",
|
|
||||||
"column",
|
|
||||||
"commit",
|
|
||||||
"conflict",
|
|
||||||
"constraint",
|
|
||||||
"create",
|
|
||||||
"cross",
|
|
||||||
"current_date",
|
|
||||||
"current_time",
|
|
||||||
"current_timestamp",
|
|
||||||
"database",
|
|
||||||
"default",
|
|
||||||
"deferrable",
|
|
||||||
"deferred",
|
|
||||||
"delete",
|
|
||||||
"desc",
|
|
||||||
"detach",
|
|
||||||
"distinct",
|
|
||||||
"drop",
|
|
||||||
"each",
|
|
||||||
"else",
|
|
||||||
"end",
|
|
||||||
"escape",
|
|
||||||
"except",
|
|
||||||
"exclusive",
|
|
||||||
"exists",
|
|
||||||
"explain",
|
|
||||||
"fail",
|
|
||||||
"for",
|
|
||||||
"foreign",
|
|
||||||
"from",
|
|
||||||
"full",
|
|
||||||
"glob",
|
|
||||||
"group",
|
|
||||||
"having",
|
|
||||||
"if",
|
|
||||||
"ignore",
|
|
||||||
"immediate",
|
|
||||||
"in",
|
|
||||||
"index",
|
|
||||||
"indexed",
|
|
||||||
"initially",
|
|
||||||
"inner",
|
|
||||||
"insert",
|
|
||||||
"instead",
|
|
||||||
"intersect",
|
|
||||||
"into",
|
|
||||||
"is",
|
|
||||||
"isnull",
|
|
||||||
"join",
|
|
||||||
"key",
|
|
||||||
"left",
|
|
||||||
"like",
|
|
||||||
"limit",
|
|
||||||
"match",
|
|
||||||
"natural",
|
|
||||||
"no",
|
|
||||||
"not",
|
|
||||||
"notnull",
|
|
||||||
"null",
|
|
||||||
"of",
|
|
||||||
"offset",
|
|
||||||
"on",
|
|
||||||
"or",
|
|
||||||
"order",
|
|
||||||
"outer",
|
|
||||||
"plan",
|
|
||||||
"pragma",
|
|
||||||
"primary",
|
|
||||||
"query",
|
|
||||||
"raise",
|
|
||||||
"recursive",
|
|
||||||
"references",
|
|
||||||
"regexp",
|
|
||||||
"reindex",
|
|
||||||
"release",
|
|
||||||
"rename",
|
|
||||||
"replace",
|
|
||||||
"restrict",
|
|
||||||
"right",
|
|
||||||
"rollback",
|
|
||||||
"row",
|
|
||||||
"savepoint",
|
|
||||||
"select",
|
|
||||||
"set",
|
|
||||||
"table",
|
|
||||||
"temp",
|
|
||||||
"temporary",
|
|
||||||
"then",
|
|
||||||
"to",
|
|
||||||
"transaction",
|
|
||||||
"trigger",
|
|
||||||
"union",
|
|
||||||
"unique",
|
|
||||||
"update",
|
|
||||||
"using",
|
|
||||||
"vacuum",
|
|
||||||
"values",
|
|
||||||
"view",
|
|
||||||
"virtual",
|
|
||||||
"when",
|
|
||||||
"where",
|
|
||||||
"with",
|
|
||||||
"without",
|
|
||||||
}
|
|
||||||
|
|
||||||
APT_GET_DOCKERFILE_EXTRAS = r"""
|
APT_GET_DOCKERFILE_EXTRAS = r"""
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
|
@ -270,7 +158,7 @@ functions_marked_as_documented = []
|
||||||
|
|
||||||
def documented(fn=None, *, label=None):
|
def documented(fn=None, *, label=None):
|
||||||
def decorate(fn):
|
def decorate(fn):
|
||||||
fn._datasette_docs_label = label or f"internals_utils_{fn.__name__}"
|
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
|
||||||
functions_marked_as_documented.append(fn)
|
functions_marked_as_documented.append(fn)
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
|
|
@ -472,7 +360,7 @@ disallawed_sql_res = [
|
||||||
(
|
(
|
||||||
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
||||||
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
|
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
|
||||||
", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas)
|
", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
@ -646,7 +534,10 @@ CMD {cmd}""".format(
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
environment_variables="\n".join(
|
environment_variables="\n".join(
|
||||||
[f"ENV {key} '{value}'" for key, value in environment_variables.items()]
|
[
|
||||||
|
"ENV {} '{}'".format(key, value)
|
||||||
|
for key, value in environment_variables.items()
|
||||||
|
]
|
||||||
),
|
),
|
||||||
install_from=" ".join(install),
|
install_from=" ".join(install),
|
||||||
files=" ".join(files),
|
files=" ".join(files),
|
||||||
|
|
@ -745,11 +636,11 @@ def detect_primary_keys(conn, table):
|
||||||
|
|
||||||
|
|
||||||
def get_outbound_foreign_keys(conn, table):
|
def get_outbound_foreign_keys(conn, table):
|
||||||
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
|
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
|
||||||
fks = []
|
fks = []
|
||||||
for info in infos:
|
for info in infos:
|
||||||
if info is not None:
|
if info is not None:
|
||||||
id, seq, table_name, from_, to_, _on_update, _on_delete, _match = info
|
id, seq, table_name, from_, to_, on_update, on_delete, match = info
|
||||||
fks.append(
|
fks.append(
|
||||||
{
|
{
|
||||||
"column": from_,
|
"column": from_,
|
||||||
|
|
@ -850,7 +741,7 @@ def detect_json1(conn=None):
|
||||||
try:
|
try:
|
||||||
conn.execute("SELECT json('{}')")
|
conn.execute("SELECT json('{}')")
|
||||||
return True
|
return True
|
||||||
except sqlite3.Error:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
if close_conn:
|
if close_conn:
|
||||||
|
|
@ -930,7 +821,9 @@ def is_url(value):
|
||||||
if not value.startswith("http://") and not value.startswith("https://"):
|
if not value.startswith("http://") and not value.startswith("https://"):
|
||||||
return False
|
return False
|
||||||
# Any whitespace at all is invalid
|
# Any whitespace at all is invalid
|
||||||
return not whitespace_re.search(value)
|
if whitespace_re.search(value):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
|
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
|
||||||
|
|
@ -983,9 +876,7 @@ def module_from_path(path, name):
|
||||||
mod.__file__ = path
|
mod.__file__ = path
|
||||||
with open(path, "r") as file:
|
with open(path, "r") as file:
|
||||||
code = compile(file.read(), path, "exec", dont_inherit=True)
|
code = compile(file.read(), path, "exec", dont_inherit=True)
|
||||||
# Executing the file is the whole point - this is how --plugins-dir loads
|
exec(code, mod.__dict__)
|
||||||
# plugins and how metadata/config .py files are evaluated
|
|
||||||
exec(code, mod.__dict__) # noqa: S102
|
|
||||||
return mod
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1142,7 +1033,9 @@ def escape_fts(query):
|
||||||
query += '"'
|
query += '"'
|
||||||
bits = _escape_fts_re.split(query)
|
bits = _escape_fts_re.split(query)
|
||||||
bits = [b for b in bits if b and b != '""']
|
bits = [b for b in bits if b and b != '""']
|
||||||
return " ".join(f'"{bit}"' if not bit.startswith('"') else bit for bit in bits)
|
return " ".join(
|
||||||
|
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MultiParams:
|
class MultiParams:
|
||||||
|
|
@ -1154,7 +1047,7 @@ class MultiParams:
|
||||||
data[key], (list, tuple)
|
data[key], (list, tuple)
|
||||||
), "dictionary data should be a dictionary of key => [list]"
|
), "dictionary data should be a dictionary of key => [list]"
|
||||||
self._data = data
|
self._data = data
|
||||||
elif isinstance(data, (list, tuple)):
|
elif isinstance(data, list) or isinstance(data, tuple):
|
||||||
new_data = {}
|
new_data = {}
|
||||||
for item in data:
|
for item in data:
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -1244,7 +1137,9 @@ def _gather_arguments(fn, kwargs):
|
||||||
for parameter in parameters:
|
for parameter in parameters:
|
||||||
if parameter not in kwargs:
|
if parameter not in kwargs:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"{fn} requires parameters {tuple(parameters)}, missing: {set(parameters) - set(kwargs.keys())}"
|
"{} requires parameters {}, missing: {}".format(
|
||||||
|
fn, tuple(parameters), set(parameters) - set(kwargs.keys())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
call_with.append(kwargs[parameter])
|
call_with.append(kwargs[parameter])
|
||||||
return call_with
|
return call_with
|
||||||
|
|
@ -1313,9 +1208,9 @@ def resolve_env_secrets(config, environ):
|
||||||
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
if list(config.keys()) == ["$env"]:
|
if list(config.keys()) == ["$env"]:
|
||||||
return environ.get(next(iter(config.values())))
|
return environ.get(list(config.values())[0])
|
||||||
elif list(config.keys()) == ["$file"]:
|
elif list(config.keys()) == ["$file"]:
|
||||||
with open(next(iter(config.values()))) as fp:
|
with open(list(config.values())[0]) as fp:
|
||||||
return fp.read()
|
return fp.read()
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
|
|
@ -1411,7 +1306,7 @@ _named_param_re = re.compile(r":(\w+)")
|
||||||
|
|
||||||
|
|
||||||
@documented
|
@documented
|
||||||
def named_parameters(sql: str) -> list[str]:
|
def named_parameters(sql: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Given a SQL statement, return a list of named parameters that are used in the statement
|
Given a SQL statement, return a list of named parameters that are used in the statement
|
||||||
|
|
||||||
|
|
@ -1424,7 +1319,7 @@ def named_parameters(sql: str) -> list[str]:
|
||||||
return _named_param_re.findall(sql)
|
return _named_param_re.findall(sql)
|
||||||
|
|
||||||
|
|
||||||
async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
|
async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
This undocumented but stable method exists for backwards compatibility
|
This undocumented but stable method exists for backwards compatibility
|
||||||
with plugins that were using it before it switched to named_parameters()
|
with plugins that were using it before it switched to named_parameters()
|
||||||
|
|
@ -1448,9 +1343,9 @@ def parse_size_limit(value, default, maximum, name="_size"):
|
||||||
if size < 0:
|
if size < 0:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"{name} must be a positive integer")
|
raise ValueError("{} must be a positive integer".format(name))
|
||||||
if size > maximum:
|
if size > maximum:
|
||||||
raise ValueError(f"{name} must be <= {maximum}")
|
raise ValueError("{} must be <= {}".format(name, maximum))
|
||||||
return size
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1508,7 +1403,7 @@ class TildeEncoder(dict):
|
||||||
elif b == _space:
|
elif b == _space:
|
||||||
res = "+"
|
res = "+"
|
||||||
else:
|
else:
|
||||||
res = f"~{b:02X}"
|
res = "~{:02X}".format(b)
|
||||||
self[b] = res
|
self[b] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
@ -1603,7 +1498,7 @@ def _combine(base: dict, update: dict) -> dict:
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def pairs_to_nested_config(pairs: list[tuple[str, typing.Any]]) -> dict:
|
def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
|
||||||
"""
|
"""
|
||||||
Parse a list of key-value pairs into a nested dictionary.
|
Parse a list of key-value pairs into a nested dictionary.
|
||||||
"""
|
"""
|
||||||
|
|
@ -1618,7 +1513,7 @@ def make_slot_function(name, datasette, request, **kwargs):
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
||||||
method = getattr(pm.hook, name, None)
|
method = getattr(pm.hook, name, None)
|
||||||
assert method is not None, f"No hook found for {name}"
|
assert method is not None, "No hook found for {}".format(name)
|
||||||
|
|
||||||
async def inner():
|
async def inner():
|
||||||
html_bits = []
|
html_bits = []
|
||||||
|
|
@ -1642,7 +1537,7 @@ def prune_empty_dicts(d: dict):
|
||||||
d.pop(key, None)
|
d.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
def move_plugins_and_allow(source: dict, destination: dict) -> tuple[dict, dict]:
|
def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
|
||||||
"""
|
"""
|
||||||
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
|
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
|
||||||
hierarchy in destination if needed. After moving, recursively remove any keys
|
hierarchy in destination if needed. After moving, recursively remove any keys
|
||||||
|
|
|
||||||
|
|
@ -252,62 +252,88 @@ async def _build_single_action_sql(
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Continue with the cascading logic.
|
# Continue with the cascading logic
|
||||||
# Aggregate the RULES by cascade level (small), rather than grouping
|
|
||||||
# base x rules (which scales with the number of resources).
|
|
||||||
def _agg(select_key, where, group_by):
|
|
||||||
parts = [
|
|
||||||
f" SELECT {select_key}",
|
|
||||||
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
|
||||||
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
|
||||||
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
|
|
||||||
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
|
|
||||||
f" FROM all_rules WHERE {where}",
|
|
||||||
]
|
|
||||||
if group_by:
|
|
||||||
parts.append(f" GROUP BY {group_by}")
|
|
||||||
return parts
|
|
||||||
|
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
["child_agg AS ("]
|
[
|
||||||
+ _agg(
|
"child_lvl AS (",
|
||||||
"parent, child,",
|
" SELECT b.parent, b.child,",
|
||||||
"parent IS NOT NULL AND child IS NOT NULL",
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
"parent, child",
|
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||||
)
|
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||||
+ ["),", "parent_agg AS ("]
|
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||||
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
" FROM base b",
|
||||||
+ ["),", "global_agg AS ("]
|
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||||
+ _agg("", "parent IS NULL AND child IS NULL", None)
|
" GROUP BY b.parent, b.child",
|
||||||
+ ["),"]
|
"),",
|
||||||
|
"parent_lvl AS (",
|
||||||
|
" SELECT b.parent, b.child,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||||
|
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||||
|
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||||
|
" FROM base b",
|
||||||
|
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||||
|
" GROUP BY b.parent, b.child",
|
||||||
|
"),",
|
||||||
|
"global_lvl AS (",
|
||||||
|
" SELECT b.parent, b.child,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||||
|
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
||||||
|
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
||||||
|
" FROM base b",
|
||||||
|
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||||
|
" GROUP BY b.parent, b.child",
|
||||||
|
"),",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add anonymous decision logic if needed
|
# Add anonymous decision logic if needed
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
|
|
||||||
def _anon_agg(select_key, where, group_by):
|
|
||||||
parts = [
|
|
||||||
f" SELECT {select_key}",
|
|
||||||
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
|
||||||
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
|
||||||
f" FROM anon_rules WHERE {where}",
|
|
||||||
]
|
|
||||||
if group_by:
|
|
||||||
parts.append(f" GROUP BY {group_by}")
|
|
||||||
return parts
|
|
||||||
|
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
["anon_child_agg AS ("]
|
[
|
||||||
+ _anon_agg(
|
"anon_child_lvl AS (",
|
||||||
"parent, child,",
|
" SELECT b.parent, b.child,",
|
||||||
"parent IS NOT NULL AND child IS NOT NULL",
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
"parent, child",
|
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||||
)
|
" FROM base b",
|
||||||
+ ["),", "anon_parent_agg AS ("]
|
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
||||||
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
" GROUP BY b.parent, b.child",
|
||||||
+ ["),", "anon_global_agg AS ("]
|
"),",
|
||||||
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
|
"anon_parent_lvl AS (",
|
||||||
+ ["),"]
|
" SELECT b.parent, b.child,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||||
|
" FROM base b",
|
||||||
|
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
|
||||||
|
" GROUP BY b.parent, b.child",
|
||||||
|
"),",
|
||||||
|
"anon_global_lvl AS (",
|
||||||
|
" SELECT b.parent, b.child,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
|
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||||
|
" FROM base b",
|
||||||
|
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
|
||||||
|
" GROUP BY b.parent, b.child",
|
||||||
|
"),",
|
||||||
|
"anon_decisions AS (",
|
||||||
|
" SELECT",
|
||||||
|
" b.parent, b.child,",
|
||||||
|
" CASE",
|
||||||
|
" WHEN acl.any_deny = 1 THEN 0",
|
||||||
|
" WHEN acl.any_allow = 1 THEN 1",
|
||||||
|
" WHEN apl.any_deny = 1 THEN 0",
|
||||||
|
" WHEN apl.any_allow = 1 THEN 1",
|
||||||
|
" WHEN agl.any_deny = 1 THEN 0",
|
||||||
|
" WHEN agl.any_allow = 1 THEN 1",
|
||||||
|
" ELSE 0",
|
||||||
|
" END AS anon_is_allowed",
|
||||||
|
" FROM base b",
|
||||||
|
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
|
||||||
|
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
|
||||||
|
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
|
||||||
|
"),",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Final decisions
|
# Final decisions
|
||||||
|
|
@ -316,28 +342,31 @@ async def _build_single_action_sql(
|
||||||
"decisions AS (",
|
"decisions AS (",
|
||||||
" SELECT",
|
" SELECT",
|
||||||
" b.parent, b.child,",
|
" b.parent, b.child,",
|
||||||
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
|
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
|
||||||
" -- Priority order:",
|
" -- Priority order:",
|
||||||
" -- 1. Child-level deny 2. Child-level allow",
|
" -- 1. Child-level deny (most specific, blocks access)",
|
||||||
" -- 3. Parent-level deny 4. Parent-level allow",
|
" -- 2. Child-level allow (most specific, grants access)",
|
||||||
" -- 5. Global-level deny 6. Global-level allow",
|
" -- 3. Parent-level deny (intermediate, blocks access)",
|
||||||
|
" -- 4. Parent-level allow (intermediate, grants access)",
|
||||||
|
" -- 5. Global-level deny (least specific, blocks access)",
|
||||||
|
" -- 6. Global-level allow (least specific, grants access)",
|
||||||
" -- 7. Default deny (no rules match)",
|
" -- 7. Default deny (no rules match)",
|
||||||
" CASE",
|
" CASE",
|
||||||
" WHEN ca.any_deny = 1 THEN 0",
|
" WHEN cl.any_deny = 1 THEN 0",
|
||||||
" WHEN ca.any_allow = 1 THEN 1",
|
" WHEN cl.any_allow = 1 THEN 1",
|
||||||
" WHEN pa.any_deny = 1 THEN 0",
|
" WHEN pl.any_deny = 1 THEN 0",
|
||||||
" WHEN pa.any_allow = 1 THEN 1",
|
" WHEN pl.any_allow = 1 THEN 1",
|
||||||
" WHEN ga.any_deny = 1 THEN 0",
|
" WHEN gl.any_deny = 1 THEN 0",
|
||||||
" WHEN ga.any_allow = 1 THEN 1",
|
" WHEN gl.any_allow = 1 THEN 1",
|
||||||
" ELSE 0",
|
" ELSE 0",
|
||||||
" END AS is_allowed,",
|
" END AS is_allowed,",
|
||||||
" CASE",
|
" CASE",
|
||||||
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
|
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
|
||||||
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
|
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
|
||||||
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
|
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
|
||||||
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
|
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
|
||||||
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
|
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
|
||||||
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
|
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
|
||||||
" ELSE '[]'",
|
" ELSE '[]'",
|
||||||
" END AS reason",
|
" END AS reason",
|
||||||
]
|
]
|
||||||
|
|
@ -345,34 +374,21 @@ async def _build_single_action_sql(
|
||||||
|
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
query_parts.append(
|
query_parts.append(
|
||||||
" , CASE WHEN ("
|
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
||||||
"CASE"
|
|
||||||
" WHEN aca.any_deny = 1 THEN 0"
|
|
||||||
" WHEN aca.any_allow = 1 THEN 1"
|
|
||||||
" WHEN apa.any_deny = 1 THEN 0"
|
|
||||||
" WHEN apa.any_allow = 1 THEN 1"
|
|
||||||
" WHEN aga.any_deny = 1 THEN 0"
|
|
||||||
" WHEN aga.any_allow = 1 THEN 1"
|
|
||||||
" ELSE 0 END"
|
|
||||||
") = 0 THEN 1 ELSE 0 END AS is_private"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
[
|
[
|
||||||
" FROM base b",
|
" FROM base b",
|
||||||
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
|
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
||||||
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
|
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
||||||
" CROSS JOIN global_agg ga",
|
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
query_parts.extend(
|
query_parts.append(
|
||||||
[
|
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
|
||||||
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
|
|
||||||
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
|
|
||||||
" CROSS JOIN anon_global_agg aga",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
query_parts.append(")")
|
query_parts.append(")")
|
||||||
|
|
@ -384,28 +400,8 @@ async def _build_single_action_sql(
|
||||||
restriction_intersect = "\nINTERSECT\n".join(
|
restriction_intersect = "\nINTERSECT\n".join(
|
||||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||||
)
|
)
|
||||||
# Decompose by NULL-pattern so the final filter can use pure-equality
|
|
||||||
# EXISTS lookups (satisfiable via automatic indexes) instead of a
|
|
||||||
# correlated OR-scan over the whole list.
|
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
[
|
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
|
||||||
",",
|
|
||||||
"restriction_list AS (",
|
|
||||||
f" {restriction_intersect}",
|
|
||||||
"),",
|
|
||||||
"restriction_exact AS (",
|
|
||||||
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
|
|
||||||
"),",
|
|
||||||
"restriction_parent_any AS (",
|
|
||||||
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
|
|
||||||
"),",
|
|
||||||
"restriction_child_any AS (",
|
|
||||||
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
|
|
||||||
"),",
|
|
||||||
"restriction_all AS (",
|
|
||||||
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
|
|
||||||
")",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Final SELECT
|
# Final SELECT
|
||||||
|
|
@ -420,11 +416,10 @@ async def _build_single_action_sql(
|
||||||
# Add restriction filter if there are restrictions
|
# Add restriction filter if there are restrictions
|
||||||
if restriction_sqls:
|
if restriction_sqls:
|
||||||
query_parts.append("""
|
query_parts.append("""
|
||||||
AND (
|
AND EXISTS (
|
||||||
EXISTS (SELECT 1 FROM restriction_all)
|
SELECT 1 FROM restriction_list r
|
||||||
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
|
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
|
||||||
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
|
AND (r.child = decisions.child OR r.child IS NULL)
|
||||||
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
|
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
# Add parent filter if specified
|
# Add parent filter if specified
|
||||||
|
|
@ -678,239 +673,3 @@ async def check_permission_for_resource(
|
||||||
child=child,
|
child=child,
|
||||||
)
|
)
|
||||||
return results[action]
|
return results[action]
|
||||||
|
|
||||||
|
|
||||||
async def explain_permission_for_resource(
|
|
||||||
*,
|
|
||||||
datasette: "Datasette",
|
|
||||||
actor: dict | None,
|
|
||||||
action: str,
|
|
||||||
parent: str | None,
|
|
||||||
child: str | None,
|
|
||||||
) -> dict:
|
|
||||||
"""Explain a permission decision for one action and resource.
|
|
||||||
|
|
||||||
This is intended for Datasette's permission debugging tools. It uses the
|
|
||||||
same ``permission_resources_sql`` hook results and the same resolution
|
|
||||||
rules as :func:`check_permissions_for_actions`, but also returns the
|
|
||||||
matching rules, actor restriction results and ``also_requires`` chain.
|
|
||||||
|
|
||||||
The returned dictionary is part of Datasette's unstable debugging API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
action_obj = datasette.actions.get(action)
|
|
||||||
if action_obj is None:
|
|
||||||
raise ValueError(f"Unknown action: {action}")
|
|
||||||
|
|
||||||
explanation = await _explain_single_action(
|
|
||||||
datasette=datasette,
|
|
||||||
actor=actor,
|
|
||||||
action=action,
|
|
||||||
parent=parent,
|
|
||||||
child=child,
|
|
||||||
)
|
|
||||||
|
|
||||||
required_actions = []
|
|
||||||
if action_obj.also_requires:
|
|
||||||
required = await explain_permission_for_resource(
|
|
||||||
datasette=datasette,
|
|
||||||
actor=actor,
|
|
||||||
action=action_obj.also_requires,
|
|
||||||
parent=parent,
|
|
||||||
child=child,
|
|
||||||
)
|
|
||||||
required_actions.append(required)
|
|
||||||
|
|
||||||
explanation["required_actions"] = required_actions
|
|
||||||
explanation["allowed"] = bool(
|
|
||||||
explanation["rule_allowed"]
|
|
||||||
and explanation["restriction_allowed"]
|
|
||||||
and all(required["allowed"] for required in required_actions)
|
|
||||||
)
|
|
||||||
explanation["summary"] = _permission_explanation_summary(explanation)
|
|
||||||
return explanation
|
|
||||||
|
|
||||||
|
|
||||||
async def _explain_single_action(
|
|
||||||
*,
|
|
||||||
datasette: "Datasette",
|
|
||||||
actor: dict | None,
|
|
||||||
action: str,
|
|
||||||
parent: str | None,
|
|
||||||
child: str | None,
|
|
||||||
) -> dict:
|
|
||||||
"""Return matching rules and restrictions for a single action."""
|
|
||||||
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
|
||||||
|
|
||||||
permission_sqls = await gather_permission_sql_from_hooks(
|
|
||||||
datasette=datasette,
|
|
||||||
actor=actor,
|
|
||||||
action=action,
|
|
||||||
)
|
|
||||||
|
|
||||||
if permission_sqls is SKIP_PERMISSION_CHECKS:
|
|
||||||
return {
|
|
||||||
"action": action,
|
|
||||||
"rule_allowed": True,
|
|
||||||
"restriction_allowed": True,
|
|
||||||
"winning_scope": "global",
|
|
||||||
"matched_rules": [
|
|
||||||
{
|
|
||||||
"scope": "global",
|
|
||||||
"effect": "allow",
|
|
||||||
"source": "skip_permission_checks",
|
|
||||||
"reason": "Permission checks were explicitly skipped",
|
|
||||||
"decisive": True,
|
|
||||||
"ignored_because": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"restrictions": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
db = datasette.get_internal_database()
|
|
||||||
matched_rules = []
|
|
||||||
restrictions = []
|
|
||||||
|
|
||||||
for permission_sql in permission_sqls:
|
|
||||||
params = dict(permission_sql.params or {})
|
|
||||||
parent_param = _unused_parameter_name(params, "_explain_parent")
|
|
||||||
params[parent_param] = parent
|
|
||||||
child_param = _unused_parameter_name(params, "_explain_child")
|
|
||||||
params[child_param] = child
|
|
||||||
|
|
||||||
if permission_sql.sql:
|
|
||||||
rows = await db.execute(
|
|
||||||
f"""
|
|
||||||
SELECT parent, child, allow, reason
|
|
||||||
FROM ({permission_sql.sql}) AS permission_rules
|
|
||||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
|
||||||
AND (child IS NULL OR child = :{child_param})
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
specificity = (
|
|
||||||
2
|
|
||||||
if row["child"] is not None
|
|
||||||
else 1 if row["parent"] is not None else 0
|
|
||||||
)
|
|
||||||
matched_rules.append(
|
|
||||||
{
|
|
||||||
"scope": ("resource", "parent", "global")[2 - specificity],
|
|
||||||
"effect": "allow" if row["allow"] else "deny",
|
|
||||||
"source": permission_sql.source,
|
|
||||||
"reason": row["reason"],
|
|
||||||
"_specificity": specificity,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if permission_sql.restriction_sql:
|
|
||||||
restriction_row = (
|
|
||||||
await db.execute(
|
|
||||||
f"""
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
|
|
||||||
WHERE (parent IS NULL OR parent = :{parent_param})
|
|
||||||
AND (child IS NULL OR child = :{child_param})
|
|
||||||
) AS resource_is_in_allowlist
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
restriction_allowed = bool(restriction_row[0])
|
|
||||||
restrictions.append(
|
|
||||||
{
|
|
||||||
"source": permission_sql.source,
|
|
||||||
"allowed": restriction_allowed,
|
|
||||||
"reason": params.get("deny")
|
|
||||||
or (
|
|
||||||
"Resource is included in this restriction allowlist"
|
|
||||||
if restriction_allowed
|
|
||||||
else "Resource is not included in this restriction allowlist"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
matched_rules.sort(
|
|
||||||
key=lambda rule: (
|
|
||||||
-rule["_specificity"],
|
|
||||||
0 if rule["effect"] == "deny" else 1,
|
|
||||||
rule["source"] or "",
|
|
||||||
rule["reason"] or "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if matched_rules:
|
|
||||||
winning_specificity = matched_rules[0]["_specificity"]
|
|
||||||
winning_rules = [
|
|
||||||
rule
|
|
||||||
for rule in matched_rules
|
|
||||||
if rule["_specificity"] == winning_specificity
|
|
||||||
]
|
|
||||||
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
|
|
||||||
winning_scope = winning_rules[0]["scope"]
|
|
||||||
else:
|
|
||||||
winning_specificity = None
|
|
||||||
rule_allowed = False
|
|
||||||
winning_scope = None
|
|
||||||
|
|
||||||
for rule in matched_rules:
|
|
||||||
specificity = rule.pop("_specificity")
|
|
||||||
if specificity != winning_specificity:
|
|
||||||
rule["decisive"] = False
|
|
||||||
rule["ignored_because"] = "A more specific rule matched"
|
|
||||||
elif not rule_allowed and rule["effect"] == "allow":
|
|
||||||
rule["decisive"] = False
|
|
||||||
rule["ignored_because"] = "A deny rule matched at the same scope"
|
|
||||||
else:
|
|
||||||
rule["decisive"] = True
|
|
||||||
rule["ignored_because"] = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"action": action,
|
|
||||||
"rule_allowed": rule_allowed,
|
|
||||||
"restriction_allowed": all(
|
|
||||||
restriction["allowed"] for restriction in restrictions
|
|
||||||
),
|
|
||||||
"winning_scope": winning_scope,
|
|
||||||
"matched_rules": matched_rules,
|
|
||||||
"restrictions": restrictions,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _unused_parameter_name(params: dict, preferred: str) -> str:
|
|
||||||
"""Return a SQL parameter name that is not already in ``params``."""
|
|
||||||
candidate = preferred
|
|
||||||
suffix = 2
|
|
||||||
while candidate in params:
|
|
||||||
candidate = f"{preferred}_{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _permission_explanation_summary(explanation: dict) -> str:
|
|
||||||
denied_requirement = next(
|
|
||||||
(
|
|
||||||
required
|
|
||||||
for required in explanation["required_actions"]
|
|
||||||
if not required["allowed"]
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if denied_requirement:
|
|
||||||
return (
|
|
||||||
f"Denied because {explanation['action']} also requires "
|
|
||||||
f"{denied_requirement['action']}, which was denied."
|
|
||||||
)
|
|
||||||
if not explanation["matched_rules"]:
|
|
||||||
return "Denied because no permission rule matched this actor and resource."
|
|
||||||
if not explanation["rule_allowed"]:
|
|
||||||
return (
|
|
||||||
f"Denied by a {explanation['winning_scope']}-level rule. "
|
|
||||||
"Deny rules take precedence over allow rules at the same scope."
|
|
||||||
)
|
|
||||||
if not explanation["restriction_allowed"]:
|
|
||||||
return (
|
|
||||||
"Denied because the resource is not included in the actor's restrictions."
|
|
||||||
)
|
|
||||||
return f"Allowed by the matching {explanation['winning_scope']}-level rule."
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,28 @@
|
||||||
import json
|
import json
|
||||||
import re
|
from typing import Optional
|
||||||
from http.cookies import Morsel, SimpleCookie
|
|
||||||
from mimetypes import guess_type
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import parse_qs, parse_qsl, urlunparse
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
import aiofiles.os
|
|
||||||
|
|
||||||
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
|
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
|
||||||
from datasette.utils.multipart import (
|
from datasette.utils.multipart import (
|
||||||
DEFAULT_MAX_FIELD_SIZE,
|
parse_form_data,
|
||||||
DEFAULT_MAX_FIELDS,
|
MultipartParseError,
|
||||||
|
FormData,
|
||||||
DEFAULT_MAX_FILE_SIZE,
|
DEFAULT_MAX_FILE_SIZE,
|
||||||
|
DEFAULT_MAX_REQUEST_SIZE,
|
||||||
|
DEFAULT_MAX_FIELDS,
|
||||||
DEFAULT_MAX_FILES,
|
DEFAULT_MAX_FILES,
|
||||||
|
DEFAULT_MAX_PARTS,
|
||||||
|
DEFAULT_MAX_FIELD_SIZE,
|
||||||
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
DEFAULT_MAX_PART_HEADER_BYTES,
|
DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
DEFAULT_MAX_PART_HEADER_LINES,
|
DEFAULT_MAX_PART_HEADER_LINES,
|
||||||
DEFAULT_MAX_PARTS,
|
|
||||||
DEFAULT_MAX_REQUEST_SIZE,
|
|
||||||
DEFAULT_MIN_FREE_DISK_BYTES,
|
DEFAULT_MIN_FREE_DISK_BYTES,
|
||||||
FormData,
|
|
||||||
MultipartParseError,
|
|
||||||
parse_form_data,
|
|
||||||
)
|
)
|
||||||
|
from mimetypes import guess_type
|
||||||
|
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
||||||
|
from pathlib import Path
|
||||||
|
from http.cookies import SimpleCookie, Morsel
|
||||||
|
import aiofiles
|
||||||
|
import aiofiles.os
|
||||||
|
import re
|
||||||
|
|
||||||
# Workaround for adding samesite support to pre 3.8 python
|
# Workaround for adding samesite support to pre 3.8 python
|
||||||
Morsel._reserved["samesite"] = "SameSite"
|
Morsel._reserved["samesite"] = "SameSite"
|
||||||
|
|
@ -89,7 +88,7 @@ class Request:
|
||||||
self.max_post_body_bytes = max_post_body_bytes
|
self.max_post_body_bytes = max_post_body_bytes
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<asgi.Request method="{self.method}" url="{self.url}">'
|
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def method(self):
|
def method(self):
|
||||||
|
|
@ -168,7 +167,7 @@ class Request:
|
||||||
if max_bytes is None:
|
if max_bytes is None:
|
||||||
max_bytes = self.max_post_body_bytes
|
max_bytes = self.max_post_body_bytes
|
||||||
too_large = PayloadTooLarge(
|
too_large = PayloadTooLarge(
|
||||||
f"Request body exceeded maximum size of {max_bytes} bytes"
|
"Request body exceeded maximum size of {} bytes".format(max_bytes)
|
||||||
)
|
)
|
||||||
if max_bytes:
|
if max_bytes:
|
||||||
# Reject early if the client declares an oversized body
|
# Reject early if the client declares an oversized body
|
||||||
|
|
@ -207,7 +206,7 @@ class Request:
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
max_files: int = DEFAULT_MAX_FILES,
|
||||||
max_parts: int | None = DEFAULT_MAX_PARTS,
|
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
|
||||||
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
@ -530,9 +529,9 @@ class Response:
|
||||||
httponly=False,
|
httponly=False,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
):
|
):
|
||||||
assert (
|
assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
|
||||||
samesite in SAMESITE_VALUES
|
SAMESITE_VALUES
|
||||||
), f"samesite should be one of {SAMESITE_VALUES}"
|
)
|
||||||
cookie = SimpleCookie()
|
cookie = SimpleCookie()
|
||||||
cookie[key] = value
|
cookie[key] = value
|
||||||
for prop_name, prop_value in (
|
for prop_name, prop_value in (
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BaseConverter:
|
class BaseConverter(object):
|
||||||
decimal_digits = "0123456789"
|
decimal_digits = "0123456789"
|
||||||
|
|
||||||
def __init__(self, digits):
|
def __init__(self, digits):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import inspect
|
import inspect
|
||||||
import types
|
import types
|
||||||
from typing import Any, NamedTuple
|
from typing import NamedTuple, Any
|
||||||
|
|
||||||
|
|
||||||
class CallableStatus(NamedTuple):
|
class CallableStatus(NamedTuple):
|
||||||
|
|
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
|
||||||
if isinstance(obj, types.FunctionType):
|
if isinstance(obj, types.FunctionType):
|
||||||
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
||||||
|
|
||||||
if callable(obj):
|
if hasattr(obj, "__call__"):
|
||||||
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
|
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
|
||||||
|
|
||||||
assert False, f"obj {obj!r} is somehow callable with no __call__ method"
|
assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import textwrap
|
||||||
from sqlite_utils import Database as SQLiteUtilsDatabase
|
from sqlite_utils import Database as SQLiteUtilsDatabase
|
||||||
from sqlite_utils import Migrations
|
from sqlite_utils import Migrations
|
||||||
|
|
||||||
from datasette.utils import escape_sqlite, table_column_details
|
from datasette.utils import table_column_details
|
||||||
|
|
||||||
INTERNAL_DB_SCHEMA_TABLES = {
|
INTERNAL_DB_SCHEMA_TABLES = {
|
||||||
"catalog_databases",
|
"catalog_databases",
|
||||||
|
|
@ -180,9 +180,29 @@ async def init_internal_db(db):
|
||||||
await db.execute_write_fn(apply_migrations, transaction=False)
|
await db.execute_write_fn(apply_migrations, transaction=False)
|
||||||
|
|
||||||
|
|
||||||
async def populate_schema_tables(internal_db, db, schema_version):
|
async def populate_schema_tables(internal_db, db):
|
||||||
database_name = db.name
|
database_name = db.name
|
||||||
|
|
||||||
|
def delete_everything(conn):
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
|
||||||
|
[database_name],
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
await internal_db.execute_write_fn(delete_everything)
|
||||||
|
|
||||||
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
||||||
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
||||||
|
|
||||||
|
|
@ -207,30 +227,25 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
||||||
columns = table_column_details(conn, table_name)
|
columns = table_column_details(conn, table_name)
|
||||||
columns_to_insert.extend(
|
columns_to_insert.extend(
|
||||||
{
|
{
|
||||||
"database_name": database_name,
|
**{"database_name": database_name, "table_name": table_name},
|
||||||
"table_name": table_name,
|
|
||||||
**column._asdict(),
|
**column._asdict(),
|
||||||
}
|
}
|
||||||
for column in columns
|
for column in columns
|
||||||
)
|
)
|
||||||
foreign_keys = conn.execute(
|
foreign_keys = conn.execute(
|
||||||
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
|
f"PRAGMA foreign_key_list([{table_name}])"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
foreign_keys_to_insert.extend(
|
foreign_keys_to_insert.extend(
|
||||||
{
|
{
|
||||||
"database_name": database_name,
|
**{"database_name": database_name, "table_name": table_name},
|
||||||
"table_name": table_name,
|
|
||||||
**dict(foreign_key),
|
**dict(foreign_key),
|
||||||
}
|
}
|
||||||
for foreign_key in foreign_keys
|
for foreign_key in foreign_keys
|
||||||
)
|
)
|
||||||
indexes = conn.execute(
|
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
|
||||||
f"PRAGMA index_list({escape_sqlite(table_name)})"
|
|
||||||
).fetchall()
|
|
||||||
indexes_to_insert.extend(
|
indexes_to_insert.extend(
|
||||||
{
|
{
|
||||||
"database_name": database_name,
|
**{"database_name": database_name, "table_name": table_name},
|
||||||
"table_name": table_name,
|
|
||||||
**dict(index),
|
**dict(index),
|
||||||
}
|
}
|
||||||
for index in indexes
|
for index in indexes
|
||||||
|
|
@ -251,76 +266,47 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
||||||
indexes_to_insert,
|
indexes_to_insert,
|
||||||
) = await db.execute_fn(collect_info)
|
) = await db.execute_fn(collect_info)
|
||||||
|
|
||||||
def replace_catalog(conn):
|
await internal_db.execute_write_many(
|
||||||
# Delete child rows before their catalog_tables parents so this also
|
"""
|
||||||
# works if a prepare_connection plugin enables foreign key enforcement.
|
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||||
for table in (
|
values (?, ?, ?, ?)
|
||||||
"catalog_columns",
|
""",
|
||||||
"catalog_foreign_keys",
|
tables_to_insert,
|
||||||
"catalog_indexes",
|
)
|
||||||
"catalog_views",
|
await internal_db.execute_write_many(
|
||||||
"catalog_tables",
|
"""
|
||||||
):
|
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||||
conn.execute(
|
values (?, ?, ?, ?)
|
||||||
f"DELETE FROM {table} WHERE database_name = ?",
|
""",
|
||||||
[database_name],
|
views_to_insert,
|
||||||
)
|
)
|
||||||
conn.execute(
|
await internal_db.execute_write_many(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO catalog_databases (
|
INSERT INTO catalog_columns (
|
||||||
database_name, path, is_memory, schema_version
|
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||||
) VALUES (?, ?, ?, ?)
|
) VALUES (
|
||||||
""",
|
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
||||||
[
|
|
||||||
database_name,
|
|
||||||
str(db.path) if db.path is not None else None,
|
|
||||||
db.is_memory,
|
|
||||||
schema_version,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
conn.executemany(
|
""",
|
||||||
"""
|
columns_to_insert,
|
||||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
)
|
||||||
values (?, ?, ?, ?)
|
await internal_db.execute_write_many(
|
||||||
""",
|
"""
|
||||||
tables_to_insert,
|
INSERT INTO catalog_foreign_keys (
|
||||||
|
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||||
|
) VALUES (
|
||||||
|
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
||||||
)
|
)
|
||||||
conn.executemany(
|
""",
|
||||||
"""
|
foreign_keys_to_insert,
|
||||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
)
|
||||||
values (?, ?, ?, ?)
|
await internal_db.execute_write_many(
|
||||||
""",
|
"""
|
||||||
views_to_insert,
|
INSERT INTO catalog_indexes (
|
||||||
|
database_name, table_name, seq, name, "unique", origin, partial
|
||||||
|
) VALUES (
|
||||||
|
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
||||||
)
|
)
|
||||||
conn.executemany(
|
""",
|
||||||
"""
|
indexes_to_insert,
|
||||||
INSERT INTO catalog_columns (
|
)
|
||||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
|
||||||
) VALUES (
|
|
||||||
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
columns_to_insert,
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO catalog_foreign_keys (
|
|
||||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
|
||||||
) VALUES (
|
|
||||||
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
foreign_keys_to_insert,
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO catalog_indexes (
|
|
||||||
database_name, table_name, seq, name, "unique", origin, partial
|
|
||||||
) VALUES (
|
|
||||||
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
indexes_to_insert,
|
|
||||||
)
|
|
||||||
|
|
||||||
await internal_db.execute_write_fn(replace_catalog)
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,15 @@ Supports:
|
||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
Union,
|
||||||
)
|
)
|
||||||
from urllib.parse import parse_qsl
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
|
|
@ -24,7 +29,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
|
||||||
DEFAULT_MAX_FIELDS = 1000
|
DEFAULT_MAX_FIELDS = 1000
|
||||||
DEFAULT_MAX_FILES = 100
|
DEFAULT_MAX_FILES = 100
|
||||||
# If max_parts is not specified, it defaults to max_fields + max_files
|
# If max_parts is not specified, it defaults to max_fields + max_files
|
||||||
DEFAULT_MAX_PARTS: int | None = None
|
DEFAULT_MAX_PARTS: Optional[int] = None
|
||||||
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
|
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
|
||||||
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
|
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
|
||||||
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
|
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
|
||||||
|
|
@ -35,6 +40,8 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
|
||||||
class MultipartParseError(Exception):
|
class MultipartParseError(Exception):
|
||||||
"""Raised when multipart parsing fails."""
|
"""Raised when multipart parsing fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UploadedFile:
|
class UploadedFile:
|
||||||
|
|
@ -50,7 +57,7 @@ class UploadedFile:
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
filename: str
|
filename: str
|
||||||
content_type: str | None
|
content_type: Optional[str]
|
||||||
size: int
|
size: int
|
||||||
_file: tempfile.SpooledTemporaryFile = field(repr=False)
|
_file: tempfile.SpooledTemporaryFile = field(repr=False)
|
||||||
|
|
||||||
|
|
@ -79,8 +86,7 @@ class UploadedFile:
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
try:
|
try:
|
||||||
self._file.close()
|
self._file.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# __del__ must never raise
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,27 +98,27 @@ class FormData:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._data: list[tuple[str, str | UploadedFile]] = []
|
self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
|
||||||
|
|
||||||
def append(self, key: str, value: str | UploadedFile) -> None:
|
def append(self, key: str, value: Union[str, UploadedFile]) -> None:
|
||||||
"""Add a key-value pair."""
|
"""Add a key-value pair."""
|
||||||
self._data.append((key, value))
|
self._data.append((key, value))
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> str | UploadedFile:
|
def __getitem__(self, key: str) -> Union[str, UploadedFile]:
|
||||||
"""Get the first value for a key."""
|
"""Get the first value for a key."""
|
||||||
for k, v in self._data:
|
for k, v in self._data:
|
||||||
if k == key:
|
if k == key:
|
||||||
return v
|
return v
|
||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> str | UploadedFile | None:
|
def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
|
||||||
"""Get the first value for a key, or default if not found."""
|
"""Get the first value for a key, or default if not found."""
|
||||||
try:
|
try:
|
||||||
return self[key]
|
return self[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def getlist(self, key: str) -> list[str | UploadedFile]:
|
def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
|
||||||
"""Get all values for a key."""
|
"""Get all values for a key."""
|
||||||
return [v for k, v in self._data if k == key]
|
return [v for k, v in self._data if k == key]
|
||||||
|
|
||||||
|
|
@ -136,15 +142,15 @@ class FormData:
|
||||||
"""Return unique keys."""
|
"""Return unique keys."""
|
||||||
return list(self)
|
return list(self)
|
||||||
|
|
||||||
def items(self) -> list[tuple[str, str | UploadedFile]]:
|
def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
|
||||||
"""Return all key-value pairs."""
|
"""Return all key-value pairs."""
|
||||||
return list(self._data)
|
return list(self._data)
|
||||||
|
|
||||||
def values(self) -> list[str | UploadedFile]:
|
def values(self) -> List[Union[str, UploadedFile]]:
|
||||||
"""Return all values."""
|
"""Return all values."""
|
||||||
return [v for _, v in self._data]
|
return [v for _, v in self._data]
|
||||||
|
|
||||||
def _uploaded_files(self) -> list[UploadedFile]:
|
def _uploaded_files(self) -> List[UploadedFile]:
|
||||||
"""Return UploadedFile instances contained in this form."""
|
"""Return UploadedFile instances contained in this form."""
|
||||||
return [v for _, v in self._data if isinstance(v, UploadedFile)]
|
return [v for _, v in self._data if isinstance(v, UploadedFile)]
|
||||||
|
|
||||||
|
|
@ -157,7 +163,7 @@ class FormData:
|
||||||
for uploaded in self._uploaded_files():
|
for uploaded in self._uploaded_files():
|
||||||
try:
|
try:
|
||||||
uploaded.close_sync()
|
uploaded.close_sync()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Best-effort cleanup; ignore close errors
|
# Best-effort cleanup; ignore close errors
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -166,7 +172,7 @@ class FormData:
|
||||||
for uploaded in self._uploaded_files():
|
for uploaded in self._uploaded_files():
|
||||||
try:
|
try:
|
||||||
await uploaded.close()
|
await uploaded.close()
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Best-effort cleanup; ignore close errors
|
# Best-effort cleanup; ignore close errors
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -183,13 +189,13 @@ class FormData:
|
||||||
await self.aclose()
|
await self.aclose()
|
||||||
|
|
||||||
|
|
||||||
def parse_content_disposition(header: str) -> dict[str, str | None]:
|
def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Parse Content-Disposition header value.
|
Parse Content-Disposition header value.
|
||||||
|
|
||||||
Returns dict with 'name', 'filename' keys (filename may be None).
|
Returns dict with 'name', 'filename' keys (filename may be None).
|
||||||
"""
|
"""
|
||||||
result: dict[str, str | None] = {"name": None, "filename": None}
|
result: Dict[str, Optional[str]] = {"name": None, "filename": None}
|
||||||
|
|
||||||
# Split on semicolons, handling quoted strings
|
# Split on semicolons, handling quoted strings
|
||||||
parts = []
|
parts = []
|
||||||
|
|
@ -232,8 +238,7 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
result["filename"] = unquote(encoded, encoding="utf-8")
|
result["filename"] = unquote(encoded, encoding="utf-8")
|
||||||
except Exception: # noqa: BLE001, S110
|
except Exception:
|
||||||
# Malformed RFC 5987 filename* - fall back to the plain filename
|
|
||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -245,19 +250,20 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
|
||||||
|
|
||||||
if key == "name":
|
if key == "name":
|
||||||
result["name"] = value
|
result["name"] = value
|
||||||
# Only set filename if filename* hasn't already set it
|
elif key == "filename":
|
||||||
elif key == "filename" and result["filename"] is None:
|
# Only set if filename* hasn't already set it
|
||||||
# Strip path components (security)
|
if result["filename"] is None:
|
||||||
# Handle both Unix and Windows paths
|
# Strip path components (security)
|
||||||
value = value.replace("\\", "/")
|
# Handle both Unix and Windows paths
|
||||||
if "/" in value:
|
value = value.replace("\\", "/")
|
||||||
value = value.rsplit("/", 1)[-1]
|
if "/" in value:
|
||||||
result["filename"] = value
|
value = value.rsplit("/", 1)[-1]
|
||||||
|
result["filename"] = value
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def parse_content_type(header: str) -> tuple[str, dict[str, str]]:
|
def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Parse Content-Type header value.
|
Parse Content-Type header value.
|
||||||
|
|
||||||
|
|
@ -301,7 +307,7 @@ class MultipartParser:
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
max_files: int = DEFAULT_MAX_FILES,
|
||||||
max_parts: int | None = DEFAULT_MAX_PARTS,
|
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
|
||||||
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
@ -342,12 +348,12 @@ class MultipartParser:
|
||||||
self._tempdir = tempfile.gettempdir()
|
self._tempdir = tempfile.gettempdir()
|
||||||
|
|
||||||
# Current part state
|
# Current part state
|
||||||
self.current_headers: dict[str, str] = {}
|
self.current_headers: Dict[str, str] = {}
|
||||||
self.current_file: tempfile.SpooledTemporaryFile | None = None
|
self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
|
||||||
self.current_body = bytearray()
|
self.current_body = bytearray()
|
||||||
self.current_name: str | None = None
|
self.current_name: Optional[str] = None
|
||||||
self.current_filename: str | None = None
|
self.current_filename: Optional[str] = None
|
||||||
self.current_content_type: str | None = None
|
self.current_content_type: Optional[str] = None
|
||||||
|
|
||||||
def feed(self, chunk: bytes) -> None:
|
def feed(self, chunk: bytes) -> None:
|
||||||
"""Feed a chunk of data to the parser."""
|
"""Feed a chunk of data to the parser."""
|
||||||
|
|
@ -448,7 +454,7 @@ class MultipartParser:
|
||||||
# Parse header
|
# Parse header
|
||||||
try:
|
try:
|
||||||
line_str = line.decode("utf-8", errors="replace")
|
line_str = line.decode("utf-8", errors="replace")
|
||||||
except UnicodeDecodeError:
|
except Exception:
|
||||||
line_str = line.decode("latin-1")
|
line_str = line.decode("latin-1")
|
||||||
|
|
||||||
if ":" in line_str:
|
if ":" in line_str:
|
||||||
|
|
@ -475,9 +481,7 @@ class MultipartParser:
|
||||||
if self.file_count > self.max_files:
|
if self.file_count > self.max_files:
|
||||||
raise MultipartParseError("Too many files")
|
raise MultipartParseError("Too many files")
|
||||||
if self.handle_files:
|
if self.handle_files:
|
||||||
# Outlives this method - it is filled in across parser callbacks
|
self.current_file = tempfile.SpooledTemporaryFile(
|
||||||
# and then handed to the UploadedFile the caller consumes
|
|
||||||
self.current_file = tempfile.SpooledTemporaryFile( # noqa: SIM115
|
|
||||||
max_size=self.max_memory_file_size
|
max_size=self.max_memory_file_size
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -640,7 +644,7 @@ async def parse_form_data(
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
max_files: int = DEFAULT_MAX_FILES,
|
||||||
max_parts: int | None = DEFAULT_MAX_PARTS,
|
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
|
||||||
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Iterable, Sequence
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from datasette.permissions import PermissionSQL
|
from datasette.permissions import PermissionSQL
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
@ -16,7 +15,7 @@ SKIP_PERMISSION_CHECKS = object()
|
||||||
|
|
||||||
async def gather_permission_sql_from_hooks(
|
async def gather_permission_sql_from_hooks(
|
||||||
*, datasette, actor: dict | None, action: str
|
*, datasette, actor: dict | None, action: str
|
||||||
) -> list[PermissionSQL] | object:
|
) -> List[PermissionSQL] | object:
|
||||||
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
||||||
|
|
||||||
Ensures that each returned PermissionSQL has a populated ``source``.
|
Ensures that each returned PermissionSQL has a populated ``source``.
|
||||||
|
|
@ -35,7 +34,7 @@ async def gather_permission_sql_from_hooks(
|
||||||
hookimpls = hook_caller.get_hookimpls()
|
hookimpls = hook_caller.get_hookimpls()
|
||||||
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
||||||
|
|
||||||
collected: list[PermissionSQL] = []
|
collected: List[PermissionSQL] = []
|
||||||
actor_json = json.dumps(actor) if actor is not None else None
|
actor_json = json.dumps(actor) if actor is not None else None
|
||||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||||
|
|
||||||
|
|
@ -72,7 +71,7 @@ def _iter_permission_sql_from_result(
|
||||||
if isinstance(result, PermissionSQL):
|
if isinstance(result, PermissionSQL):
|
||||||
return [result]
|
return [result]
|
||||||
if isinstance(result, (list, tuple)):
|
if isinstance(result, (list, tuple)):
|
||||||
collected: list[PermissionSQL] = []
|
collected: List[PermissionSQL] = []
|
||||||
for item in result:
|
for item in result:
|
||||||
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
||||||
return collected
|
return collected
|
||||||
|
|
@ -91,7 +90,7 @@ def _iter_permission_sql_from_result(
|
||||||
|
|
||||||
def build_rules_union(
|
def build_rules_union(
|
||||||
actor: dict | None, plugins: Sequence[PermissionSQL]
|
actor: dict | None, plugins: Sequence[PermissionSQL]
|
||||||
) -> tuple[str, dict[str, Any]]:
|
) -> Tuple[str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Compose plugin SQL into a UNION ALL.
|
Compose plugin SQL into a UNION ALL.
|
||||||
|
|
||||||
|
|
@ -103,10 +102,10 @@ def build_rules_union(
|
||||||
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
|
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
|
||||||
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
|
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
|
||||||
"""
|
"""
|
||||||
parts: list[str] = []
|
parts: List[str] = []
|
||||||
actor_json = json.dumps(actor) if actor else None
|
actor_json = json.dumps(actor) if actor else None
|
||||||
actor_id = actor.get("id") if actor else None
|
actor_id = actor.get("id") if actor else None
|
||||||
params: dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
||||||
|
|
||||||
for p in plugins:
|
for p in plugins:
|
||||||
# No namespacing - just use plugin params as-is
|
# No namespacing - just use plugin params as-is
|
||||||
|
|
@ -142,10 +141,10 @@ async def resolve_permissions_from_catalog(
|
||||||
plugins: Sequence[Any],
|
plugins: Sequence[Any],
|
||||||
action: str,
|
action: str,
|
||||||
candidate_sql: str,
|
candidate_sql: str,
|
||||||
candidate_params: dict[str, Any] | None = None,
|
candidate_params: Dict[str, Any] | None = None,
|
||||||
*,
|
*,
|
||||||
implicit_deny: bool = True,
|
implicit_deny: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
||||||
|
|
||||||
|
|
@ -169,8 +168,8 @@ async def resolve_permissions_from_catalog(
|
||||||
- parent, child, allow, reason, source_plugin, depth
|
- parent, child, allow, reason, source_plugin, depth
|
||||||
- resource (rendered "/parent/child" or "/parent" or "/")
|
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||||
"""
|
"""
|
||||||
resolved_plugins: list[PermissionSQL] = []
|
resolved_plugins: List[PermissionSQL] = []
|
||||||
restriction_sqls: list[str] = []
|
restriction_sqls: List[str] = []
|
||||||
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
||||||
|
|
@ -399,11 +398,11 @@ async def resolve_permissions_with_candidates(
|
||||||
db,
|
db,
|
||||||
actor: dict | None,
|
actor: dict | None,
|
||||||
plugins: Sequence[Any],
|
plugins: Sequence[Any],
|
||||||
candidates: list[tuple[str, str | None]],
|
candidates: List[Tuple[str, str | None]],
|
||||||
action: str,
|
action: str,
|
||||||
*,
|
*,
|
||||||
implicit_deny: bool = True,
|
implicit_deny: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Resolve permissions without any external candidate table by embedding
|
Resolve permissions without any external candidate table by embedding
|
||||||
the candidates as a UNION of parameterized SELECTs in a CTE.
|
the candidates as a UNION of parameterized SELECTs in a CTE.
|
||||||
|
|
@ -412,8 +411,8 @@ async def resolve_permissions_with_candidates(
|
||||||
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
||||||
"""
|
"""
|
||||||
# Build a small CTE for candidates.
|
# Build a small CTE for candidates.
|
||||||
cand_rows_sql: list[str] = []
|
cand_rows_sql: List[str] = []
|
||||||
cand_params: dict[str, Any] = {}
|
cand_params: Dict[str, Any] = {}
|
||||||
for i, (parent, child) in enumerate(candidates):
|
for i, (parent, child) in enumerate(candidates):
|
||||||
pkey = f"cand_p_{i}"
|
pkey = f"cand_p_{i}"
|
||||||
ckey = f"cand_c_{i}"
|
ckey = f"cand_c_{i}"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ https://github.com/python/cpython/blob/v3.8.3/LICENSE
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from shutil import Error, copy, copy2, copystat
|
from shutil import copy, copy2, copystat, Error
|
||||||
|
|
||||||
|
|
||||||
def _copytree(
|
def _copytree(
|
||||||
|
|
|
||||||
|
|
@ -413,12 +413,12 @@ def analyze_sql_tables(
|
||||||
database=None,
|
database=None,
|
||||||
table=None,
|
table=None,
|
||||||
sqlite_schema=sqlite_schema,
|
sqlite_schema=sqlite_schema,
|
||||||
target=f"{arg1} {arg2}" if arg2 is not None else arg1,
|
target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
|
||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
return sqlite3.SQLITE_OK
|
return sqlite3.SQLITE_OK
|
||||||
|
|
||||||
action_name = _AUTHORIZER_ACTION_NAMES.get(action, f"SQLITE_{action}")
|
action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
|
||||||
record(
|
record(
|
||||||
"unknown",
|
"unknown",
|
||||||
"unknown",
|
"unknown",
|
||||||
|
|
@ -521,7 +521,9 @@ def analyze_sql_tables(
|
||||||
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
|
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
return bool(key_is_drop_table_delete(key))
|
if key_is_drop_table_delete(key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
|
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]
|
||||||
schema_table = _sqlite_schema_table(schema)
|
schema_table = _sqlite_schema_table(schema)
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"select name, sql from {schema_table} where type = 'table'"
|
"select name, sql from {} where type = 'table'".format(schema_table)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
return []
|
return []
|
||||||
|
|
@ -127,7 +127,7 @@ def _sqlite_table_type_from_schema(
|
||||||
schema_table = _sqlite_schema_table(schema)
|
schema_table = _sqlite_schema_table(schema)
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"select type, sql from {schema_table} where name = ?",
|
"select type, sql from {} where name = ?".format(schema_table),
|
||||||
(table,),
|
(table,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
|
|
@ -155,7 +155,7 @@ def _is_known_shadow_table(
|
||||||
schema_table = _sqlite_schema_table(schema)
|
schema_table = _sqlite_schema_table(schema)
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"select name, sql from {schema_table} where type = 'table'"
|
"select name, sql from {} where type = 'table'".format(schema_table)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
return False
|
return False
|
||||||
|
|
@ -174,7 +174,7 @@ def _sqlite_schema_table(schema: str | None) -> str:
|
||||||
return "sqlite_master"
|
return "sqlite_master"
|
||||||
if schema == "temp":
|
if schema == "temp":
|
||||||
return "sqlite_temp_master"
|
return "sqlite_temp_master"
|
||||||
return f"{_quote_identifier(schema)}.sqlite_master"
|
return "{}.sqlite_master".format(_quote_identifier(schema))
|
||||||
|
|
||||||
|
|
||||||
def _quote_identifier(value: str) -> str:
|
def _quote_identifier(value: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import json
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import json
|
||||||
|
|
||||||
# These wrapper classes pre-date the introduction of
|
# These wrapper classes pre-date the introduction of
|
||||||
# datasette.client and httpx to Datasette. They could
|
# datasette.client and httpx to Datasette. They could
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
__version__ = "1.0a37"
|
__version__ = "1.0a36"
|
||||||
__version_info__ = tuple(__version__.split("."))
|
__version_info__ = tuple(__version__.split("."))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import types
|
import types
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -74,14 +74,16 @@ class Context:
|
||||||
extra_class = table_extra_registry.classes_by_name[name]
|
extra_class = table_extra_registry.classes_by_name[name]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise KeyError(
|
raise KeyError(
|
||||||
f"{cls.__name__}.{name} is declared with from_extra() but there is no "
|
"{}.{} is declared with from_extra() but there is no "
|
||||||
"registered extra of that name"
|
"registered extra of that name".format(cls.__name__, name)
|
||||||
)
|
)
|
||||||
if cls.extras_scope is not None and not extra_class.available_for(
|
if cls.extras_scope is not None and not extra_class.available_for(
|
||||||
cls.extras_scope
|
cls.extras_scope
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{cls.__name__}.{name} is declared with from_extra() but the {name} extra is "
|
"{}.{} is declared with from_extra() but the {} extra is "
|
||||||
f"not available for scope {cls.extras_scope}"
|
"not available for scope {}".format(
|
||||||
|
cls.__name__, name, name, cls.extras_scope
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return extra_class.description or ""
|
return extra_class.description or ""
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@ import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from datasette.utils.asgi import Request
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
add_cors_headers,
|
||||||
EscapeHtmlWriter,
|
EscapeHtmlWriter,
|
||||||
InvalidSql,
|
InvalidSql,
|
||||||
LimitedWriter,
|
LimitedWriter,
|
||||||
add_cors_headers,
|
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
path_with_format,
|
path_with_format,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import (
|
from datasette.utils.asgi import (
|
||||||
AsgiStream,
|
AsgiStream,
|
||||||
BadRequest,
|
|
||||||
Request,
|
|
||||||
Response,
|
Response,
|
||||||
|
BadRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,10 +129,12 @@ class BaseView:
|
||||||
template = environment.select_template(templates)
|
template = environment.select_template(templates)
|
||||||
template_context = {
|
template_context = {
|
||||||
**context,
|
**context,
|
||||||
"select_templates": [
|
**{
|
||||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
"select_templates": [
|
||||||
for template_name in templates
|
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||||
],
|
for template_name in templates
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.has_json_alternate:
|
if self.has_json_alternate:
|
||||||
|
|
@ -149,7 +151,9 @@ class BaseView:
|
||||||
template_context["alternate_url_json"] = alternate_url_json
|
template_context["alternate_url_json"] = alternate_url_json
|
||||||
headers.update(
|
headers.update(
|
||||||
{
|
{
|
||||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||||
|
alternate_url_json
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return Response.html(
|
return Response.html(
|
||||||
|
|
@ -180,7 +184,9 @@ async def stream_csv(datasette, fetch_data, request, database):
|
||||||
stream = request.args.get("_stream")
|
stream = request.args.get("_stream")
|
||||||
# Do not calculate facets or counts:
|
# Do not calculate facets or counts:
|
||||||
extra_parameters = [
|
extra_parameters = [
|
||||||
f"{key}=1" for key in ("_nofacet", "_nocount") if not request.args.get(key)
|
"{}=1".format(key)
|
||||||
|
for key in ("_nofacet", "_nocount")
|
||||||
|
if not request.args.get(key)
|
||||||
]
|
]
|
||||||
if extra_parameters:
|
if extra_parameters:
|
||||||
# Replace request object with a new one with modified scope
|
# Replace request object with a new one with modified scope
|
||||||
|
|
@ -210,6 +216,9 @@ async def stream_csv(datasette, fetch_data, request, database):
|
||||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
raise DatasetteError(str(e))
|
||||||
|
|
||||||
except DatasetteError:
|
except DatasetteError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -316,9 +325,8 @@ async def stream_csv(datasette, fetch_data, request, database):
|
||||||
else:
|
else:
|
||||||
new_row.append(cell)
|
new_row.append(cell)
|
||||||
await writer.writerow(new_row)
|
await writer.writerow(new_row)
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex:
|
||||||
# Streaming CSV: report the error into the response body and stop
|
sys.stderr.write("Caught this error: {}\n".format(ex))
|
||||||
sys.stderr.write(f"Caught this error: {ex}\n")
|
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
await r.write(str(ex))
|
await r.write(str(ex))
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,52 @@
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
|
import markupsafe
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
from dataclasses import asdict, dataclass, field
|
|
||||||
from urllib.parse import parse_qsl, urlencode
|
|
||||||
|
|
||||||
import markupsafe
|
|
||||||
|
|
||||||
|
from datasette.extras import extra_names_from_request, ExtraScope
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.extras import ExtraScope, extra_names_from_request
|
|
||||||
from datasette.plugins import pm
|
|
||||||
from datasette.resources import DatabaseResource, QueryResource
|
from datasette.resources import DatabaseResource, QueryResource
|
||||||
from datasette.stored_queries import StoredQuery, stored_query_to_dict
|
from datasette.stored_queries import StoredQuery, stored_query_to_dict
|
||||||
|
from datasette.write_sql import QueryWriteRejected
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
InvalidSql,
|
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
call_with_supported_arguments,
|
|
||||||
error_body,
|
error_body,
|
||||||
|
call_with_supported_arguments,
|
||||||
|
named_parameters as derive_named_parameters,
|
||||||
format_bytes,
|
format_bytes,
|
||||||
is_url,
|
|
||||||
make_slot_function,
|
make_slot_function,
|
||||||
|
tilde_decode,
|
||||||
|
to_css_class,
|
||||||
|
validate_sql_select,
|
||||||
|
is_url,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
path_with_format,
|
path_with_format,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
tilde_decode,
|
|
||||||
to_css_class,
|
|
||||||
truncate_url,
|
truncate_url,
|
||||||
validate_sql_select,
|
InvalidSql,
|
||||||
)
|
)
|
||||||
from datasette.utils import (
|
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
||||||
named_parameters as derive_named_parameters,
|
from datasette.plugins import pm
|
||||||
)
|
|
||||||
from datasette.utils.asgi import AsgiFileDownload, Forbidden, NotFound, Response
|
|
||||||
from datasette.write_sql import QueryWriteRejected
|
|
||||||
|
|
||||||
from . import Context
|
|
||||||
from .base import DatasetteError, View, stream_csv
|
from .base import DatasetteError, View, stream_csv
|
||||||
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
from .query_helpers import (
|
||||||
from .table_create_alter import _create_table_ui_context
|
_ensure_stored_query_execution_permissions,
|
||||||
|
_editor_schema,
|
||||||
|
)
|
||||||
from .table_extras import (
|
from .table_extras import (
|
||||||
QueryExtraContext,
|
QueryExtraContext,
|
||||||
resolve_query_extras,
|
resolve_query_extras,
|
||||||
table_extra_registry,
|
table_extra_registry,
|
||||||
)
|
)
|
||||||
|
from .table_create_alter import _create_table_ui_context
|
||||||
|
from . import Context
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -103,7 +103,7 @@ class DatabaseView(View):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if format_ not in ("html", "json"):
|
if format_ not in ("html", "json"):
|
||||||
raise NotFound(f"Invalid format: {format_}")
|
raise NotFound("Invalid format: {}".format(format_))
|
||||||
|
|
||||||
metadata = await datasette.get_database_metadata(database)
|
metadata = await datasette.get_database_metadata(database)
|
||||||
|
|
||||||
|
|
@ -167,7 +167,7 @@ class DatabaseView(View):
|
||||||
"label": "Create table",
|
"label": "Create table",
|
||||||
"description": "Create a new table in this database.",
|
"description": "Create a new table in this database.",
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"aria-label": f"Create table in {database}",
|
"aria-label": "Create table in {}".format(database),
|
||||||
"data-database-action": "create-table",
|
"data-database-action": "create-table",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +204,7 @@ class DatabaseView(View):
|
||||||
"queries_count": queries_count,
|
"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 _editor_schema(datasette, database) if allow_execute_sql else {}
|
||||||
),
|
),
|
||||||
"metadata": await datasette.get_database_metadata(database),
|
"metadata": await datasette.get_database_metadata(database),
|
||||||
}
|
}
|
||||||
|
|
@ -245,7 +245,7 @@ class DatabaseView(View):
|
||||||
queries_count=queries_count,
|
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 _editor_schema(datasette, database)
|
||||||
if allow_execute_sql
|
if allow_execute_sql
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
|
|
@ -274,7 +274,9 @@ class DatabaseView(View):
|
||||||
view_name="database",
|
view_name="database",
|
||||||
),
|
),
|
||||||
headers={
|
headers={
|
||||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||||
|
alternate_url_json
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -455,6 +457,11 @@ class QueryContext(Context):
|
||||||
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
default_table: str = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries."
|
||||||
|
}
|
||||||
|
)
|
||||||
alternate_url_json: str = field(
|
alternate_url_json: str = field(
|
||||||
metadata={"help": "URL for alternate JSON version of this page"}
|
metadata={"help": "URL for alternate JSON version of this page"}
|
||||||
)
|
)
|
||||||
|
|
@ -557,7 +564,7 @@ async def database_download(request, datasette):
|
||||||
if datasette.cors:
|
if datasette.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
if db.hash:
|
if db.hash:
|
||||||
etag = f'"{db.hash}"'
|
etag = '"{}"'.format(db.hash)
|
||||||
headers["Etag"] = etag
|
headers["Etag"] = etag
|
||||||
# Has user seen this already?
|
# Has user seen this already?
|
||||||
if_none_match = request.headers.get("if-none-match")
|
if_none_match = request.headers.get("if-none-match")
|
||||||
|
|
@ -644,15 +651,8 @@ class QueryView(View):
|
||||||
ok = None
|
ok = None
|
||||||
redirect_url = None
|
redirect_url = None
|
||||||
try:
|
try:
|
||||||
execute_write_kwargs = {"request": request}
|
|
||||||
if stored_query.is_trusted:
|
|
||||||
analysis = await db.analyze_sql(stored_query.sql, params_for_query)
|
|
||||||
if any(
|
|
||||||
operation.operation == "vacuum" for operation in analysis.operations
|
|
||||||
):
|
|
||||||
execute_write_kwargs["transaction"] = False
|
|
||||||
cursor = await db.execute_write(
|
cursor = await db.execute_write(
|
||||||
stored_query.sql, params_for_query, **execute_write_kwargs
|
stored_query.sql, params_for_query, request=request
|
||||||
)
|
)
|
||||||
# success message can come from on_success_message or on_success_message_sql
|
# success message can come from on_success_message or on_success_message_sql
|
||||||
message = None
|
message = None
|
||||||
|
|
@ -665,9 +665,8 @@ class QueryView(View):
|
||||||
).first()
|
).first()
|
||||||
if message_result:
|
if message_result:
|
||||||
message = message_result[0]
|
message = message_result[0]
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex:
|
||||||
# Stored-query on_success_message_sql is user-authored
|
message = "Error running on_success_message_sql: {}".format(ex)
|
||||||
message = f"Error running on_success_message_sql: {ex}"
|
|
||||||
message_type = datasette.ERROR
|
message_type = datasette.ERROR
|
||||||
if not message:
|
if not message:
|
||||||
if stored_query.on_success_message:
|
if stored_query.on_success_message:
|
||||||
|
|
@ -681,8 +680,7 @@ class QueryView(View):
|
||||||
|
|
||||||
redirect_url = stored_query.on_success_redirect
|
redirect_url = stored_query.on_success_redirect
|
||||||
ok = True
|
ok = True
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex:
|
||||||
# Stored-query execution is user-authored SQL
|
|
||||||
message = stored_query.on_error_message or str(ex)
|
message = stored_query.on_error_message or str(ex)
|
||||||
message_type = datasette.ERROR
|
message_type = datasette.ERROR
|
||||||
redirect_url = stored_query.on_error_redirect
|
redirect_url = stored_query.on_error_redirect
|
||||||
|
|
@ -723,6 +721,15 @@ 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}
|
||||||
|
|
||||||
|
# If the request carries a ?_table= pointing at a real (visible) table
|
||||||
|
# or view in this database, treat this as a table-scoped query - e.g.
|
||||||
|
# arriving here via the "View and edit SQL" link on a table page - so
|
||||||
|
# the SQL editor can offer that table's columns unprefixed. Anything
|
||||||
|
# else (including stored/canned queries, which may reference more
|
||||||
|
# than one table) leaves this as None.
|
||||||
|
requested_table = request.args.get("_table")
|
||||||
|
default_table = requested_table if requested_table in allowed_dict else None
|
||||||
|
|
||||||
# Are we a stored query?
|
# Are we a stored query?
|
||||||
stored_query = None
|
stored_query = None
|
||||||
stored_query_write = False
|
stored_query_write = False
|
||||||
|
|
@ -816,16 +823,16 @@ class QueryView(View):
|
||||||
rows = results.rows
|
rows = results.rows
|
||||||
except QueryInterrupted as ex:
|
except QueryInterrupted as ex:
|
||||||
raise DatasetteError(
|
raise DatasetteError(
|
||||||
textwrap.dedent(f"""
|
textwrap.dedent("""
|
||||||
<p>SQL query took too long. The time limit is controlled by the
|
<p>SQL query took too long. The time limit is controlled by the
|
||||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||||
configuration option.</p>
|
configuration option.</p>
|
||||||
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea>
|
<textarea style="width: 90%">{}</textarea>
|
||||||
<script>
|
<script>
|
||||||
let ta = document.querySelector("textarea");
|
let ta = document.querySelector("textarea");
|
||||||
ta.style.height = ta.scrollHeight + "px";
|
ta.style.height = ta.scrollHeight + "px";
|
||||||
</script>
|
</script>
|
||||||
""").strip(),
|
""".format(markupsafe.escape(ex.sql))).strip(),
|
||||||
title="SQL Interrupted",
|
title="SQL Interrupted",
|
||||||
status=400,
|
status=400,
|
||||||
message_is_html=True,
|
message_is_html=True,
|
||||||
|
|
@ -841,6 +848,8 @@ class QueryView(View):
|
||||||
columns = []
|
columns = []
|
||||||
except (sqlite3.OperationalError, InvalidSql) as ex:
|
except (sqlite3.OperationalError, InvalidSql) as ex:
|
||||||
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
|
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
|
||||||
|
except sqlite3.OperationalError as ex:
|
||||||
|
raise DatasetteError(str(ex))
|
||||||
except DatasetteError:
|
except DatasetteError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -862,7 +871,7 @@ class QueryView(View):
|
||||||
return data, None, None
|
return data, None, None
|
||||||
|
|
||||||
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
||||||
elif format_ in datasette.renderers:
|
elif format_ in datasette.renderers.keys():
|
||||||
if not sql:
|
if not sql:
|
||||||
raise DatasetteError("?sql= is required", status=400)
|
raise DatasetteError("?sql= is required", status=400)
|
||||||
data = {"ok": True, "rows": rows, "columns": columns}
|
data = {"ok": True, "rows": rows, "columns": columns}
|
||||||
|
|
@ -954,7 +963,9 @@ class QueryView(View):
|
||||||
}
|
}
|
||||||
headers.update(
|
headers.update(
|
||||||
{
|
{
|
||||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||||
|
alternate_url_json
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
metadata = await query_metadata()
|
metadata = await query_metadata()
|
||||||
|
|
@ -1035,7 +1046,9 @@ class QueryView(View):
|
||||||
+ "?"
|
+ "?"
|
||||||
+ urlencode(
|
+ urlencode(
|
||||||
{
|
{
|
||||||
"sql": sql,
|
**{
|
||||||
|
"sql": sql,
|
||||||
|
},
|
||||||
**named_parameter_values,
|
**named_parameter_values,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1098,10 +1111,11 @@ class QueryView(View):
|
||||||
datasette, database, request, rows, columns
|
datasette, database, request, rows, columns
|
||||||
),
|
),
|
||||||
table_columns=(
|
table_columns=(
|
||||||
await _table_columns(datasette, database)
|
await _editor_schema(datasette, database)
|
||||||
if allow_execute_sql
|
if allow_execute_sql
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
|
default_table=default_table,
|
||||||
columns=columns,
|
columns=columns,
|
||||||
renderers=renderers,
|
renderers=renderers,
|
||||||
url_csv=datasette.urls.path(
|
url_csv=datasette.urls.path(
|
||||||
|
|
@ -1137,7 +1151,7 @@ class QueryView(View):
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
assert False, f"Invalid format: {format_}"
|
assert False, "Invalid format: {}".format(format_)
|
||||||
if datasette.cors:
|
if datasette.cors:
|
||||||
add_cors_headers(r.headers)
|
add_cors_headers(r.headers)
|
||||||
return r
|
return r
|
||||||
|
|
@ -1238,7 +1252,7 @@ async def display_rows(datasette, database, request, rows, columns):
|
||||||
'<a class="blob-download" href="{}"{}><Binary: {:,} byte{}></a>'.format(
|
'<a class="blob-download" href="{}"{}><Binary: {:,} byte{}></a>'.format(
|
||||||
blob_url,
|
blob_url,
|
||||||
(
|
(
|
||||||
f' title="{formatted}"'
|
' title="{}"'.format(formatted)
|
||||||
if "bytes" not in formatted
|
if "bytes" not in formatted
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ from datasette.utils.asgi import Response
|
||||||
from .base import BaseView
|
from .base import BaseView
|
||||||
from .database import display_rows as display_query_rows
|
from .database import display_rows as display_query_rows
|
||||||
from .query_helpers import (
|
from .query_helpers import (
|
||||||
SQL_PARAMETER_FORM_PREFIX,
|
|
||||||
QueryValidationError,
|
QueryValidationError,
|
||||||
|
SQL_PARAMETER_FORM_PREFIX,
|
||||||
_analysis_is_write,
|
_analysis_is_write,
|
||||||
_analysis_rows,
|
_analysis_rows,
|
||||||
_analysis_rows_with_permissions,
|
_analysis_rows_with_permissions,
|
||||||
|
|
@ -21,6 +21,7 @@ from .query_helpers import (
|
||||||
_inserted_row_url,
|
_inserted_row_url,
|
||||||
_json_or_form_payload,
|
_json_or_form_payload,
|
||||||
_prepare_execute_write,
|
_prepare_execute_write,
|
||||||
|
_editor_schema,
|
||||||
_table_columns,
|
_table_columns,
|
||||||
_wants_json,
|
_wants_json,
|
||||||
)
|
)
|
||||||
|
|
@ -31,7 +32,15 @@ WRITE_TEMPLATE_LABELS = {
|
||||||
"delete": "Delete rows",
|
"delete": "Delete rows",
|
||||||
}
|
}
|
||||||
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
|
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
|
||||||
CREATE_TABLE_TEMPLATE_SQL = "create table new_table (\n id integer primary key,\n name text\n -- created text default (datetime('now'))\n)"
|
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
|
||||||
|
(
|
||||||
|
"create table new_table (",
|
||||||
|
" id integer primary key,",
|
||||||
|
" name text",
|
||||||
|
" -- created text default (datetime('now'))",
|
||||||
|
")",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parameter_names(columns):
|
def _parameter_names(columns):
|
||||||
|
|
@ -41,11 +50,11 @@ def _parameter_names(columns):
|
||||||
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
||||||
base = base.strip("_") or "value"
|
base = base.strip("_") or "value"
|
||||||
if base[0].isdigit():
|
if base[0].isdigit():
|
||||||
base = f"p_{base}"
|
base = "p_{}".format(base)
|
||||||
name = base
|
name = base
|
||||||
index = 2
|
index = 2
|
||||||
while name in seen:
|
while name in seen:
|
||||||
name = f"{base}_{index}"
|
name = "{}_{}".format(base, index)
|
||||||
index += 1
|
index += 1
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
names[column] = name
|
names[column] = name
|
||||||
|
|
@ -57,7 +66,7 @@ def _quote_identifier(identifier):
|
||||||
|
|
||||||
|
|
||||||
def _preferred_where_column(table, columns):
|
def _preferred_where_column(table, columns):
|
||||||
lower_table_id = f"{table.lower()}_id"
|
lower_table_id = "{}_id".format(table.lower())
|
||||||
return (
|
return (
|
||||||
next((column for column in columns if column.lower() == "id"), None)
|
next((column for column in columns if column.lower() == "id"), None)
|
||||||
or next(
|
or next(
|
||||||
|
|
@ -82,15 +91,17 @@ def _insert_template_sql(table, columns):
|
||||||
auto_pk = _auto_incrementing_primary_key(columns)
|
auto_pk = _auto_incrementing_primary_key(columns)
|
||||||
insert_columns = [column for column in column_names if column != auto_pk]
|
insert_columns = [column for column in column_names if column != auto_pk]
|
||||||
if not insert_columns:
|
if not insert_columns:
|
||||||
return f"insert into {_quote_identifier(table)}\ndefault values"
|
return "insert into {}\ndefault values".format(_quote_identifier(table))
|
||||||
names = _parameter_names(insert_columns)
|
names = _parameter_names(insert_columns)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
f"insert into {_quote_identifier(table)} (",
|
"insert into {} (".format(_quote_identifier(table)),
|
||||||
",\n".join(f" {_quote_identifier(column)}" for column in insert_columns),
|
",\n".join(
|
||||||
|
" {}".format(_quote_identifier(column)) for column in insert_columns
|
||||||
|
),
|
||||||
")",
|
")",
|
||||||
"values (",
|
"values (",
|
||||||
",\n".join(f" :{names[column]}" for column in insert_columns),
|
",\n".join(" :{}".format(names[column]) for column in insert_columns),
|
||||||
")",
|
")",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -104,14 +115,18 @@ def _update_template_sql(table, columns):
|
||||||
if not set_columns:
|
if not set_columns:
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
f"update {_quote_identifier(table)}",
|
"update {}".format(_quote_identifier(table)),
|
||||||
f"set {_quote_identifier(where_column)} = :new_{names[where_column]}",
|
"set {} = :new_{}".format(
|
||||||
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
_quote_identifier(where_column), names[where_column]
|
||||||
|
),
|
||||||
|
"where {} = :{}".format(
|
||||||
|
_quote_identifier(where_column), names[where_column]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
f"update {_quote_identifier(table)}",
|
"update {}".format(_quote_identifier(table)),
|
||||||
"set "
|
"set "
|
||||||
+ ",\n".join(
|
+ ",\n".join(
|
||||||
"{}{} = :{}".format(
|
"{}{} = :{}".format(
|
||||||
|
|
@ -121,7 +136,9 @@ def _update_template_sql(table, columns):
|
||||||
)
|
)
|
||||||
for index, column in enumerate(set_columns)
|
for index, column in enumerate(set_columns)
|
||||||
),
|
),
|
||||||
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
"where {} = :{}".format(
|
||||||
|
_quote_identifier(where_column), names[where_column]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -132,8 +149,10 @@ def _delete_template_sql(table, columns):
|
||||||
where_column = _preferred_where_column(table, column_names)
|
where_column = _preferred_where_column(table, column_names)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
f"delete from {_quote_identifier(table)}",
|
"delete from {}".format(_quote_identifier(table)),
|
||||||
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
"where {} = :{}".format(
|
||||||
|
_quote_identifier(where_column), names[where_column]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -248,6 +267,7 @@ class ExecuteWriteView(BaseView):
|
||||||
write_template_tables = await _write_template_tables(
|
write_template_tables = await _write_template_tables(
|
||||||
self.ds, db, table_columns, hidden_table_names, request.actor
|
self.ds, db, table_columns, hidden_table_names, request.actor
|
||||||
)
|
)
|
||||||
|
editor_schema = await _editor_schema(self.ds, db.name)
|
||||||
write_template_operations = _write_template_operations(write_template_tables)
|
write_template_operations = _write_template_operations(write_template_tables)
|
||||||
write_create_table_template_sql = await _create_table_template_sql(
|
write_create_table_template_sql = await _create_table_template_sql(
|
||||||
self.ds, db, request.actor
|
self.ds, db, request.actor
|
||||||
|
|
@ -310,7 +330,7 @@ class ExecuteWriteView(BaseView):
|
||||||
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
|
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
|
||||||
"execute_disabled": bool(execute_disabled_reason),
|
"execute_disabled": bool(execute_disabled_reason),
|
||||||
"execute_disabled_reason": execute_disabled_reason,
|
"execute_disabled_reason": execute_disabled_reason,
|
||||||
"table_columns": table_columns,
|
"table_columns": editor_schema,
|
||||||
"write_template_tables": write_template_tables,
|
"write_template_tables": write_template_tables,
|
||||||
"write_template_operations": write_template_operations,
|
"write_template_operations": write_template_operations,
|
||||||
"write_create_table_template_sql": write_create_table_template_sql,
|
"write_create_table_template_sql": write_create_table_template_sql,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import json
|
||||||
|
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
UNSTABLE_API_MESSAGE,
|
|
||||||
CustomJSONEncoder,
|
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
make_slot_function,
|
make_slot_function,
|
||||||
|
CustomJSONEncoder,
|
||||||
|
UNSTABLE_API_MESSAGE,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
from datasette.version import __version__
|
from datasette.version import __version__
|
||||||
|
|
@ -46,15 +46,15 @@ class IndexView(BaseView):
|
||||||
|
|
||||||
databases = []
|
databases = []
|
||||||
# Iterate over allowed databases instead of all databases
|
# Iterate over allowed databases instead of all databases
|
||||||
for name, allowed_db in allowed_db_dict.items():
|
for name in allowed_db_dict.keys():
|
||||||
db = self.ds.databases[name]
|
db = self.ds.databases[name]
|
||||||
database_private = allowed_db.private
|
database_private = allowed_db_dict[name].private
|
||||||
|
|
||||||
# Get allowed tables/views for this database
|
# Get allowed tables/views for this database
|
||||||
allowed_for_db = tables_by_db.get(name, {})
|
allowed_for_db = tables_by_db.get(name, {})
|
||||||
|
|
||||||
# Get table names from allowed set instead of db.table_names()
|
# Get table names from allowed set instead of db.table_names()
|
||||||
table_names = [child_name for child_name in allowed_for_db]
|
table_names = [child_name for child_name in allowed_for_db.keys()]
|
||||||
|
|
||||||
hidden_table_names = set(await db.hidden_table_names())
|
hidden_table_names = set(await db.hidden_table_names())
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ class IndexView(BaseView):
|
||||||
# We will be sorting by number of relationships, so populate that field
|
# We will be sorting by number of relationships, so populate that field
|
||||||
all_foreign_keys = await db.get_all_foreign_keys()
|
all_foreign_keys = await db.get_all_foreign_keys()
|
||||||
for table, foreign_keys in all_foreign_keys.items():
|
for table, foreign_keys in all_foreign_keys.items():
|
||||||
if table in tables:
|
if table in tables.keys():
|
||||||
count = len(foreign_keys["incoming"] + foreign_keys["outgoing"])
|
count = len(foreign_keys["incoming"] + foreign_keys["outgoing"])
|
||||||
tables[table]["num_relationships_for_sorting"] = count
|
tables[table]["num_relationships_for_sorting"] = count
|
||||||
|
|
||||||
|
|
@ -121,7 +121,8 @@ class IndexView(BaseView):
|
||||||
# Only add views if this is less than TRUNCATE_AT
|
# Only add views if this is less than TRUNCATE_AT
|
||||||
if len(tables_and_views_truncated) < TRUNCATE_AT:
|
if len(tables_and_views_truncated) < TRUNCATE_AT:
|
||||||
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
|
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
|
||||||
tables_and_views_truncated.extend(views[:num_views_to_add])
|
for view in views[:num_views_to_add]:
|
||||||
|
tables_and_views_truncated.append(view)
|
||||||
|
|
||||||
databases.append(
|
databases.append(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,6 @@ from datasette.resources import DatabaseResource
|
||||||
from datasette.stored_queries import (
|
from datasette.stored_queries import (
|
||||||
StoredQuery,
|
StoredQuery,
|
||||||
)
|
)
|
||||||
from datasette.utils import (
|
|
||||||
InvalidSql,
|
|
||||||
escape_sqlite,
|
|
||||||
parse_size_limit,
|
|
||||||
path_from_row_pks,
|
|
||||||
sqlite3,
|
|
||||||
validate_sql_select,
|
|
||||||
)
|
|
||||||
from datasette.utils import (
|
|
||||||
named_parameters as derive_named_parameters,
|
|
||||||
)
|
|
||||||
from datasette.utils.asgi import Forbidden
|
|
||||||
from datasette.utils.sql_analysis import Operation, SQLAnalysis
|
|
||||||
from datasette.write_sql import (
|
from datasette.write_sql import (
|
||||||
IgnoreWriteSqlOperation,
|
IgnoreWriteSqlOperation,
|
||||||
QueryWriteRejected,
|
QueryWriteRejected,
|
||||||
|
|
@ -25,6 +12,17 @@ from datasette.write_sql import (
|
||||||
decision_for_write_sql_operation,
|
decision_for_write_sql_operation,
|
||||||
operation_is_write,
|
operation_is_write,
|
||||||
)
|
)
|
||||||
|
from datasette.utils import (
|
||||||
|
parse_size_limit,
|
||||||
|
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_name_re = re.compile(r"^[^/\.\n]+$")
|
||||||
|
|
||||||
|
|
@ -93,7 +91,7 @@ def _as_optional_bool(value, name):
|
||||||
return True
|
return True
|
||||||
if lowered in {"0", "false", "f", "no", "off"}:
|
if lowered in {"0", "false", "f", "no", "off"}:
|
||||||
return False
|
return False
|
||||||
raise QueryValidationError(f"{name} must be 0 or 1")
|
raise QueryValidationError("{} must be 0 or 1".format(name))
|
||||||
|
|
||||||
|
|
||||||
def _query_list_limit(value, default, maximum):
|
def _query_list_limit(value, default, maximum):
|
||||||
|
|
@ -173,7 +171,7 @@ async def _json_or_form_payload(request):
|
||||||
try:
|
try:
|
||||||
return json.loads(body or b"{}"), True
|
return json.loads(body or b"{}"), True
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise QueryValidationError(f"Invalid JSON: {e}")
|
raise QueryValidationError("Invalid JSON: {}".format(e))
|
||||||
return await request.post_vars(), False
|
return await request.post_vars(), False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -194,7 +192,7 @@ async def _analyze_user_query(datasette, db, sql, *, actor):
|
||||||
try:
|
try:
|
||||||
analysis = await db.analyze_sql(sql, params)
|
analysis = await db.analyze_sql(sql, params)
|
||||||
except sqlite3.DatabaseError as ex:
|
except sqlite3.DatabaseError as ex:
|
||||||
raise QueryValidationError(f"Could not analyze query: {ex}") from ex
|
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
||||||
|
|
||||||
is_write = _analysis_is_write(analysis)
|
is_write = _analysis_is_write(analysis)
|
||||||
if is_write:
|
if is_write:
|
||||||
|
|
@ -295,7 +293,8 @@ def _coerce_execute_write_payload(data, is_json):
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key in {"sql", "csrftoken", "_json"}:
|
if key in {"sql", "csrftoken", "_json"}:
|
||||||
continue
|
continue
|
||||||
key = key.removeprefix(SQL_PARAMETER_FORM_PREFIX)
|
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
|
||||||
|
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
|
||||||
params[key] = value
|
params[key] = value
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
raise QueryValidationError("params must be a dictionary")
|
raise QueryValidationError("params must be a dictionary")
|
||||||
|
|
@ -315,7 +314,7 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
|
||||||
try:
|
try:
|
||||||
analysis = await db.analyze_sql(sql, params)
|
analysis = await db.analyze_sql(sql, params)
|
||||||
except sqlite3.DatabaseError as ex:
|
except sqlite3.DatabaseError as ex:
|
||||||
raise QueryValidationError(f"Could not analyze query: {ex}") from ex
|
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
||||||
if not _analysis_is_write(analysis):
|
if not _analysis_is_write(analysis):
|
||||||
raise QueryValidationError(
|
raise QueryValidationError(
|
||||||
"Use /-/query for read-only SQL; this endpoint only executes writes"
|
"Use /-/query for read-only SQL; this endpoint only executes writes"
|
||||||
|
|
@ -497,7 +496,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor):
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
f"select {select} from {escape_sqlite(table)} where rowid = ?",
|
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
|
||||||
[lastrowid],
|
[lastrowid],
|
||||||
)
|
)
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
|
|
@ -635,3 +634,92 @@ async def _table_columns(datasette, database_name):
|
||||||
for view_name in await db.view_names():
|
for view_name in await db.view_names():
|
||||||
table_columns[view_name] = []
|
table_columns[view_name] = []
|
||||||
return table_columns
|
return table_columns
|
||||||
|
|
||||||
|
|
||||||
|
def _column_completion(name, type_):
|
||||||
|
# A @codemirror/lang-sql Completion object for a single column. boost keeps
|
||||||
|
# columns ranked above bare SQL keywords in the autocomplete popup.
|
||||||
|
completion = {
|
||||||
|
"label": name,
|
||||||
|
"type": "property",
|
||||||
|
"boost": 10,
|
||||||
|
}
|
||||||
|
if type_:
|
||||||
|
completion["detail"] = type_
|
||||||
|
return completion
|
||||||
|
|
||||||
|
|
||||||
|
async def _schema_tables(datasette, database_name, *, include_hidden=True):
|
||||||
|
"""
|
||||||
|
Neutral introspection of a database's tables and views for SQL editors.
|
||||||
|
|
||||||
|
Returns an ordered list of dicts, one per table or view::
|
||||||
|
|
||||||
|
{"name": str, "view": bool,
|
||||||
|
"columns": [{"name": str, "type": str}, ...]}
|
||||||
|
|
||||||
|
``type`` is the SQLite declared column type (empty string when the column
|
||||||
|
has no declared type). Regular-table columns come from the internal
|
||||||
|
``catalog_columns`` catalog; views are absent from that catalog so their
|
||||||
|
columns are read directly via PRAGMA table_xinfo. Hidden tables (FTS shadow
|
||||||
|
tables and the like) are excluded unless ``include_hidden`` is True. This is
|
||||||
|
the shared, serialization-agnostic source for both ``_editor_schema`` (which
|
||||||
|
maps it to lang-sql Completion objects) and the ``/-/editor-schema.json``
|
||||||
|
endpoint (which emits it directly).
|
||||||
|
"""
|
||||||
|
internal_db = datasette.get_internal_database()
|
||||||
|
result = await internal_db.execute(
|
||||||
|
"select table_name, name, type from catalog_columns where database_name = ?",
|
||||||
|
[database_name],
|
||||||
|
)
|
||||||
|
table_columns = {}
|
||||||
|
for row in result.rows:
|
||||||
|
table_columns.setdefault(row["table_name"], []).append(
|
||||||
|
{"name": row["name"], "type": row["type"]}
|
||||||
|
)
|
||||||
|
db = datasette.get_database(database_name)
|
||||||
|
hidden = set() if include_hidden else set(await db.hidden_table_names())
|
||||||
|
tables = []
|
||||||
|
for table_name, columns in table_columns.items():
|
||||||
|
if table_name in hidden:
|
||||||
|
continue
|
||||||
|
tables.append({"name": table_name, "view": False, "columns": columns})
|
||||||
|
# Views are not represented in catalog_columns, so pull their real columns
|
||||||
|
# directly (PRAGMA table_xinfo works against views too).
|
||||||
|
for view_name in await db.view_names():
|
||||||
|
columns = [
|
||||||
|
{"name": column.name, "type": column.type}
|
||||||
|
for column in await db.table_column_details(view_name)
|
||||||
|
]
|
||||||
|
tables.append({"name": view_name, "view": True, "columns": columns})
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
async def _editor_schema(datasette, database_name):
|
||||||
|
"""
|
||||||
|
Build a lang-sql SQLNamespace for the CodeMirror SQL editor autocomplete.
|
||||||
|
|
||||||
|
Returns a dict keyed by table or view name. Table values are lists of
|
||||||
|
Completion objects (one per column, carrying the column's SQLite type as
|
||||||
|
``detail``). Views are wrapped in a ``{"self": Completion, "children": [...]}``
|
||||||
|
container so the popup can label them as views while still completing their
|
||||||
|
real columns. See @codemirror/lang-sql >= 6.6 SQLNamespace / Completion.
|
||||||
|
"""
|
||||||
|
schema = {}
|
||||||
|
for table in await _schema_tables(datasette, database_name, include_hidden=True):
|
||||||
|
completions = [
|
||||||
|
_column_completion(column["name"], column["type"])
|
||||||
|
for column in table["columns"]
|
||||||
|
]
|
||||||
|
if table["view"]:
|
||||||
|
schema[table["name"]] = {
|
||||||
|
"self": {
|
||||||
|
"label": table["name"],
|
||||||
|
"type": "class",
|
||||||
|
"detail": "view",
|
||||||
|
},
|
||||||
|
"children": completions,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
schema[table["name"]] = completions
|
||||||
|
return schema
|
||||||
|
|
|
||||||
|
|
@ -8,35 +8,34 @@ from dataclasses import dataclass, field
|
||||||
import markupsafe
|
import markupsafe
|
||||||
import sqlite_utils
|
import sqlite_utils
|
||||||
|
|
||||||
|
from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.events import DeleteRowEvent, UpdateRowEvent
|
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
||||||
from datasette.extras import ExtraScope, extra_names_from_request
|
|
||||||
from datasette.plugins import pm
|
|
||||||
from datasette.resources import TableResource
|
from datasette.resources import TableResource
|
||||||
|
from .base import BaseView, DatasetteError, stream_csv
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
CustomJSONEncoder,
|
|
||||||
CustomRow,
|
|
||||||
InvalidSql,
|
|
||||||
WriteJsonValueError,
|
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
|
CustomJSONEncoder,
|
||||||
|
CustomRow,
|
||||||
decode_write_json_row,
|
decode_write_json_row,
|
||||||
escape_sqlite,
|
InvalidSql,
|
||||||
make_slot_function,
|
make_slot_function,
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
path_with_format,
|
path_with_format,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
sqlite3,
|
|
||||||
to_css_class,
|
to_css_class,
|
||||||
|
escape_sqlite,
|
||||||
|
sqlite3,
|
||||||
|
WriteJsonValueError,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response
|
from datasette.plugins import pm
|
||||||
|
from datasette.extras import extra_names_from_request, ExtraScope
|
||||||
from . import Context, from_extra
|
from . import Context, from_extra
|
||||||
from .base import BaseView, DatasetteError, stream_csv
|
|
||||||
from .table import (
|
from .table import (
|
||||||
_table_page_data,
|
|
||||||
display_columns_and_rows,
|
display_columns_and_rows,
|
||||||
|
_table_page_data,
|
||||||
row_label_from_label_column,
|
row_label_from_label_column,
|
||||||
)
|
)
|
||||||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
||||||
|
|
@ -188,16 +187,16 @@ class RowView(BaseView):
|
||||||
data, extra_template_data, templates = response_or_template_contexts
|
data, extra_template_data, templates = response_or_template_contexts
|
||||||
except QueryInterrupted as ex:
|
except QueryInterrupted as ex:
|
||||||
raise DatasetteError(
|
raise DatasetteError(
|
||||||
textwrap.dedent(f"""
|
textwrap.dedent("""
|
||||||
<p>SQL query took too long. The time limit is controlled by the
|
<p>SQL query took too long. The time limit is controlled by the
|
||||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||||
configuration option.</p>
|
configuration option.</p>
|
||||||
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea>
|
<textarea style="width: 90%">{}</textarea>
|
||||||
<script>
|
<script>
|
||||||
let ta = document.querySelector("textarea");
|
let ta = document.querySelector("textarea");
|
||||||
ta.style.height = ta.scrollHeight + "px";
|
ta.style.height = ta.scrollHeight + "px";
|
||||||
</script>
|
</script>
|
||||||
""").strip(),
|
""".format(markupsafe.escape(ex.sql))).strip(),
|
||||||
title="SQL Interrupted",
|
title="SQL Interrupted",
|
||||||
status=400,
|
status=400,
|
||||||
message_is_html=True,
|
message_is_html=True,
|
||||||
|
|
@ -208,13 +207,15 @@ class RowView(BaseView):
|
||||||
)
|
)
|
||||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
raise DatasetteError(str(e))
|
||||||
except DatasetteError:
|
except DatasetteError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
end = time.perf_counter()
|
end = time.perf_counter()
|
||||||
data["query_ms"] = (end - start) * 1000
|
data["query_ms"] = (end - start) * 1000
|
||||||
|
|
||||||
if format_ in self.ds.renderers:
|
if format_ in self.ds.renderers.keys():
|
||||||
# Dispatch request to the correct output format renderer
|
# Dispatch request to the correct output format renderer
|
||||||
# (CSV is not handled here due to streaming)
|
# (CSV is not handled here due to streaming)
|
||||||
result = call_with_supported_arguments(
|
result = call_with_supported_arguments(
|
||||||
|
|
@ -257,7 +258,7 @@ class RowView(BaseView):
|
||||||
if status_code is not None:
|
if status_code is not None:
|
||||||
response.status = status_code
|
response.status = status_code
|
||||||
else:
|
else:
|
||||||
raise NotFound(f"Invalid format: {format_}")
|
raise NotFound("Invalid format: {}".format(format_))
|
||||||
|
|
||||||
ttl = request.args.get("_ttl", None)
|
ttl = request.args.get("_ttl", None)
|
||||||
if ttl is None or not ttl.isdigit():
|
if ttl is None or not ttl.isdigit():
|
||||||
|
|
@ -372,7 +373,9 @@ class RowView(BaseView):
|
||||||
view_name=self.name,
|
view_name=self.name,
|
||||||
),
|
),
|
||||||
headers={
|
headers={
|
||||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||||
|
alternate_url_json
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -497,7 +500,7 @@ class RowView(BaseView):
|
||||||
|
|
||||||
row_action_label = pk_path
|
row_action_label = pk_path
|
||||||
if row_label and row_label != pk_path:
|
if row_label and row_label != pk_path:
|
||||||
row_action_label = f"{pk_path} {row_label}"
|
row_action_label = "{} {}".format(pk_path, row_label)
|
||||||
|
|
||||||
row_action_permissions = {}
|
row_action_permissions = {}
|
||||||
if is_table and db.is_mutable:
|
if is_table and db.is_mutable:
|
||||||
|
|
@ -510,7 +513,7 @@ class RowView(BaseView):
|
||||||
row_actions = []
|
row_actions = []
|
||||||
if row_action_permissions.get("update-row"):
|
if row_action_permissions.get("update-row"):
|
||||||
attrs = {
|
attrs = {
|
||||||
"aria-label": f"Edit row {row_action_label}",
|
"aria-label": "Edit row {}".format(row_action_label),
|
||||||
"data-row": row_path,
|
"data-row": row_path,
|
||||||
"data-row-action": "edit",
|
"data-row-action": "edit",
|
||||||
}
|
}
|
||||||
|
|
@ -526,7 +529,7 @@ class RowView(BaseView):
|
||||||
)
|
)
|
||||||
if row_action_permissions.get("delete-row"):
|
if row_action_permissions.get("delete-row"):
|
||||||
attrs = {
|
attrs = {
|
||||||
"aria-label": f"Delete row {row_action_label}",
|
"aria-label": "Delete row {}".format(row_action_label),
|
||||||
"data-row": row_path,
|
"data-row": row_path,
|
||||||
"data-row-action": "delete",
|
"data-row-action": "delete",
|
||||||
}
|
}
|
||||||
|
|
@ -676,7 +679,7 @@ class RowView(BaseView):
|
||||||
key,
|
key,
|
||||||
",".join(pk_values),
|
",".join(pk_values),
|
||||||
)
|
)
|
||||||
foreign_key_tables.append({**fk, "count": count, "link": link})
|
foreign_key_tables.append({**fk, **{"count": count, "link": link}})
|
||||||
return foreign_key_tables
|
return foreign_key_tables
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -702,21 +705,23 @@ async def _row_flash_message(db, action, resolved, row=None):
|
||||||
if label:
|
if label:
|
||||||
label = _truncated_row_flash_label(label)
|
label = _truncated_row_flash_label(label)
|
||||||
if label and label != pk_label:
|
if label and label != pk_label:
|
||||||
return f"{action} row {pk_label} ({label})"
|
return "{} row {} ({})".format(action, pk_label, label)
|
||||||
return f"{action} row {pk_label}"
|
return "{} row {}".format(action, pk_label)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_row_and_check_permission(datasette, request, permission):
|
async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||||
from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound
|
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await datasette.resolve_row(request)
|
resolved = await datasette.resolve_row(request)
|
||||||
except DatabaseNotFound as e:
|
except DatabaseNotFound as e:
|
||||||
return False, Response.error([f"Database not found: {e.database_name}"], 404)
|
return False, Response.error(
|
||||||
|
["Database not found: {}".format(e.database_name)], 404
|
||||||
|
)
|
||||||
except TableNotFound as e:
|
except TableNotFound as e:
|
||||||
return False, Response.error([f"Table not found: {e.table}"], 404)
|
return False, Response.error(["Table not found: {}".format(e.table)], 404)
|
||||||
except RowNotFound as e:
|
except RowNotFound as e:
|
||||||
return False, Response.error([f"Record not found: {e.pk_values}"], 404)
|
return False, Response.error(["Record not found: {}".format(e.pk_values)], 404)
|
||||||
|
|
||||||
# Ensure user has permission to delete this row
|
# Ensure user has permission to delete this row
|
||||||
if not await datasette.allowed(
|
if not await datasette.allowed(
|
||||||
|
|
@ -748,8 +753,7 @@ class RowDeleteView(BaseView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await resolved.db.execute_write_fn(delete_row, request=request)
|
await resolved.db.execute_write_fn(delete_row, request=request)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
|
||||||
return Response.error([str(e)], 400)
|
return Response.error([str(e)], 400)
|
||||||
|
|
||||||
await self.ds.track_event(
|
await self.ds.track_event(
|
||||||
|
|
@ -789,7 +793,7 @@ class RowUpdateView(BaseView):
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return Response.error([f"Invalid JSON: {e}"])
|
return Response.error(["Invalid JSON: {}".format(e)])
|
||||||
except PayloadTooLarge as e:
|
except PayloadTooLarge as e:
|
||||||
return Response.error([str(e)], 413)
|
return Response.error([str(e)], 413)
|
||||||
|
|
||||||
|
|
@ -832,8 +836,7 @@ class RowUpdateView(BaseView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await resolved.db.execute_write_fn(update_row, request=request)
|
await resolved.db.execute_write_fn(update_row, request=request)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
|
||||||
return Response.error([str(e)], 400)
|
return Response.error([str(e)], 400)
|
||||||
|
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
import urllib
|
|
||||||
|
|
||||||
from datasette.events import CreateTokenEvent, LoginEvent, LogoutEvent
|
|
||||||
from datasette.jump import JumpSQL, namespace_sql_params
|
from datasette.jump import JumpSQL, namespace_sql_params
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
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 import (
|
from datasette.utils import (
|
||||||
UNSTABLE_API_MESSAGE,
|
UNSTABLE_API_MESSAGE,
|
||||||
actor_matches_allow,
|
actor_matches_allow,
|
||||||
|
parse_size_limit,
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
error_body,
|
error_body,
|
||||||
parse_size_limit,
|
|
||||||
tilde_decode,
|
|
||||||
tilde_encode,
|
tilde_encode,
|
||||||
|
tilde_decode,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Forbidden, Response
|
|
||||||
|
|
||||||
from .base import BaseView, View
|
from .base import BaseView, View
|
||||||
|
import secrets
|
||||||
|
import urllib
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -181,7 +179,9 @@ class AutocompleteDebugView(BaseView):
|
||||||
)
|
)
|
||||||
context.update(
|
context.update(
|
||||||
{
|
{
|
||||||
"autocomplete_url": f"{self.ds.urls.table(database_name, table_name)}/-/autocomplete",
|
"autocomplete_url": "{}/-/autocomplete".format(
|
||||||
|
self.ds.urls.table(database_name, table_name)
|
||||||
|
),
|
||||||
"label_column": await db.label_column_for_table(table_name),
|
"label_column": await db.label_column_for_table(table_name),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -420,11 +420,8 @@ class AllowedResourcesView(BaseView):
|
||||||
row["reason"] = resource.reasons
|
row["reason"] = resource.reasons
|
||||||
|
|
||||||
allowed_rows.append(row)
|
allowed_rows.append(row)
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
# Returns empty results if the catalog tables don't exist yet, but
|
# If catalog tables don't exist yet, return empty results
|
||||||
# also swallows the AttributeError raised for instance-level actions
|
|
||||||
# such as view-instance, which have no resource_class.
|
|
||||||
# TODO: handle that case explicitly and narrow this to sqlite3.Error
|
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
@ -526,7 +523,7 @@ class PermissionRulesView(BaseView):
|
||||||
|
|
||||||
from datasette.utils.actions_sql import build_permission_rules_sql
|
from datasette.utils.actions_sql import build_permission_rules_sql
|
||||||
|
|
||||||
union_sql, union_params, _restriction_sqls = await build_permission_rules_sql(
|
union_sql, union_params, restriction_sqls = await build_permission_rules_sql(
|
||||||
self.ds, actor, action
|
self.ds, actor, action
|
||||||
)
|
)
|
||||||
await self.ds.refresh_schemas()
|
await self.ds.refresh_schemas()
|
||||||
|
|
@ -603,7 +600,7 @@ class PermissionRulesView(BaseView):
|
||||||
|
|
||||||
|
|
||||||
async def _check_permission_for_actor(ds, action, parent, child, actor):
|
async def _check_permission_for_actor(ds, action, parent, child, actor):
|
||||||
"""Shared logic for checking and explaining a permission decision."""
|
"""Shared logic for checking permissions. Returns a dict with check results."""
|
||||||
if action not in ds.actions:
|
if action not in ds.actions:
|
||||||
return error_body(f"Unknown action: {action}", 404), 404
|
return error_body(f"Unknown action: {action}", 404), 404
|
||||||
|
|
||||||
|
|
@ -632,28 +629,15 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
|
||||||
|
|
||||||
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
|
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
|
||||||
|
|
||||||
from datasette.utils.actions_sql import explain_permission_for_resource
|
|
||||||
|
|
||||||
explanation = await explain_permission_for_resource(
|
|
||||||
datasette=ds,
|
|
||||||
actor=actor,
|
|
||||||
action=action,
|
|
||||||
parent=parent,
|
|
||||||
child=child,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"unstable": UNSTABLE_API_MESSAGE,
|
|
||||||
"action": action,
|
"action": action,
|
||||||
"allowed": bool(allowed),
|
"allowed": bool(allowed),
|
||||||
"actor": actor,
|
|
||||||
"resource": {
|
"resource": {
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
"child": child,
|
"child": child,
|
||||||
"path": _resource_path(parent, child),
|
"path": _resource_path(parent, child),
|
||||||
},
|
},
|
||||||
"explanation": explanation,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if actor and "id" in actor:
|
if actor and "id" in actor:
|
||||||
|
|
@ -671,25 +655,11 @@ class PermissionCheckView(BaseView):
|
||||||
as_format = request.url_vars.get("format")
|
as_format = request.url_vars.get("format")
|
||||||
|
|
||||||
if not as_format:
|
if not as_format:
|
||||||
actions = [
|
|
||||||
{
|
|
||||||
"name": action.name,
|
|
||||||
"description": action.description,
|
|
||||||
"takes_parent": action.takes_parent,
|
|
||||||
"takes_child": action.takes_child,
|
|
||||||
"also_requires": action.also_requires,
|
|
||||||
}
|
|
||||||
for action in sorted(
|
|
||||||
self.ds.actions.values(), key=lambda action: action.name
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return await self.render(
|
return await self.render(
|
||||||
["debug_check.html"],
|
["debug_check.html"],
|
||||||
request,
|
request,
|
||||||
{
|
{
|
||||||
"actions": actions,
|
"sorted_actions": sorted(self.ds.actions.keys()),
|
||||||
"actor_json": request.args.get("actor")
|
|
||||||
or json.dumps(request.actor, indent=2),
|
|
||||||
"has_debug_permission": True,
|
"has_debug_permission": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -701,18 +671,9 @@ class PermissionCheckView(BaseView):
|
||||||
|
|
||||||
parent = request.args.get("parent")
|
parent = request.args.get("parent")
|
||||||
child = request.args.get("child")
|
child = request.args.get("child")
|
||||||
actor = request.actor
|
|
||||||
actor_json = request.args.get("actor")
|
|
||||||
if actor_json is not None:
|
|
||||||
try:
|
|
||||||
actor = json.loads(actor_json)
|
|
||||||
except json.JSONDecodeError as ex:
|
|
||||||
return Response.error(f"Invalid actor JSON: {ex}", 400)
|
|
||||||
if actor is not None and not isinstance(actor, dict):
|
|
||||||
return Response.error("actor must be a JSON object or null", 400)
|
|
||||||
|
|
||||||
response, status = await _check_permission_for_actor(
|
response, status = await _check_permission_for_actor(
|
||||||
self.ds, action, parent, child, actor
|
self.ds, action, parent, child, request.actor
|
||||||
)
|
)
|
||||||
return Response.json(response, status=status)
|
return Response.json(response, status=status)
|
||||||
|
|
||||||
|
|
@ -939,7 +900,7 @@ class ApiExplorerView(BaseView):
|
||||||
tables.append({"name": table, "links": table_links})
|
tables.append({"name": table, "links": table_links})
|
||||||
table_links.append(
|
table_links.append(
|
||||||
{
|
{
|
||||||
"label": f"Get rows for {table}",
|
"label": "Get rows for {}".format(table),
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": self.ds.urls.table(name, table, format="json"),
|
"path": self.ds.urls.table(name, table, format="json"),
|
||||||
}
|
}
|
||||||
|
|
@ -959,7 +920,7 @@ class ApiExplorerView(BaseView):
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.table(name, table) + "/-/insert",
|
"path": self.ds.urls.table(name, table) + "/-/insert",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"label": f"Insert rows into {table}",
|
"label": "Insert rows into {}".format(table),
|
||||||
"json": {
|
"json": {
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
|
|
@ -973,7 +934,7 @@ class ApiExplorerView(BaseView):
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.table(name, table) + "/-/upsert",
|
"path": self.ds.urls.table(name, table) + "/-/upsert",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"label": f"Upsert rows into {table}",
|
"label": "Upsert rows into {}".format(table),
|
||||||
"json": {
|
"json": {
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
|
|
@ -1003,7 +964,7 @@ class ApiExplorerView(BaseView):
|
||||||
table_links.append(
|
table_links.append(
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.table(name, table) + "/-/drop",
|
"path": self.ds.urls.table(name, table) + "/-/drop",
|
||||||
"label": f"Drop table {table}",
|
"label": "Drop table {}".format(table),
|
||||||
"json": {"confirm": False},
|
"json": {"confirm": False},
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
}
|
}
|
||||||
|
|
@ -1020,7 +981,7 @@ class ApiExplorerView(BaseView):
|
||||||
database_links.append(
|
database_links.append(
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.database(name) + "/-/create",
|
"path": self.ds.urls.database(name) + "/-/create",
|
||||||
"label": f"Create table in {name}",
|
"label": "Create table in {}".format(name),
|
||||||
"json": {
|
"json": {
|
||||||
"table": "new_table",
|
"table": "new_table",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
|
@ -1384,6 +1345,59 @@ class DatabaseSchemaView(SchemaBaseView):
|
||||||
return await self.format_html_response(request, schemas)
|
return await self.format_html_response(request, schemas)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseEditorSchemaView(BaseView):
|
||||||
|
"""
|
||||||
|
JSON introspection of a database's tables, views and columns shaped for SQL
|
||||||
|
editor autocomplete consumers (the CodeMirror ``<datasette-sql-editor>``
|
||||||
|
component and external clients such as datasette-paper).
|
||||||
|
|
||||||
|
Distinct from :class:`DatabaseSchemaView` (``/<db>/-/schema.json``), which
|
||||||
|
returns the raw DDL as a SQL string gated on ``view-database`` alone. This
|
||||||
|
endpoint returns a neutral structured payload and is gated on both
|
||||||
|
``view-database`` and ``execute-sql`` — the same permissions as the inline
|
||||||
|
editor schema handed to the SQL query page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "database_editor_schema"
|
||||||
|
has_json_alternate = False
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
from .query_helpers import _schema_tables
|
||||||
|
|
||||||
|
database_name = request.url_vars["database"]
|
||||||
|
|
||||||
|
# view-database is checked first so actors without it cannot
|
||||||
|
# distinguish an existing database from a missing one, and a denied
|
||||||
|
# request only ever leaks the permission action name, never table names.
|
||||||
|
await self.ds.ensure_permission(
|
||||||
|
action="view-database",
|
||||||
|
resource=DatabaseResource(database=database_name),
|
||||||
|
actor=request.actor,
|
||||||
|
)
|
||||||
|
if database_name not in self.ds.databases:
|
||||||
|
headers = {}
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
return Response.json(
|
||||||
|
error_body("Database not found", 404), status=404, headers=headers
|
||||||
|
)
|
||||||
|
await self.ds.ensure_permission(
|
||||||
|
action="execute-sql",
|
||||||
|
resource=DatabaseResource(database=database_name),
|
||||||
|
actor=request.actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.ds.refresh_schemas()
|
||||||
|
tables = await _schema_tables(self.ds, database_name, include_hidden=False)
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
return Response.json(
|
||||||
|
{"database": database_name, "tables": tables}, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TableSchemaView(SchemaBaseView):
|
class TableSchemaView(SchemaBaseView):
|
||||||
"""
|
"""
|
||||||
Displays schema for a specific table.
|
Displays schema for a specific table.
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ class QueryListView(BaseView):
|
||||||
pairs.append(("_next", page.next))
|
pairs.append(("_next", page.next))
|
||||||
next_url = self.ds.absolute_url(
|
next_url = self.ds.absolute_url(
|
||||||
request,
|
request,
|
||||||
f"{request.path}?{urlencode(pairs)}",
|
"{}?{}".format(request.path, urlencode(pairs)),
|
||||||
)
|
)
|
||||||
|
|
||||||
current_filters = {
|
current_filters = {
|
||||||
|
|
@ -415,7 +415,7 @@ class QueryDefinitionView(BaseView):
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
query_name = tilde_decode(request.url_vars["query"])
|
||||||
query = await self.ds.get_query(db.name, query_name)
|
query = await self.ds.get_query(db.name, query_name)
|
||||||
if query is None:
|
if query is None:
|
||||||
return Response.error([f"Query not found: {query_name}"], 404)
|
return Response.error(["Query not found: {}".format(query_name)], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="view-query",
|
action="view-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
|
|
@ -439,7 +439,7 @@ class QueryUpdateView(BaseView):
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
query_name = tilde_decode(request.url_vars["query"])
|
||||||
existing = await self.ds.get_query(db.name, query_name)
|
existing = await self.ds.get_query(db.name, query_name)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return Response.error([f"Query not found: {query_name}"], 404)
|
return Response.error(["Query not found: {}".format(query_name)], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="update-query",
|
action="update-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
|
|
@ -532,7 +532,7 @@ class QueryEditView(BaseView):
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return Response.error([f"Query not found: {query_name}"], 404)
|
return Response.error(["Query not found: {}".format(query_name)], 404)
|
||||||
await self.ds.ensure_permission(
|
await self.ds.ensure_permission(
|
||||||
action="update-query",
|
action="update-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
|
|
@ -545,7 +545,7 @@ class QueryEditView(BaseView):
|
||||||
async def post(self, request):
|
async def post(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return Response.error([f"Query not found: {query_name}"], 404)
|
return Response.error(["Query not found: {}".format(query_name)], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="update-query",
|
action="update-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
|
|
@ -629,7 +629,7 @@ class QueryDeleteView(BaseView):
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return Response.error([f"Query not found: {query_name}"], 404)
|
return Response.error(["Query not found: {}".format(query_name)], 404)
|
||||||
await self.ds.ensure_permission(
|
await self.ds.ensure_permission(
|
||||||
action="delete-query",
|
action="delete-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
|
|
@ -653,7 +653,7 @@ class QueryDeleteView(BaseView):
|
||||||
async def post(self, request):
|
async def post(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return Response.error([f"Query not found: {query_name}"], 404)
|
return Response.error(["Query not found: {}".format(query_name)], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="delete-query",
|
action="delete-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
|
|
@ -665,13 +665,13 @@ class QueryDeleteView(BaseView):
|
||||||
["Trusted queries cannot be deleted using the API"], 403
|
["Trusted queries cannot be deleted using the API"], 403
|
||||||
)
|
)
|
||||||
|
|
||||||
_data, is_json = await _json_or_form_payload(request)
|
data, is_json = await _json_or_form_payload(request)
|
||||||
await self.ds.remove_query(db.name, query_name)
|
await self.ds.remove_query(db.name, query_name)
|
||||||
if is_json:
|
if is_json:
|
||||||
return Response.json({"ok": True})
|
return Response.json({"ok": True})
|
||||||
self.ds.add_message(
|
self.ds.add_message(
|
||||||
request,
|
request,
|
||||||
f"Query “{existing.title or query_name}” deleted",
|
"Query “{}” deleted".format(existing.title or query_name),
|
||||||
self.ds.INFO,
|
self.ds.INFO,
|
||||||
)
|
)
|
||||||
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))
|
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))
|
||||||
|
|
|
||||||
|
|
@ -3,51 +3,48 @@ import itertools
|
||||||
import json
|
import json
|
||||||
import urllib
|
import urllib
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import markupsafe
|
import markupsafe
|
||||||
import sqlite_utils
|
|
||||||
|
|
||||||
from datasette import tracer
|
|
||||||
from datasette.column_types import SQLiteType
|
from datasette.column_types import SQLiteType
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.extras import extra_names_from_request
|
||||||
|
from datasette.plugins import pm
|
||||||
from datasette.events import (
|
from datasette.events import (
|
||||||
AlterTableEvent,
|
AlterTableEvent,
|
||||||
DropTableEvent,
|
DropTableEvent,
|
||||||
InsertRowsEvent,
|
InsertRowsEvent,
|
||||||
UpsertRowsEvent,
|
UpsertRowsEvent,
|
||||||
)
|
)
|
||||||
from datasette.extras import ExtraScope, extra_names_from_request
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.filters import Filters
|
from datasette import tracer
|
||||||
from datasette.plugins import pm
|
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
from datasette.resources import DatabaseResource, TableResource
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
CustomJSONEncoder,
|
|
||||||
CustomRow,
|
|
||||||
InvalidSql,
|
|
||||||
WriteJsonValueError,
|
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
append_querystring,
|
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
|
CustomJSONEncoder,
|
||||||
|
CustomRow,
|
||||||
|
append_querystring,
|
||||||
compound_keys_after_sql,
|
compound_keys_after_sql,
|
||||||
decode_write_json_rows,
|
decode_write_json_rows,
|
||||||
|
format_bytes,
|
||||||
|
make_slot_function,
|
||||||
|
tilde_encode,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
filters_should_redirect,
|
filters_should_redirect,
|
||||||
format_bytes,
|
|
||||||
is_url,
|
is_url,
|
||||||
make_slot_function,
|
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
path_with_format,
|
path_with_format,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
path_with_replaced_args,
|
path_with_replaced_args,
|
||||||
sqlite3,
|
|
||||||
tilde_encode,
|
|
||||||
to_css_class,
|
to_css_class,
|
||||||
truncate_url,
|
truncate_url,
|
||||||
urlsafe_components,
|
urlsafe_components,
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
|
InvalidSql,
|
||||||
|
WriteJsonValueError,
|
||||||
|
sqlite3,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import (
|
from datasette.utils.asgi import (
|
||||||
BadRequest,
|
BadRequest,
|
||||||
|
|
@ -57,7 +54,11 @@ from datasette.utils.asgi import (
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
)
|
)
|
||||||
|
from datasette.filters import Filters
|
||||||
|
import sqlite_utils
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from datasette.extras import ExtraScope
|
||||||
from . import Context, from_extra
|
from . import Context, from_extra
|
||||||
from .base import BaseView, DatasetteError, stream_csv
|
from .base import BaseView, DatasetteError, stream_csv
|
||||||
from .database import QueryView
|
from .database import QueryView
|
||||||
|
|
@ -535,7 +536,7 @@ async def _table_insert_ui(
|
||||||
columns.append(column_data)
|
columns.append(column_data)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"path": f"{datasette.urls.table(database_name, table_name)}/-/insert",
|
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
|
||||||
"tableName": table_name,
|
"tableName": table_name,
|
||||||
"columns": columns,
|
"columns": columns,
|
||||||
"bulkColumns": bulk_columns,
|
"bulkColumns": bulk_columns,
|
||||||
|
|
@ -543,8 +544,8 @@ async def _table_insert_ui(
|
||||||
"maxInsertRows": datasette.setting("max_insert_rows"),
|
"maxInsertRows": datasette.setting("max_insert_rows"),
|
||||||
}
|
}
|
||||||
if can_update:
|
if can_update:
|
||||||
data["upsertPath"] = (
|
data["upsertPath"] = "{}/-/upsert".format(
|
||||||
f"{datasette.urls.table(database_name, table_name)}/-/upsert"
|
datasette.urls.table(database_name, table_name)
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
@ -603,7 +604,7 @@ async def _table_alter_ui(
|
||||||
columns.append(column_data)
|
columns.append(column_data)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"path": f"{datasette.urls.table(database_name, table_name)}/-/alter",
|
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
|
||||||
"tableName": table_name,
|
"tableName": table_name,
|
||||||
"columns": columns,
|
"columns": columns,
|
||||||
"primaryKeys": pks,
|
"primaryKeys": pks,
|
||||||
|
|
@ -629,7 +630,9 @@ async def _table_alter_ui(
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
if can_drop_table:
|
if can_drop_table:
|
||||||
data["dropPath"] = f"{datasette.urls.table(database_name, table_name)}/-/drop"
|
data["dropPath"] = "{}/-/drop".format(
|
||||||
|
datasette.urls.table(database_name, table_name)
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -725,10 +728,12 @@ async def display_columns_and_rows(
|
||||||
row_label = row_label_from_label_column(row, label_column)
|
row_label = row_label_from_label_column(row, label_column)
|
||||||
row_action_label = pk_path
|
row_action_label = pk_path
|
||||||
if row_label and row_label != pk_path:
|
if row_label and row_label != pk_path:
|
||||||
row_action_label = f"{pk_path} {row_label}"
|
row_action_label = "{} {}".format(pk_path, row_label)
|
||||||
table_path = datasette.urls.table(database_name, table_name)
|
table_path = datasette.urls.table(database_name, table_name)
|
||||||
row_link = (
|
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
|
||||||
f'<a href="{table_path}/{row_path}">{markupsafe.escape(pk_path)!s}</a>'
|
table_path=table_path,
|
||||||
|
flat_pks=str(markupsafe.escape(pk_path)),
|
||||||
|
flat_pks_quoted=row_path,
|
||||||
)
|
)
|
||||||
edit_icon = (
|
edit_icon = (
|
||||||
'<svg class="row-inline-action-icon" aria-hidden="true" '
|
'<svg class="row-inline-action-icon" aria-hidden="true" '
|
||||||
|
|
@ -755,16 +760,22 @@ async def display_columns_and_rows(
|
||||||
if row_action_permissions.get("update-row"):
|
if row_action_permissions.get("update-row"):
|
||||||
row_actions.append(
|
row_actions.append(
|
||||||
'<button type="button" class="row-inline-action row-inline-action-edit" '
|
'<button type="button" class="row-inline-action row-inline-action-edit" '
|
||||||
f'aria-label="Edit row {markupsafe.escape(row_action_label)}" title="Edit row" '
|
'aria-label="Edit row {row_label}" title="Edit row" '
|
||||||
'data-row-action="edit">'
|
'data-row-action="edit">'
|
||||||
f"{edit_icon}</button>"
|
"{edit_icon}</button>".format(
|
||||||
|
edit_icon=edit_icon,
|
||||||
|
row_label=markupsafe.escape(row_action_label),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if row_action_permissions.get("delete-row"):
|
if row_action_permissions.get("delete-row"):
|
||||||
row_actions.append(
|
row_actions.append(
|
||||||
'<button type="button" class="row-inline-action row-inline-action-delete" '
|
'<button type="button" class="row-inline-action row-inline-action-delete" '
|
||||||
f'aria-label="Delete row {markupsafe.escape(row_action_label)}" title="Delete row" '
|
'aria-label="Delete row {row_label}" title="Delete row" '
|
||||||
'data-row-action="delete">'
|
'data-row-action="delete">'
|
||||||
f"{delete_icon}</button>"
|
"{delete_icon}</button>".format(
|
||||||
|
delete_icon=delete_icon,
|
||||||
|
row_label=markupsafe.escape(row_action_label),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if row_actions:
|
if row_actions:
|
||||||
row_link = (
|
row_link = (
|
||||||
|
|
@ -832,7 +843,11 @@ async def display_columns_and_rows(
|
||||||
path_from_row_pks(row, pks, not pks),
|
path_from_row_pks(row, pks, not pks),
|
||||||
column,
|
column,
|
||||||
),
|
),
|
||||||
(f' title="{formatted}"' if "bytes" not in formatted else ""),
|
(
|
||||||
|
' title="{}"'.format(formatted)
|
||||||
|
if "bytes" not in formatted
|
||||||
|
else ""
|
||||||
|
),
|
||||||
len(value),
|
len(value),
|
||||||
"" if len(value) == 1 else "s",
|
"" if len(value) == 1 else "s",
|
||||||
)
|
)
|
||||||
|
|
@ -944,7 +959,7 @@ class TableInsertView(BaseView):
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return _errors([f"Invalid JSON: {e}"])
|
return _errors(["Invalid JSON: {}".format(e)])
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return _errors(["JSON must be a dictionary"])
|
return _errors(["JSON must be a dictionary"])
|
||||||
keys = data.keys()
|
keys = data.keys()
|
||||||
|
|
@ -972,7 +987,9 @@ class TableInsertView(BaseView):
|
||||||
# Does this exceed max_insert_rows?
|
# Does this exceed max_insert_rows?
|
||||||
max_insert_rows = self.ds.setting("max_insert_rows")
|
max_insert_rows = self.ds.setting("max_insert_rows")
|
||||||
if len(rows) > max_insert_rows:
|
if len(rows) > max_insert_rows:
|
||||||
return _errors([f"Too many rows, maximum allowed is {max_insert_rows}"])
|
return _errors(
|
||||||
|
["Too many rows, maximum allowed is {}".format(max_insert_rows)]
|
||||||
|
)
|
||||||
|
|
||||||
# Validate other parameters
|
# Validate other parameters
|
||||||
extras = {
|
extras = {
|
||||||
|
|
@ -1030,7 +1047,7 @@ class TableInsertView(BaseView):
|
||||||
# Table must exist (may handle table creation in the future)
|
# Table must exist (may handle table creation in the future)
|
||||||
db = self.ds.get_database(database_name)
|
db = self.ds.get_database(database_name)
|
||||||
if not await db.table_exists(table_name):
|
if not await db.table_exists(table_name):
|
||||||
return Response.error([f"Table not found: {table_name}"], 404)
|
return Response.error(["Table not found: {}".format(table_name)], 404)
|
||||||
|
|
||||||
if upsert:
|
if upsert:
|
||||||
# Must have insert-row AND upsert-row permissions
|
# Must have insert-row AND upsert-row permissions
|
||||||
|
|
@ -1153,15 +1170,14 @@ class TableInsertView(BaseView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
|
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
|
||||||
return Response.error([str(e)])
|
return Response.error([str(e)])
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
if should_return:
|
if should_return:
|
||||||
if upsert:
|
if upsert:
|
||||||
# Fetch based on initial input IDs
|
# Fetch based on initial input IDs
|
||||||
where_clause = " OR ".join(
|
where_clause = " OR ".join(
|
||||||
["({})".format(" AND ".join(f"{pk} = ?" for pk in pks))]
|
["({})".format(" AND ".join("{} = ?".format(pk) for pk in pks))]
|
||||||
* len(row_pk_values_for_later)
|
* len(row_pk_values_for_later)
|
||||||
)
|
)
|
||||||
args = list(itertools.chain.from_iterable(row_pk_values_for_later))
|
args = list(itertools.chain.from_iterable(row_pk_values_for_later))
|
||||||
|
|
@ -1251,7 +1267,7 @@ class TableSetColumnTypeView(BaseView):
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return Response.error([f"Invalid JSON: {e}"], 400)
|
return Response.error(["Invalid JSON: {}".format(e)], 400)
|
||||||
except PayloadTooLarge as e:
|
except PayloadTooLarge as e:
|
||||||
return Response.error([str(e)], 413)
|
return Response.error([str(e)], 413)
|
||||||
|
|
||||||
|
|
@ -1278,7 +1294,7 @@ class TableSetColumnTypeView(BaseView):
|
||||||
database_name, table_name
|
database_name, table_name
|
||||||
)
|
)
|
||||||
if column not in column_details:
|
if column not in column_details:
|
||||||
return Response.error([f"Column not found: {column}"], 400)
|
return Response.error(["Column not found: {}".format(column)], 400)
|
||||||
|
|
||||||
column_type_data = data["column_type"]
|
column_type_data = data["column_type"]
|
||||||
if column_type_data is None:
|
if column_type_data is None:
|
||||||
|
|
@ -1319,7 +1335,7 @@ class TableSetColumnTypeView(BaseView):
|
||||||
return Response.error(['"column_type.config" must be a dictionary'], 400)
|
return Response.error(['"column_type.config" must be a dictionary'], 400)
|
||||||
|
|
||||||
if column_type not in self.ds._column_types:
|
if column_type not in self.ds._column_types:
|
||||||
return Response.error([f"Unknown column type: {column_type}"], 400)
|
return Response.error(["Unknown column type: {}".format(column_type)], 400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.ds.set_column_type(
|
await self.ds.set_column_type(
|
||||||
|
|
@ -1357,7 +1373,7 @@ class TableDropView(BaseView):
|
||||||
# Table must exist
|
# Table must exist
|
||||||
db = self.ds.get_database(database_name)
|
db = self.ds.get_database(database_name)
|
||||||
if not await db.table_exists(table_name):
|
if not await db.table_exists(table_name):
|
||||||
return Response.error([f"Table not found: {table_name}"], 404)
|
return Response.error(["Table not found: {}".format(table_name)], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="drop-table",
|
action="drop-table",
|
||||||
resource=TableResource(database=database_name, table=table_name),
|
resource=TableResource(database=database_name, table=table_name),
|
||||||
|
|
@ -1382,7 +1398,7 @@ class TableDropView(BaseView):
|
||||||
"database": database_name,
|
"database": database_name,
|
||||||
"table": table_name,
|
"table": table_name,
|
||||||
"row_count": (
|
"row_count": (
|
||||||
await db.execute(f"select count(*) from [{table_name}]")
|
await db.execute("select count(*) from [{}]".format(table_name))
|
||||||
).single_value(),
|
).single_value(),
|
||||||
"message": 'Pass "confirm": true to confirm',
|
"message": 'Pass "confirm": true to confirm',
|
||||||
},
|
},
|
||||||
|
|
@ -1401,7 +1417,7 @@ class TableDropView(BaseView):
|
||||||
)
|
)
|
||||||
self.ds.add_message(
|
self.ds.add_message(
|
||||||
request,
|
request,
|
||||||
f"Table {table_name} dropped",
|
"Table {} dropped".format(table_name),
|
||||||
self.ds.WARNING,
|
self.ds.WARNING,
|
||||||
)
|
)
|
||||||
return Response.json({"ok": True}, status=200)
|
return Response.json({"ok": True}, status=200)
|
||||||
|
|
@ -1461,28 +1477,32 @@ def _prefix_range_end(value):
|
||||||
|
|
||||||
|
|
||||||
def _autocomplete_like(column):
|
def _autocomplete_like(column):
|
||||||
return f"{escape_sqlite(column)} like :like escape char(92)"
|
return "{} like :like escape char(92)".format(escape_sqlite(column))
|
||||||
|
|
||||||
|
|
||||||
def _autocomplete_prefix_like(column):
|
def _autocomplete_prefix_like(column):
|
||||||
return f"{escape_sqlite(column)} like :prefix escape char(92)"
|
return "{} like :prefix escape char(92)".format(escape_sqlite(column))
|
||||||
|
|
||||||
|
|
||||||
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
|
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
|
||||||
clauses = []
|
clauses = []
|
||||||
if exact_pk:
|
if exact_pk:
|
||||||
clauses.append(
|
clauses.append(
|
||||||
f"case when cast({escape_sqlite(pks[0])} as text) = :q then 0 else 1 end"
|
"case when cast({} as text) = :q then 0 else 1 end".format(
|
||||||
|
escape_sqlite(pks[0])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if label_column:
|
if label_column:
|
||||||
label_like = _autocomplete_like(label_column)
|
label_like = _autocomplete_like(label_column)
|
||||||
if label_matches_first:
|
if label_matches_first:
|
||||||
clauses.append(f"case when {label_like} then 0 else 1 end")
|
clauses.append("case when {} then 0 else 1 end".format(label_like))
|
||||||
clauses.append(
|
clauses.append(
|
||||||
f"case when {label_like} then length(cast({escape_sqlite(label_column)} as text)) end"
|
"case when {} then length(cast({} as text)) end".format(
|
||||||
|
label_like, escape_sqlite(label_column)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
clauses.append(f"length(cast({escape_sqlite(pks[0])} as text))")
|
clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0])))
|
||||||
clauses.extend(escape_sqlite(pk) for pk in pks)
|
clauses.extend(escape_sqlite(pk) for pk in pks)
|
||||||
return ", ".join(clauses)
|
return ", ".join(clauses)
|
||||||
|
|
||||||
|
|
@ -1549,8 +1569,8 @@ class TableAutocompleteView(BaseView):
|
||||||
return Response.json({"ok": True, "rows": []})
|
return Response.json({"ok": True, "rows": []})
|
||||||
params = {
|
params = {
|
||||||
"q": q,
|
"q": q,
|
||||||
"like": f"%{_escape_like(q)}%",
|
"like": "%{}%".format(_escape_like(q)),
|
||||||
"prefix": f"{_escape_like(q)}%",
|
"prefix": "{}%".format(_escape_like(q)),
|
||||||
}
|
}
|
||||||
|
|
||||||
like_columns = pks[:]
|
like_columns = pks[:]
|
||||||
|
|
@ -1564,13 +1584,18 @@ class TableAutocompleteView(BaseView):
|
||||||
where_sql = "1 = 1"
|
where_sql = "1 = 1"
|
||||||
order_by = _autocomplete_initial_order_by(pks)
|
order_by = _autocomplete_initial_order_by(pks)
|
||||||
|
|
||||||
sql = f"""
|
sql = """
|
||||||
select {select_sql}
|
select {select_sql}
|
||||||
from {escape_sqlite(table_name)}
|
from {table}
|
||||||
where {where_sql}
|
where {where}
|
||||||
order by {order_by}
|
order by {order_by}
|
||||||
limit 10
|
limit 10
|
||||||
"""
|
""".format(
|
||||||
|
select_sql=select_sql,
|
||||||
|
table=escape_sqlite(table_name),
|
||||||
|
where=where_sql,
|
||||||
|
order_by=order_by,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await db.execute(
|
results = await db.execute(
|
||||||
|
|
@ -1582,14 +1607,21 @@ class TableAutocompleteView(BaseView):
|
||||||
if prefix_end:
|
if prefix_end:
|
||||||
params["prefix_end"] = prefix_end
|
params["prefix_end"] = prefix_end
|
||||||
first_pk = escape_sqlite(pks[0])
|
first_pk = escape_sqlite(pks[0])
|
||||||
fallback_where = f"{first_pk} >= :q and {first_pk} < :prefix_end and {fallback_where}"
|
fallback_where = (
|
||||||
fallback_sql = f"""
|
"{first_pk} >= :q and {first_pk} < :prefix_end and {like}"
|
||||||
|
).format(first_pk=first_pk, like=fallback_where)
|
||||||
|
fallback_sql = """
|
||||||
select {select_sql}
|
select {select_sql}
|
||||||
from {escape_sqlite(table_name)}
|
from {table}
|
||||||
where {fallback_where}
|
where {where}
|
||||||
order by {_autocomplete_pk_order_by(pks)}
|
order by {order_by}
|
||||||
limit 10
|
limit 10
|
||||||
"""
|
""".format(
|
||||||
|
select_sql=select_sql,
|
||||||
|
table=escape_sqlite(table_name),
|
||||||
|
where=fallback_where,
|
||||||
|
order_by=_autocomplete_pk_order_by(pks),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
results = await db.execute(
|
results = await db.execute(
|
||||||
fallback_sql,
|
fallback_sql,
|
||||||
|
|
@ -1745,7 +1777,7 @@ async def table_view_traced(datasette, request):
|
||||||
)
|
)
|
||||||
if isinstance(view_data, Response):
|
if isinstance(view_data, Response):
|
||||||
return view_data
|
return view_data
|
||||||
data, rows, columns, _expanded_columns, sql, next_url = view_data
|
data, rows, columns, expanded_columns, sql, next_url = view_data
|
||||||
|
|
||||||
# Handle formats from plugins
|
# Handle formats from plugins
|
||||||
if format_ == "csv":
|
if format_ == "csv":
|
||||||
|
|
@ -1756,8 +1788,8 @@ async def table_view_traced(datasette, request):
|
||||||
rows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
expanded_columns,
|
expanded_columns,
|
||||||
_sql,
|
sql,
|
||||||
_next_url,
|
next_url,
|
||||||
) = await table_view_data(
|
) = await table_view_data(
|
||||||
datasette,
|
datasette,
|
||||||
request,
|
request,
|
||||||
|
|
@ -1774,7 +1806,7 @@ async def table_view_traced(datasette, request):
|
||||||
return data, None, None
|
return data, None, None
|
||||||
|
|
||||||
return await stream_csv(datasette, fetch_data, request, resolved.db.name)
|
return await stream_csv(datasette, fetch_data, request, resolved.db.name)
|
||||||
elif format_ in datasette.renderers:
|
elif format_ in datasette.renderers.keys():
|
||||||
# Dispatch request to the correct output format renderer
|
# Dispatch request to the correct output format renderer
|
||||||
# (CSV is not handled here due to streaming)
|
# (CSV is not handled here due to streaming)
|
||||||
result = call_with_supported_arguments(
|
result = call_with_supported_arguments(
|
||||||
|
|
@ -1832,7 +1864,9 @@ async def table_view_traced(datasette, request):
|
||||||
)
|
)
|
||||||
headers.update(
|
headers.update(
|
||||||
{
|
{
|
||||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||||
|
alternate_url_json
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
table_context = TableContext(
|
table_context = TableContext(
|
||||||
|
|
@ -1917,7 +1951,7 @@ async def table_view_traced(datasette, request):
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
assert False, f"Invalid format: {format_}"
|
assert False, "Invalid format: {}".format(format_)
|
||||||
if next_url:
|
if next_url:
|
||||||
r.headers["link"] = f'<{next_url}>; rel="next"'
|
r.headers["link"] = f'<{next_url}>; rel="next"'
|
||||||
return r
|
return r
|
||||||
|
|
@ -2108,7 +2142,9 @@ async def table_view_data(
|
||||||
extra_desc_only=(
|
extra_desc_only=(
|
||||||
""
|
""
|
||||||
if sort
|
if sort
|
||||||
else f" or {escape_sqlite(sort or sort_desc)} is null"
|
else " or {column2} is null".format(
|
||||||
|
column2=escape_sqlite(sort or sort_desc)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
next_clauses=" and ".join(next_by_pk_clauses),
|
next_clauses=" and ".join(next_by_pk_clauses),
|
||||||
)
|
)
|
||||||
|
|
@ -2150,11 +2186,22 @@ async def table_view_data(
|
||||||
|
|
||||||
# Facets are calculated against SQL without order by or limit
|
# Facets are calculated against SQL without order by or limit
|
||||||
sql_no_order_no_limit = (
|
sql_no_order_no_limit = (
|
||||||
f"select {select_all_columns} from {escape_sqlite(table_name)} {where_clause}"
|
"select {select_all_columns} from {table_name} {where}".format(
|
||||||
|
select_all_columns=select_all_columns,
|
||||||
|
table_name=escape_sqlite(table_name),
|
||||||
|
where=where_clause,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# This is the SQL that populates the main table on the page
|
# This is the SQL that populates the main table on the page
|
||||||
sql = f"select {select_specified_columns} from {escape_sqlite(table_name)} {where_clause}{order_by} limit {page_size + 1}{offset}"
|
sql = "select {select_specified_columns} from {table_name} {where}{order_by} limit {page_size}{offset}".format(
|
||||||
|
select_specified_columns=select_specified_columns,
|
||||||
|
table_name=escape_sqlite(table_name),
|
||||||
|
where=where_clause,
|
||||||
|
order_by=order_by,
|
||||||
|
page_size=page_size + 1,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
if request.args.get("_timelimit"):
|
if request.args.get("_timelimit"):
|
||||||
extra_args["custom_time_limit"] = int(request.args.get("_timelimit"))
|
extra_args["custom_time_limit"] = int(request.args.get("_timelimit"))
|
||||||
|
|
@ -2165,6 +2212,9 @@ async def table_view_data(
|
||||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
raise DatasetteError(str(e))
|
||||||
|
|
||||||
columns = [r[0] for r in results.description]
|
columns = [r[0] for r in results.description]
|
||||||
rows = list(results.rows)
|
rows = list(results.rows)
|
||||||
|
|
||||||
|
|
@ -2211,8 +2261,7 @@ async def table_view_data(
|
||||||
new_rows = []
|
new_rows = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
new_row = CustomRow(columns)
|
new_row = CustomRow(columns)
|
||||||
# CustomRow/sqlite3.Row iterate over values, so .keys() is required
|
for column in row.keys():
|
||||||
for column in row.keys(): # noqa: SIM118
|
|
||||||
value = row[column]
|
value = row[column]
|
||||||
if (column, value) in expanded_labels and value is not None:
|
if (column, value) in expanded_labels and value is not None:
|
||||||
new_row[column] = {
|
new_row[column] = {
|
||||||
|
|
@ -2249,7 +2298,7 @@ async def table_view_data(
|
||||||
# Data formats reject unknown extras; the HTML path (which passes
|
# Data formats reject unknown extras; the HTML path (which passes
|
||||||
# extra_extras={"_html"}) resolves internal extras of its own
|
# extra_extras={"_html"}) resolves internal extras of its own
|
||||||
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
|
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
|
||||||
if any(k for k in request.args if k == "_facet" or k.startswith("_facet_")):
|
if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")):
|
||||||
extras.add("facet_results")
|
extras.add("facet_results")
|
||||||
if request.args.get("_shape") == "object":
|
if request.args.get("_shape") == "object":
|
||||||
extras.add("primary_keys")
|
extras.add("primary_keys")
|
||||||
|
|
@ -2430,13 +2479,20 @@ async def _next_value_and_url(
|
||||||
except IndexError:
|
except IndexError:
|
||||||
# sort/sort_desc column missing from SELECT - look up value by PK instead
|
# sort/sort_desc column missing from SELECT - look up value by PK instead
|
||||||
prefix_where_clause = " and ".join(
|
prefix_where_clause = " and ".join(
|
||||||
f"[{pk}] = :pk{i}" for i, pk in enumerate(pks)
|
"[{}] = :pk{}".format(pk, i) for i, pk in enumerate(pks)
|
||||||
|
)
|
||||||
|
prefix_lookup_sql = "select [{}] from [{}] where {}".format(
|
||||||
|
sort or sort_desc, table_name, prefix_where_clause
|
||||||
)
|
)
|
||||||
prefix_lookup_sql = f"select [{sort or sort_desc}] from [{table_name}] where {prefix_where_clause}"
|
|
||||||
prefix = (
|
prefix = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
prefix_lookup_sql,
|
prefix_lookup_sql,
|
||||||
{**{f"pk{i}": rows[-2][pk] for i, pk in enumerate(pks)}},
|
{
|
||||||
|
**{
|
||||||
|
"pk{}".format(i): rows[-2][pk]
|
||||||
|
for i, pk in enumerate(pks)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
).single_value()
|
).single_value()
|
||||||
if isinstance(prefix, dict) and "value" in prefix:
|
if isinstance(prefix, dict) and "value" in prefix:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal, Union
|
||||||
|
|
||||||
import sqlite_utils
|
from datasette.database import QueryInterrupted
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
ConfigDict,
|
ConfigDict,
|
||||||
|
|
@ -13,18 +13,18 @@ from pydantic import (
|
||||||
model_validator,
|
model_validator,
|
||||||
)
|
)
|
||||||
from pydantic_core import PydanticCustomError
|
from pydantic_core import PydanticCustomError
|
||||||
|
import sqlite_utils
|
||||||
from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT
|
from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT
|
||||||
|
|
||||||
from datasette.column_types import SQLiteType
|
from datasette.column_types import SQLiteType
|
||||||
from datasette.database import QueryInterrupted
|
|
||||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
from datasette.resources import DatabaseResource, TableResource
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
WriteJsonValueError,
|
|
||||||
decode_write_json_rows,
|
decode_write_json_rows,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
get_outbound_foreign_keys,
|
get_outbound_foreign_keys,
|
||||||
table_column_details,
|
table_column_details,
|
||||||
|
WriteJsonValueError,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
|
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
|
||||||
from datasette.utils.sqlite import sqlite_hidden_table_names
|
from datasette.utils.sqlite import sqlite_hidden_table_names
|
||||||
|
|
@ -136,14 +136,14 @@ def _foreign_key_name_reasons(source_column, target):
|
||||||
singular_table = _singular(table)
|
singular_table = _singular(table)
|
||||||
column = target["fk_column"].lower()
|
column = target["fk_column"].lower()
|
||||||
possible_names = {
|
possible_names = {
|
||||||
f"{table}_{column}",
|
"{}_{}".format(table, column),
|
||||||
f"{singular_table}_{column}",
|
"{}_{}".format(singular_table, column),
|
||||||
}
|
}
|
||||||
if column == "id":
|
if column == "id":
|
||||||
possible_names.update(
|
possible_names.update(
|
||||||
{
|
{
|
||||||
f"{table}_id",
|
"{}_id".format(table),
|
||||||
f"{singular_table}_id",
|
"{}_id".format(singular_table),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return ["name_match"] if source in possible_names else []
|
return ["name_match"] if source in possible_names else []
|
||||||
|
|
@ -262,8 +262,10 @@ async def _create_table_ui_context(
|
||||||
if not database_action_permissions.get("create-table"):
|
if not database_action_permissions.get("create-table"):
|
||||||
return None
|
return None
|
||||||
data = {
|
data = {
|
||||||
"path": f"{datasette.urls.database(database_name)}/-/create",
|
"path": "{}/-/create".format(datasette.urls.database(database_name)),
|
||||||
"foreignKeyTargetsPath": f"{datasette.urls.database(database_name)}/-/foreign-key-targets",
|
"foreignKeyTargetsPath": "{}/-/foreign-key-targets".format(
|
||||||
|
datasette.urls.database(database_name)
|
||||||
|
),
|
||||||
"databaseName": database_name,
|
"databaseName": database_name,
|
||||||
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
|
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
|
||||||
"defaultExpressions": default_expression_options(),
|
"defaultExpressions": default_expression_options(),
|
||||||
|
|
@ -396,15 +398,15 @@ def default_expr_for_sql(expression):
|
||||||
|
|
||||||
def _quoted_options(options):
|
def _quoted_options(options):
|
||||||
if len(options) == 1:
|
if len(options) == 1:
|
||||||
return f"'{options[0]}'"
|
return "'{}'".format(options[0])
|
||||||
return "{} or '{}'".format(
|
return "{} or '{}'".format(
|
||||||
", ".join(f"'{option}'" for option in options[:-1]),
|
", ".join("'{}'".format(option) for option in options[:-1]),
|
||||||
options[-1],
|
options[-1],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _default_expr_error_message():
|
def _default_expr_error_message():
|
||||||
return f"Input should be {_quoted_options(list(DEFAULT_EXPRESSIONS))}"
|
return "Input should be {}".format(_quoted_options(list(DEFAULT_EXPRESSIONS)))
|
||||||
|
|
||||||
|
|
||||||
def default_expression_options():
|
def default_expression_options():
|
||||||
|
|
@ -713,16 +715,18 @@ class SetForeignKeysOperation(_StrictPydanticModel):
|
||||||
|
|
||||||
|
|
||||||
AlterTableOperation = Annotated[
|
AlterTableOperation = Annotated[
|
||||||
AddColumnOperation
|
Union[
|
||||||
| RenameColumnOperation
|
AddColumnOperation,
|
||||||
| RenameTableOperation
|
RenameColumnOperation,
|
||||||
| AlterColumnOperation
|
RenameTableOperation,
|
||||||
| DropColumnOperation
|
AlterColumnOperation,
|
||||||
| SetPrimaryKeyOperation
|
DropColumnOperation,
|
||||||
| ReorderColumnsOperation
|
SetPrimaryKeyOperation,
|
||||||
| AddForeignKeyOperation
|
ReorderColumnsOperation,
|
||||||
| DropForeignKeyOperation
|
AddForeignKeyOperation,
|
||||||
| SetForeignKeysOperation,
|
DropForeignKeyOperation,
|
||||||
|
SetForeignKeysOperation,
|
||||||
|
],
|
||||||
Field(discriminator="op"),
|
Field(discriminator="op"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -736,7 +740,7 @@ def _pydantic_errors(validation_error):
|
||||||
for error in validation_error.errors():
|
for error in validation_error.errors():
|
||||||
location = ".".join(str(item) for item in error["loc"])
|
location = ".".join(str(item) for item in error["loc"])
|
||||||
message = error["msg"]
|
message = error["msg"]
|
||||||
errors.append(f"{location}: {message}" if location else message)
|
errors.append("{}: {}".format(location, message) if location else message)
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -757,7 +761,7 @@ def _create_table_pydantic_errors(validation_error):
|
||||||
output.append(message)
|
output.append(message)
|
||||||
continue
|
continue
|
||||||
location = ".".join(str(item) for item in error["loc"])
|
location = ".".join(str(item) for item in error["loc"])
|
||||||
output.append(f"{location}: {message}" if location else message)
|
output.append("{}: {}".format(location, message) if location else message)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -806,7 +810,7 @@ class TableCreateView(BaseView):
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return Response.error([f"Invalid JSON: {e}"])
|
return Response.error(["Invalid JSON: {}".format(e)])
|
||||||
except PayloadTooLarge as e:
|
except PayloadTooLarge as e:
|
||||||
return Response.error([str(e)], 413)
|
return Response.error([str(e)], 413)
|
||||||
|
|
||||||
|
|
@ -821,13 +825,14 @@ class TableCreateView(BaseView):
|
||||||
ignore = create_request.ignore
|
ignore = create_request.ignore
|
||||||
replace = create_request.replace
|
replace = create_request.replace
|
||||||
|
|
||||||
# Replacing rows requires update-row permission
|
if replace:
|
||||||
if replace and not await self.ds.allowed(
|
# Must have update-row permission
|
||||||
action="update-row",
|
if not await self.ds.allowed(
|
||||||
resource=DatabaseResource(database=database_name),
|
action="update-row",
|
||||||
actor=request.actor,
|
resource=DatabaseResource(database=database_name),
|
||||||
):
|
actor=request.actor,
|
||||||
return Response.error(["Permission denied: need update-row"], 403)
|
):
|
||||||
|
return Response.error(["Permission denied: need update-row"], 403)
|
||||||
|
|
||||||
table_name = create_request.table
|
table_name = create_request.table
|
||||||
table_exists = await db.table_exists(table_name)
|
table_exists = await db.table_exists(table_name)
|
||||||
|
|
@ -873,14 +878,9 @@ class TableCreateView(BaseView):
|
||||||
actual_pks = await db.primary_keys(table_name)
|
actual_pks = await db.primary_keys(table_name)
|
||||||
# if pk passed and table already exists check it does not change
|
# if pk passed and table already exists check it does not change
|
||||||
bad_pks = False
|
bad_pks = False
|
||||||
if (
|
if len(actual_pks) == 1 and pk and pk != actual_pks[0]:
|
||||||
len(actual_pks) == 1
|
bad_pks = True
|
||||||
and pk
|
elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks):
|
||||||
and pk != actual_pks[0]
|
|
||||||
or len(actual_pks) > 1
|
|
||||||
and pks
|
|
||||||
and set(pks) != set(actual_pks)
|
|
||||||
):
|
|
||||||
bad_pks = True
|
bad_pks = True
|
||||||
if bad_pks:
|
if bad_pks:
|
||||||
return Response.error(["pk cannot be changed for existing table"])
|
return Response.error(["pk cannot be changed for existing table"])
|
||||||
|
|
@ -925,8 +925,7 @@ class TableCreateView(BaseView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
schema = await db.execute_write_fn(create_table, request=request)
|
schema = await db.execute_write_fn(create_table, request=request)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
|
||||||
return Response.error([str(e)])
|
return Response.error([str(e)])
|
||||||
|
|
||||||
if initial_schema is not None and initial_schema != schema:
|
if initial_schema is not None and initial_schema != schema:
|
||||||
|
|
@ -1172,7 +1171,7 @@ class TableAlterView(BaseView):
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return Response.error([f"Invalid JSON: {e}"], 400)
|
return Response.error(["Invalid JSON: {}".format(e)], 400)
|
||||||
except PayloadTooLarge as e:
|
except PayloadTooLarge as e:
|
||||||
return Response.error([str(e)], 413)
|
return Response.error([str(e)], 413)
|
||||||
|
|
||||||
|
|
@ -1312,7 +1311,10 @@ class TableAlterView(BaseView):
|
||||||
and rename_table_to != current_table_name
|
and rename_table_to != current_table_name
|
||||||
):
|
):
|
||||||
operation_conn.execute(
|
operation_conn.execute(
|
||||||
f"alter table {escape_sqlite(current_table_name)} rename to {escape_sqlite(rename_table_to)}"
|
"alter table {} rename to {}".format(
|
||||||
|
escape_sqlite(current_table_name),
|
||||||
|
escape_sqlite(rename_table_to),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
current_table_name = rename_table_to
|
current_table_name = rename_table_to
|
||||||
|
|
||||||
|
|
@ -1327,8 +1329,7 @@ class TableAlterView(BaseView):
|
||||||
before_schema, after_schema, after_table_name = await db.execute_write_fn(
|
before_schema, after_schema, after_table_name = await db.execute_write_fn(
|
||||||
alter_table, request=request
|
alter_table, request=request
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e:
|
||||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
|
||||||
return Response.error([str(e)], 400)
|
return Response.error([str(e)], 400)
|
||||||
|
|
||||||
altered = before_schema != after_schema
|
altered = before_schema != after_schema
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import itertools
|
import itertools
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
from datasette.column_types import SQLiteType
|
from datasette.column_types import SQLiteType
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
|
|
@ -102,7 +101,7 @@ class QueryExtraContext:
|
||||||
class CountSqlExtra(Extra):
|
class CountSqlExtra(Extra):
|
||||||
description = "SQL query string used to calculate the total count for the current table view, including active filters."
|
description = "SQL query string used to calculate the total count for the current table view, including active filters."
|
||||||
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
|
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.count_sql
|
return context.count_sql
|
||||||
|
|
@ -111,7 +110,7 @@ class CountSqlExtra(Extra):
|
||||||
class CountExtra(Extra):
|
class CountExtra(Extra):
|
||||||
description = "Total count of rows matching these filters"
|
description = "Total count of rows matching these filters"
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=count")
|
example = ExtraExample("/fixtures/facetable.json?_extra=count")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
expensive = True
|
expensive = True
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
|
|
@ -129,7 +128,9 @@ class CountExtra(Extra):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if context.count_sql and count is None and not context.nocount:
|
if context.count_sql and count is None and not context.nocount:
|
||||||
count_sql_limited = f"select count(*) from (select * {context.from_sql} limit {context.db.count_limit + 1})"
|
count_sql_limited = "select count(*) from (select * {} limit {})".format(
|
||||||
|
context.from_sql, context.db.count_limit + 1
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
count_rows = list(
|
count_rows = list(
|
||||||
await context.db.execute(count_sql_limited, context.from_sql_params)
|
await context.db.execute(count_sql_limited, context.from_sql_params)
|
||||||
|
|
@ -159,7 +160,7 @@ def count_is_truncated(datasette, db, database_name, table_name, count_sql, coun
|
||||||
class CountTruncatedExtra(Extra):
|
class CountTruncatedExtra(Extra):
|
||||||
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
|
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
|
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
expensive = True
|
expensive = True
|
||||||
|
|
||||||
async def resolve(self, context, count):
|
async def resolve(self, context, count):
|
||||||
|
|
@ -174,7 +175,7 @@ class CountTruncatedExtra(Extra):
|
||||||
|
|
||||||
|
|
||||||
class FacetInstancesProvider(Provider):
|
class FacetInstancesProvider(Provider):
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context, count):
|
async def resolve(self, context, count):
|
||||||
facet_instances = []
|
facet_instances = []
|
||||||
|
|
@ -215,7 +216,7 @@ class FacetResultsExtra(Extra):
|
||||||
},
|
},
|
||||||
note="Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.",
|
note="Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.",
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
expensive = True
|
expensive = True
|
||||||
docs_note = "See :ref:`facets` for details of how facets work."
|
docs_note = "See :ref:`facets` for details of how facets work."
|
||||||
|
|
||||||
|
|
@ -258,7 +259,7 @@ class FacetsTimedOutExtra(Extra):
|
||||||
"if every facet calculation completed."
|
"if every facet calculation completed."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context, facet_results):
|
async def resolve(self, context, facet_results):
|
||||||
return facet_results["timed_out"]
|
return facet_results["timed_out"]
|
||||||
|
|
@ -275,7 +276,7 @@ class SuggestedFacetsExtra(Extra):
|
||||||
],
|
],
|
||||||
note="Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.",
|
note="Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.",
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
expensive = True
|
expensive = True
|
||||||
docs_note = (
|
docs_note = (
|
||||||
"Suggestions are controlled by the :ref:`setting_suggest_facets` setting."
|
"Suggestions are controlled by the :ref:`setting_suggest_facets` setting."
|
||||||
|
|
@ -303,7 +304,7 @@ class HumanDescriptionEnExtra(Extra):
|
||||||
example = ExtraExample(
|
example = ExtraExample(
|
||||||
"/fixtures/facetable.json?state=CA&_sort=pk&_extra=human_description_en"
|
"/fixtures/facetable.json?state=CA&_sort=pk&_extra=human_description_en"
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
human_description_en = context.filters.human_description_en(
|
human_description_en = context.filters.human_description_en(
|
||||||
|
|
@ -323,7 +324,7 @@ class HumanDescriptionEnExtra(Extra):
|
||||||
class ColumnsExtra(Extra):
|
class ColumnsExtra(Extra):
|
||||||
description = "List of column names returned by this table, row or query."
|
description = "List of column names returned by this table, row or query."
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
|
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=columns"
|
"/fixtures/simple_primary_key/1.json?_extra=columns"
|
||||||
),
|
),
|
||||||
|
|
@ -331,11 +332,7 @@ class ColumnsExtra(Extra):
|
||||||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=columns"
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=columns"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.columns
|
return context.columns
|
||||||
|
|
@ -344,7 +341,7 @@ class ColumnsExtra(Extra):
|
||||||
class AllColumnsExtra(Extra):
|
class AllColumnsExtra(Extra):
|
||||||
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
|
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
|
||||||
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
|
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return list(context.table_columns)
|
return list(context.table_columns)
|
||||||
|
|
@ -353,12 +350,12 @@ class AllColumnsExtra(Extra):
|
||||||
class PrimaryKeysExtra(Extra):
|
class PrimaryKeysExtra(Extra):
|
||||||
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
|
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
|
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=primary_keys"
|
"/fixtures/simple_primary_key/1.json?_extra=primary_keys"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.pks
|
return context.pks
|
||||||
|
|
@ -396,12 +393,12 @@ class ColumnDetailsExtra(Extra):
|
||||||
"virtual generated columns and ``3`` for stored generated columns."
|
"virtual generated columns and ``3`` for stored generated columns."
|
||||||
)
|
)
|
||||||
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
|
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/binary_data/1.json?_extra=column_details"
|
"/fixtures/binary_data/1.json?_extra=column_details"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
column_details = await context.datasette._get_resource_column_details(
|
column_details = await context.datasette._get_resource_column_details(
|
||||||
|
|
@ -415,7 +412,7 @@ class ColumnDetailsExtra(Extra):
|
||||||
|
|
||||||
class ActionsExtra(Extra):
|
class ActionsExtra(Extra):
|
||||||
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
|
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
# Returns an async function for the HTML templates - not JSON serializable
|
# Returns an async function for the HTML templates - not JSON serializable
|
||||||
public = False
|
public = False
|
||||||
|
|
||||||
|
|
@ -487,7 +484,7 @@ async def precompute_database_action_permissions(datasette, actor, database_name
|
||||||
class IsViewExtra(Extra):
|
class IsViewExtra(Extra):
|
||||||
description = "Whether this resource is a view instead of a table"
|
description = "Whether this resource is a view instead of a table"
|
||||||
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
|
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.is_view
|
return context.is_view
|
||||||
|
|
@ -500,7 +497,7 @@ class DebugExtra(Extra):
|
||||||
"API and may change without warning."
|
"API and may change without warning."
|
||||||
)
|
)
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=debug")
|
example = ExtraExample("/fixtures/facetable.json?_extra=debug")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=debug"
|
"/fixtures/simple_primary_key/1.json?_extra=debug"
|
||||||
),
|
),
|
||||||
|
|
@ -508,11 +505,7 @@ class DebugExtra(Extra):
|
||||||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=debug"
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=debug"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
debug = {
|
debug = {
|
||||||
|
|
@ -536,7 +529,7 @@ class DebugExtra(Extra):
|
||||||
class RequestExtra(Extra):
|
class RequestExtra(Extra):
|
||||||
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
|
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=request")
|
example = ExtraExample("/fixtures/facetable.json?_extra=request")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=request"
|
"/fixtures/simple_primary_key/1.json?_extra=request"
|
||||||
),
|
),
|
||||||
|
|
@ -544,11 +537,7 @@ class RequestExtra(Extra):
|
||||||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=request"
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=request"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return {
|
return {
|
||||||
|
|
@ -561,7 +550,7 @@ class RequestExtra(Extra):
|
||||||
|
|
||||||
|
|
||||||
class DisplayColumnsAndRowsProvider(Provider):
|
class DisplayColumnsAndRowsProvider(Provider):
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
display_columns, display_rows = await context.display_columns_and_rows(
|
display_columns, display_rows = await context.display_columns_and_rows(
|
||||||
|
|
@ -605,7 +594,7 @@ class DisplayColumnsExtra(Extra):
|
||||||
],
|
],
|
||||||
note="Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.",
|
note="Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.",
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context, display_columns_and_rows):
|
async def resolve(self, context, display_columns_and_rows):
|
||||||
return display_columns_and_rows["columns"]
|
return display_columns_and_rows["columns"]
|
||||||
|
|
@ -613,7 +602,7 @@ class DisplayColumnsExtra(Extra):
|
||||||
|
|
||||||
class DisplayRowsExtra(Extra):
|
class DisplayRowsExtra(Extra):
|
||||||
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
|
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
# Contains markupsafe/sqlite3.Row values - not JSON serializable
|
# Contains markupsafe/sqlite3.Row values - not JSON serializable
|
||||||
public = False
|
public = False
|
||||||
|
|
||||||
|
|
@ -644,7 +633,7 @@ class RenderCellExtra(Extra):
|
||||||
"whose rendered value differs from the default are included."
|
"whose rendered value differs from the default are included."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
value={
|
value={
|
||||||
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
|
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
|
||||||
|
|
@ -669,11 +658,7 @@ class RenderCellExtra(Extra):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
table_name = context.table_name
|
table_name = context.table_name
|
||||||
|
|
@ -727,7 +712,7 @@ class RenderCellExtra(Extra):
|
||||||
class QueryExtra(Extra):
|
class QueryExtra(Extra):
|
||||||
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
|
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
|
||||||
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
|
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=query"
|
"/fixtures/simple_primary_key/1.json?_extra=query"
|
||||||
),
|
),
|
||||||
|
|
@ -736,11 +721,7 @@ class QueryExtra(Extra):
|
||||||
ExtraExample("/fixtures/neighborhood_search.json?text=town&_extra=query"),
|
ExtraExample("/fixtures/neighborhood_search.json?text=town&_extra=query"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return {
|
return {
|
||||||
|
|
@ -764,7 +745,7 @@ class ColumnTypesExtra(Extra):
|
||||||
"been assigned the ``json`` column type."
|
"been assigned the ``json`` column type."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/facetable/1.json?_extra=column_types",
|
"/fixtures/facetable/1.json?_extra=column_types",
|
||||||
note=(
|
note=(
|
||||||
|
|
@ -773,7 +754,7 @@ class ColumnTypesExtra(Extra):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
ct_map = await context.datasette.get_column_types(
|
ct_map = await context.datasette.get_column_types(
|
||||||
|
|
@ -823,7 +804,7 @@ class SetColumnTypeUiExtra(Extra):
|
||||||
"types that could be assigned to it."
|
"types that could be assigned to it."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
if context.is_view:
|
if context.is_view:
|
||||||
|
|
@ -865,7 +846,9 @@ class SetColumnTypeUiExtra(Extra):
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"path": f"{context.datasette.urls.table(context.database_name, context.table_name)}/-/set-column-type",
|
"path": "{}/-/set-column-type".format(
|
||||||
|
context.datasette.urls.table(context.database_name, context.table_name)
|
||||||
|
),
|
||||||
"columns": columns,
|
"columns": columns,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -883,7 +866,7 @@ class MetadataExtra(Extra):
|
||||||
"descriptions."
|
"descriptions."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=metadata",
|
"/fixtures/simple_primary_key/1.json?_extra=metadata",
|
||||||
note=(
|
note=(
|
||||||
|
|
@ -901,11 +884,7 @@ class MetadataExtra(Extra):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
if context.scope == ExtraScope.QUERY:
|
if context.scope == ExtraScope.QUERY:
|
||||||
|
|
@ -934,7 +913,7 @@ class MetadataExtra(Extra):
|
||||||
class DatabaseExtra(Extra):
|
class DatabaseExtra(Extra):
|
||||||
description = "Database name"
|
description = "Database name"
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=database")
|
example = ExtraExample("/fixtures/facetable.json?_extra=database")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=database"
|
"/fixtures/simple_primary_key/1.json?_extra=database"
|
||||||
),
|
),
|
||||||
|
|
@ -942,11 +921,7 @@ class DatabaseExtra(Extra):
|
||||||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database"
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.database_name
|
return context.database_name
|
||||||
|
|
@ -955,10 +930,10 @@ class DatabaseExtra(Extra):
|
||||||
class TableExtra(Extra):
|
class TableExtra(Extra):
|
||||||
description = "Table name"
|
description = "Table name"
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=table")
|
example = ExtraExample("/fixtures/facetable.json?_extra=table")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table")
|
ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table")
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.table_name
|
return context.table_name
|
||||||
|
|
@ -971,7 +946,7 @@ class DatabaseColorExtra(Extra):
|
||||||
"a hash of the database name and used in the Datasette interface."
|
"a hash of the database name and used in the Datasette interface."
|
||||||
)
|
)
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
|
example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=database_color"
|
"/fixtures/simple_primary_key/1.json?_extra=database_color"
|
||||||
),
|
),
|
||||||
|
|
@ -979,11 +954,7 @@ class DatabaseColorExtra(Extra):
|
||||||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database_color"
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database_color"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.db.color
|
return context.db.color
|
||||||
|
|
@ -994,7 +965,7 @@ class FormHiddenArgsExtra(Extra):
|
||||||
example = ExtraExample(
|
example = ExtraExample(
|
||||||
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
|
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
form_hidden_args = []
|
form_hidden_args = []
|
||||||
|
|
@ -1011,7 +982,7 @@ class FormHiddenArgsExtra(Extra):
|
||||||
|
|
||||||
class FiltersExtra(Extra):
|
class FiltersExtra(Extra):
|
||||||
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
|
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
# Returns a Filters instance for the HTML templates - not JSON serializable
|
# Returns a Filters instance for the HTML templates - not JSON serializable
|
||||||
public = False
|
public = False
|
||||||
|
|
||||||
|
|
@ -1027,7 +998,7 @@ class CustomTableTemplatesExtra(Extra):
|
||||||
":ref:`customization_custom_templates`."
|
":ref:`customization_custom_templates`."
|
||||||
)
|
)
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates")
|
example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return [
|
return [
|
||||||
|
|
@ -1048,7 +1019,7 @@ class SortedFacetResultsExtra(Extra):
|
||||||
example = ExtraExample(
|
example = ExtraExample(
|
||||||
"/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results"
|
"/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results"
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context, facet_results):
|
async def resolve(self, context, facet_results):
|
||||||
facet_configs = context.table_metadata.get("facets", [])
|
facet_configs = context.table_metadata.get("facets", [])
|
||||||
|
|
@ -1058,7 +1029,7 @@ class SortedFacetResultsExtra(Extra):
|
||||||
if isinstance(fc, str):
|
if isinstance(fc, str):
|
||||||
metadata_facet_names.append(fc)
|
metadata_facet_names.append(fc)
|
||||||
elif isinstance(fc, dict):
|
elif isinstance(fc, dict):
|
||||||
metadata_facet_names.append(next(iter(fc.values())))
|
metadata_facet_names.append(list(fc.values())[0])
|
||||||
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
|
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
|
||||||
metadata_facets = []
|
metadata_facets = []
|
||||||
request_facets = []
|
request_facets = []
|
||||||
|
|
@ -1084,7 +1055,7 @@ class SortedFacetResultsExtra(Extra):
|
||||||
class TableDefinitionExtra(Extra):
|
class TableDefinitionExtra(Extra):
|
||||||
description = "SQL definition for this table"
|
description = "SQL definition for this table"
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=table_definition")
|
example = ExtraExample("/fixtures/facetable.json?_extra=table_definition")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return await context.db.get_table_definition(context.table_name)
|
return await context.db.get_table_definition(context.table_name)
|
||||||
|
|
@ -1093,7 +1064,7 @@ class TableDefinitionExtra(Extra):
|
||||||
class ViewDefinitionExtra(Extra):
|
class ViewDefinitionExtra(Extra):
|
||||||
description = "SQL definition for this view"
|
description = "SQL definition for this view"
|
||||||
example = ExtraExample("/fixtures/simple_view.json?_extra=view_definition")
|
example = ExtraExample("/fixtures/simple_view.json?_extra=view_definition")
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return await context.db.get_view_definition(context.table_name)
|
return await context.db.get_view_definition(context.table_name)
|
||||||
|
|
@ -1110,7 +1081,7 @@ class RenderersExtra(Extra):
|
||||||
"<plugin_register_output_renderer>`."
|
"<plugin_register_output_renderer>`."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context, expandable_columns, query):
|
async def resolve(self, context, expandable_columns, query):
|
||||||
renderers = {}
|
renderers = {}
|
||||||
|
|
@ -1152,7 +1123,7 @@ class PrivateExtra(Extra):
|
||||||
"anonymous user could not. See :ref:`authentication_permissions`."
|
"anonymous user could not. See :ref:`authentication_permissions`."
|
||||||
)
|
)
|
||||||
example = ExtraExample("/fixtures/facetable.json?_extra=private")
|
example = ExtraExample("/fixtures/facetable.json?_extra=private")
|
||||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
examples = {
|
||||||
ExtraScope.ROW: ExtraExample(
|
ExtraScope.ROW: ExtraExample(
|
||||||
"/fixtures/simple_primary_key/1.json?_extra=private"
|
"/fixtures/simple_primary_key/1.json?_extra=private"
|
||||||
),
|
),
|
||||||
|
|
@ -1160,11 +1131,7 @@ class PrivateExtra(Extra):
|
||||||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private"
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
return context.private
|
return context.private
|
||||||
|
|
@ -1181,7 +1148,7 @@ class ExpandableColumnsExtra(Extra):
|
||||||
"that would be used as the label for each expanded value."
|
"that would be used as the label for each expanded value."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
scopes = {ExtraScope.TABLE}
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
expandables = []
|
expandables = []
|
||||||
|
|
@ -1201,7 +1168,7 @@ class ForeignKeyTablesExtra(Extra):
|
||||||
"reference this row, and ``link`` is a URL to browse those rows."
|
"reference this row, and ``link`` is a URL to browse those rows."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.ROW}
|
scopes = {ExtraScope.ROW}
|
||||||
expensive = True
|
expensive = True
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
|
|
@ -1235,11 +1202,7 @@ class ExtrasExtra(Extra):
|
||||||
"the current request."
|
"the current request."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
scopes: ClassVar[set[ExtraScope]] = {
|
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||||
ExtraScope.TABLE,
|
|
||||||
ExtraScope.ROW,
|
|
||||||
ExtraScope.QUERY,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def resolve(self, context):
|
async def resolve(self, context):
|
||||||
all_extras = [
|
all_extras = [
|
||||||
|
|
|
||||||
157
demos/sql-editor-element.html
Normal file
157
demos/sql-editor-element.html
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
Manual-QA demo for <datasette-sql-editor> (ticket 08).
|
||||||
|
|
||||||
|
This page is NOT served by Datasette by default. Launch a Datasette instance
|
||||||
|
with this directory mounted at /demos so both the demo page and the ESM bundle
|
||||||
|
are served same-origin (the schema fetch needs same-origin):
|
||||||
|
|
||||||
|
uv run python tests/fixtures.py fixtures.db # once, to build fixtures
|
||||||
|
uv run datasette fixtures.db --static demos:demos/ -p 8001
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
http://localhost:8001/demos/sql-editor-element.html
|
||||||
|
|
||||||
|
The editor imports /-/static/datasette-sql-editor.bundle.js (Datasette's built
|
||||||
|
ESM module, which auto-registers the <datasette-sql-editor> tag on import) and
|
||||||
|
fetches its schema from /fixtures/-/editor-schema.json.
|
||||||
|
|
||||||
|
QA checklist:
|
||||||
|
[ ] Editor mounts; "ready" logs in the event panel.
|
||||||
|
[ ] Typing "select st" / "facetable." offers ranked, typed column completions
|
||||||
|
(schema fetch succeeded). Ctrl-Space forces the completion popup.
|
||||||
|
[ ] Editing fires "input" events (origin: user).
|
||||||
|
[ ] Shift-Enter / Mod-Enter fires a cancelable "submit"; with the checkbox
|
||||||
|
UNchecked the form GET-submits ?sql=... (name=sql). Checked cancels it.
|
||||||
|
[ ] The plain "Run SQL" button also submits the current editor text as sql=.
|
||||||
|
[ ] Escape fires "editor-escape".
|
||||||
|
[ ] Reset button restores the initial document (form reset).
|
||||||
|
[ ] Format button reformats via window.sqlFormatter (loaded below).
|
||||||
|
[ ] Read-only toggle disables editing.
|
||||||
|
[ ] Point schema-url at a bad URL (edit source) -> editor still works,
|
||||||
|
keyword-only completion, a console.warn appears.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><datasette-sql-editor> demo</title>
|
||||||
|
<!-- sql-formatter global (window.sqlFormatter) so format() works. -->
|
||||||
|
<script src="/-/static/sql-formatter-2.3.3.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--datasette-sql-editor-font-size: 14px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
datasette-sql-editor {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.cm-editor {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#log {
|
||||||
|
background: #111;
|
||||||
|
color: #b9f6ca;
|
||||||
|
font: 12px/1.5 ui-monospace, Menlo, monospace;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1><datasette-sql-editor> demo</h1>
|
||||||
|
<p>
|
||||||
|
Form-associated custom element against the live
|
||||||
|
<code>fixtures</code> database. See the HTML comment at the top of this file
|
||||||
|
for launch and QA instructions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="f" action="/fixtures/-/query" method="get">
|
||||||
|
<datasette-sql-editor
|
||||||
|
id="ed"
|
||||||
|
name="sql"
|
||||||
|
database="fixtures"
|
||||||
|
default-table="facetable"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
select state, county, on_earth
|
||||||
|
from facetable
|
||||||
|
where on_earth = 1
|
||||||
|
order by pk</datasette-sql-editor
|
||||||
|
>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="submit">Run SQL</button>
|
||||||
|
<button type="button" id="format">Format</button>
|
||||||
|
<button type="reset">Reset</button>
|
||||||
|
<label
|
||||||
|
><input type="checkbox" id="cancel" /> cancel submit event</label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
><input type="checkbox" id="ro" /> read-only</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Events</h3>
|
||||||
|
<div id="log"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
// Importing the bundle auto-registers <datasette-sql-editor>.
|
||||||
|
import "/-/static/datasette-sql-editor.bundle.js";
|
||||||
|
|
||||||
|
const ed = document.getElementById("ed");
|
||||||
|
const log = document.getElementById("log");
|
||||||
|
const line = (m) => {
|
||||||
|
log.textContent += m + "\n";
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
["ready", "input", "submit", "editor-escape"].forEach((type) => {
|
||||||
|
ed.addEventListener(type, (e) => {
|
||||||
|
if (type === "submit" && document.getElementById("cancel").checked) {
|
||||||
|
e.preventDefault();
|
||||||
|
line("submit (CANCELED via preventDefault)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
line(
|
||||||
|
type +
|
||||||
|
(e.detail ? " " + JSON.stringify(e.detail) : "") +
|
||||||
|
" | value=" +
|
||||||
|
JSON.stringify(ed.value.replace(/\s+/g, " ").slice(0, 60)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("format").addEventListener("click", () => {
|
||||||
|
ed.format();
|
||||||
|
line("format() called");
|
||||||
|
});
|
||||||
|
document.getElementById("ro").addEventListener("change", (e) => {
|
||||||
|
ed.readOnly = e.target.checked;
|
||||||
|
line("readOnly = " + e.target.checked);
|
||||||
|
});
|
||||||
|
// view escape hatch smoke test
|
||||||
|
line("view is EditorView? " + (ed.view && ed.view.constructor.name));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -45,7 +45,7 @@ Using the "root" actor
|
||||||
|
|
||||||
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
|
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
|
||||||
|
|
||||||
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user starts with **all permissions**: Datasette contributes a global allow rule for every action. More specific deny rules can still override that global rule.
|
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.
|
||||||
|
|
||||||
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
|
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
|
||||||
|
|
||||||
|
|
@ -84,12 +84,12 @@ Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm t
|
||||||
Permissions
|
Permissions
|
||||||
===========
|
===========
|
||||||
|
|
||||||
|
Datasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access.
|
||||||
|
|
||||||
The key question the permissions system answers is this:
|
The key question the permissions system answers is this:
|
||||||
|
|
||||||
Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
|
Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
|
||||||
|
|
||||||
Every permission decision can be understood in terms of those three values. Datasette implements the decisions using SQL, but you do not need to understand the generated SQL to configure or debug permissions.
|
|
||||||
|
|
||||||
**Actors** are :ref:`described above <authentication_actor>`.
|
**Actors** are :ref:`described above <authentication_actor>`.
|
||||||
|
|
||||||
An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.
|
An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.
|
||||||
|
|
@ -138,51 +138,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
|
||||||
How permissions are resolved
|
How permissions are resolved
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
Permission rules describe an effect (``allow`` or ``deny``) at one of three levels:
|
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||||
|
|
||||||
``resource``
|
|
||||||
A specific child resource, such as the ``analytics/sales`` table.
|
|
||||||
|
|
||||||
``parent``
|
|
||||||
A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources.
|
|
||||||
|
|
||||||
``global``
|
|
||||||
Every resource for that action.
|
|
||||||
|
|
||||||
Datasette resolves matching rules from most specific to least specific:
|
|
||||||
|
|
||||||
#. Resource rules take precedence over parent and global rules.
|
|
||||||
#. Parent rules take precedence over global rules.
|
|
||||||
#. If both allow and deny rules match at the same level, deny takes precedence.
|
|
||||||
#. If no rule matches, access is denied.
|
|
||||||
|
|
||||||
This means a resource-level allow can provide an exception to a parent-level deny. It also means that two plugins which disagree at the same level resolve to deny.
|
|
||||||
|
|
||||||
.. list-table:: Permission rule examples
|
|
||||||
:header-rows: 1
|
|
||||||
|
|
||||||
* - Matching rules
|
|
||||||
- Result
|
|
||||||
- Explanation
|
|
||||||
* - Global allow
|
|
||||||
- Allow
|
|
||||||
- The global rule is the most specific matching rule.
|
|
||||||
* - Global allow, parent deny
|
|
||||||
- Deny
|
|
||||||
- The parent rule is more specific.
|
|
||||||
* - Parent deny, resource allow
|
|
||||||
- Allow
|
|
||||||
- The resource rule is more specific.
|
|
||||||
* - Resource allow and resource deny
|
|
||||||
- Deny
|
|
||||||
- Deny takes precedence at the same level.
|
|
||||||
* - No matching rules
|
|
||||||
- Deny
|
|
||||||
- Permissions default to deny when no rule applies.
|
|
||||||
|
|
||||||
The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules.
|
|
||||||
|
|
||||||
Datasette performs checks using :ref:`datasette_allowed`, 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.
|
||||||
|
|
||||||
|
|
@ -193,12 +149,12 @@ resources were allowed or denied. The combined sources are:
|
||||||
|
|
||||||
* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.
|
* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.
|
||||||
* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.
|
* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.
|
||||||
* The "root" user rule when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active. This is a global allow rule, so a more specific configuration deny can override it.
|
* The "root" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level.
|
||||||
* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.
|
* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.
|
||||||
|
|
||||||
Actor restrictions are applied after the allow/deny rules. They act as an additional allowlist: a restriction can remove access but cannot grant access that the actor did not already have. See :ref:`authentication_cli_create_token_restrict`.
|
Datasette evaluates the SQL to determine if the requested ``resource`` is
|
||||||
|
included. Explicit deny rules returned by configuration or plugins will block
|
||||||
Some actions have dependencies on other actions. These are evaluated as an ``AND`` condition. For example, ``execute-sql`` also requires ``view-database``: both decisions must be allowed for the final result to be allowed.
|
access even if other rules allowed it.
|
||||||
|
|
||||||
.. _authentication_permissions_allow:
|
.. _authentication_permissions_allow:
|
||||||
|
|
||||||
|
|
@ -1189,21 +1145,11 @@ The debug tool at ``/-/permissions`` is available to any actor with the ``permis
|
||||||
|
|
||||||
datasette -s permissions.permissions-debug true data.db
|
datasette -s permissions.permissions-debug true data.db
|
||||||
|
|
||||||
The permission debug tools answer four different questions:
|
The page shows the permission checks that have been carried out by the Datasette instance.
|
||||||
|
|
||||||
Why was this decision allowed or denied?
|
It also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect.
|
||||||
Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions.
|
|
||||||
|
|
||||||
Which resources can the current actor access?
|
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
|
||||||
Use :ref:`AllowedResourcesView` to view an access map for a selected action.
|
|
||||||
|
|
||||||
Which raw rules did Datasette and its plugins contribute?
|
|
||||||
Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions.
|
|
||||||
|
|
||||||
Which checks has this Datasette instance performed recently?
|
|
||||||
Use ``/-/permissions`` to view recent permission activity.
|
|
||||||
|
|
||||||
These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration.
|
|
||||||
|
|
||||||
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
|
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
|
||||||
|
|
||||||
|
|
@ -1238,20 +1184,11 @@ This endpoint requires the ``permissions-debug`` permission.
|
||||||
Permission check view
|
Permission check view
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
The ``/-/check`` endpoint evaluates and explains a single actor, action and resource decision. The explanation includes:
|
The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information.
|
||||||
|
|
||||||
* Every matching allow and deny rule, with its source and reason.
|
|
||||||
* The winning resource, parent or global scope.
|
|
||||||
* Rules ignored because a more specific rule matched, or because a deny won at the same scope.
|
|
||||||
* Actor restriction allowlists that included or excluded the resource.
|
|
||||||
* Additional actions required by the requested action.
|
|
||||||
* An explicit default-deny explanation when no rule matched.
|
|
||||||
|
|
||||||
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.
|
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.
|
||||||
|
|
||||||
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. The interactive form also accepts actor JSON, allowing a hypothetical actor to be tested without signing in as that actor. The JSON endpoint accepts the same value using the ``actor`` query string parameter. Use ``actor=null`` to represent an anonymous actor.
|
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource.
|
||||||
|
|
||||||
This endpoint requires the ``permissions-debug`` permission. The hypothetical actor is used only for the decision being explained; access to the debug tool is checked against the actor who is actually signed in.
|
|
||||||
|
|
||||||
.. _authentication_ds_actor:
|
.. _authentication_ds_actor:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,6 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
.. _v1_0_a37:
|
|
||||||
|
|
||||||
1.0a37 (2026-07-14)
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
Performance improvement for SQL-backed permission checks, plus an improved permission debugging interface.
|
|
||||||
|
|
||||||
- SQL used to resolve permission checks now aggregates permission rules before joining them to resources, improving performance on instances with large schemas. (:issue:`2832`)
|
|
||||||
- The :ref:`PermissionCheckView` permission debugger now explains why a decision was allowed or denied, including the matching rules. The interactive form can also test a hypothetical actor supplied as JSON, and the :ref:`permissions documentation <authentication_permissions_explained>` now describes resolution rules in more detail. (:issue:`2841`)
|
|
||||||
- :ref:`db.execute_write(sql, ..., transaction=True) <database_execute_write>` has a new ``transaction=`` parameter, which can be set to ``False`` for statements such as ``VACUUM`` that cannot run inside a transaction. Write tasks now start their transactions using ``BEGIN IMMEDIATE``, which also ensures that writes are rolled back if the task fails. (:issue:`2831`)
|
|
||||||
- Refreshing a database's schema in Datasette's internal catalog is now performed as a single atomic operation. (:issue:`2831`)
|
|
||||||
- Fixed schema introspection, table pages, facets and table counts for tables with names containing a ``]`` character. Thanks, `TowyTowy <https://github.com/TowyTowy>`__. (:issue:`2431`, :pr:`2846`)
|
|
||||||
- ``/-/plugins.json`` once again returns a top-level JSON array of plugin objects, reverting the object envelope introduced in 1.0a36. This should fix a large number of trivial test failures in existing plugins. (:issue:`2842`, :pr:`2843`)
|
|
||||||
|
|
||||||
.. _v1_0_a36:
|
.. _v1_0_a36:
|
||||||
|
|
||||||
1.0a36 (2026-07-07)
|
1.0a36 (2026-07-07)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
# Datasette documentation build configuration file, created by
|
# Datasette documentation build configuration file, created by
|
||||||
# sphinx-quickstart on Thu Nov 16 06:50:13 2017.
|
# sphinx-quickstart on Thu Nov 16 06:50:13 2017.
|
||||||
|
|
|
||||||
|
|
@ -434,13 +434,15 @@ Datasette bundles `CodeMirror <https://codemirror.net/>`__ for the SQL editing i
|
||||||
|
|
||||||
npm i codemirror @codemirror/lang-sql
|
npm i codemirror @codemirror/lang-sql
|
||||||
|
|
||||||
* Build the bundle using the version number from package.json with::
|
* Build the bundle with::
|
||||||
|
|
||||||
node_modules/.bin/rollup datasette/static/cm-editor-6.0.1.js \
|
npm install && npm run build:codemirror
|
||||||
-f iife \
|
|
||||||
-n cm \
|
|
||||||
-o datasette/static/cm-editor-6.0.1.bundle.js \
|
|
||||||
-p @rollup/plugin-node-resolve \
|
|
||||||
-p @rollup/plugin-terser
|
|
||||||
|
|
||||||
* Update the version reference in the ``codemirror.html`` template.
|
This runs ``rollup -c`` against the ``rollup.config.mjs`` file at the root of the repository, which builds two bundles:
|
||||||
|
|
||||||
|
* ``datasette/static/cm-editor.bundle.js`` - the minified IIFE bundle (global name ``cm``) used by Datasette's own pages, built from ``datasette/static/cm-editor.js``.
|
||||||
|
* ``datasette/static/datasette-sql-editor.bundle.js`` - a self-contained ESM bundle built from ``datasette/static/datasette-sql-editor.js``, which plugin authors can import directly (e.g. ``import {createSqlEditor, datasetteSchema} from "/-/static/datasette-sql-editor.bundle.js"``). ``cm-editor.js`` is a thin consumer of this shared module, so there is a single CodeMirror implementation.
|
||||||
|
|
||||||
|
The bundle filenames do not include the CodeMirror version number, so no template needs to be updated.
|
||||||
|
|
||||||
|
* Commit the rebuilt ``datasette/static/cm-editor.bundle.js`` and ``datasette/static/datasette-sql-editor.bundle.js`` - the bundles are checked into the repository.
|
||||||
|
|
|
||||||
|
|
@ -2023,8 +2023,8 @@ Example usage:
|
||||||
|
|
||||||
.. _database_execute_write:
|
.. _database_execute_write:
|
||||||
|
|
||||||
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True)
|
await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10)
|
||||||
--------------------------------------------------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
|
SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received.
|
||||||
|
|
||||||
|
|
@ -2059,9 +2059,7 @@ If you need to retrieve every row returned by a statement, pass ``return_all=Tru
|
||||||
|
|
||||||
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
|
If you pass ``block=False`` this behavior changes to "fire and forget" - queries will be added to the write queue and executed in a separate thread while your code can continue to do other things. The method will return a UUID representing the queued task.
|
||||||
|
|
||||||
Each call to ``execute_write()`` will be executed inside a transaction. Pass
|
Each call to ``execute_write()`` will be executed inside a transaction.
|
||||||
``transaction=False`` for statements such as ``VACUUM`` that cannot run inside
|
|
||||||
a transaction.
|
|
||||||
|
|
||||||
.. _database_execute_write_script:
|
.. _database_execute_write_script:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,15 +80,18 @@ Shows a list of currently installed plugins and their versions. `Plugins example
|
||||||
|
|
||||||
.. code-block:: json
|
.. code-block:: json
|
||||||
|
|
||||||
[
|
{
|
||||||
{
|
"ok": true,
|
||||||
"name": "datasette_cluster_map",
|
"plugins": [
|
||||||
"static": true,
|
{
|
||||||
"templates": false,
|
"name": "datasette_cluster_map",
|
||||||
"version": "0.10",
|
"static": true,
|
||||||
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
|
"templates": false,
|
||||||
}
|
"version": "0.10",
|
||||||
]
|
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
Add ``?all=1`` to include details of the default plugins baked into Datasette.
|
Add ``?all=1`` to include details of the default plugins baked into Datasette.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,62 @@ Values for named SQL parameters can be provided as additional query string param
|
||||||
|
|
||||||
The response uses the same default representation described above.
|
The response uses the same default representation described above.
|
||||||
|
|
||||||
|
.. _json_api_editor_schema:
|
||||||
|
|
||||||
|
.. _DatabaseEditorSchemaView:
|
||||||
|
|
||||||
|
Schema for SQL editors
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
The ``/-/editor-schema.json`` endpoint returns a machine-readable description of
|
||||||
|
a database's tables, views and columns, shaped for SQL editor autocomplete. It
|
||||||
|
powers Datasette's own CodeMirror SQL editor and is available for external
|
||||||
|
consumers such as embeddable editor components.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
GET /<database>/-/editor-schema.json
|
||||||
|
|
||||||
|
Access requires both the :ref:`actions_view_database` and
|
||||||
|
:ref:`actions_execute_sql` permissions for the database - the same gate as the
|
||||||
|
inline editor schema on the SQL query page. A request that fails either check
|
||||||
|
receives a ``403`` JSON error that does not reveal any table or column names.
|
||||||
|
|
||||||
|
The response is a neutral structure - a ``database`` name and a list of
|
||||||
|
``tables``, each with a ``view`` flag (``true`` for SQL views) and a list of
|
||||||
|
``columns`` carrying the SQLite declared ``type`` (an empty string when the
|
||||||
|
column has no declared type):
|
||||||
|
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
{
|
||||||
|
"database": "fixtures",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"name": "facetable",
|
||||||
|
"view": false,
|
||||||
|
"columns": [
|
||||||
|
{"name": "pk", "type": "INTEGER"},
|
||||||
|
{"name": "state", "type": "TEXT"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paginated_view",
|
||||||
|
"view": true,
|
||||||
|
"columns": [
|
||||||
|
{"name": "content", "type": "TEXT"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Hidden tables - such as the shadow tables that back SQLite full-text search -
|
||||||
|
are excluded from the response.
|
||||||
|
|
||||||
|
This endpoint is distinct from the :ref:`database schema endpoint <DatabaseSchemaView>`
|
||||||
|
at ``/<database>/-/schema.json``, which returns the raw ``CREATE`` statements as
|
||||||
|
a SQL string.
|
||||||
|
|
||||||
.. _json_api_shapes:
|
.. _json_api_shapes:
|
||||||
|
|
||||||
Different shapes
|
Different shapes
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ def table_extras(cog):
|
||||||
cog.out("\n")
|
cog.out("\n")
|
||||||
for scope, heading, intro, classes in classes_by_scope:
|
for scope, heading, intro, classes in classes_by_scope:
|
||||||
cog.out("{}\n{}\n\n".format(heading, "~" * len(heading)))
|
cog.out("{}\n{}\n\n".format(heading, "~" * len(heading)))
|
||||||
cog.out(f"{intro}\n\n")
|
cog.out("{}\n\n".format(intro))
|
||||||
for cls in classes:
|
for cls in classes:
|
||||||
examples = _examples_for_scope(cls, scope)
|
examples = _examples_for_scope(cls, scope)
|
||||||
description = cls.description or ""
|
description = cls.description or ""
|
||||||
|
|
@ -58,16 +58,16 @@ def table_extras(cog):
|
||||||
if notes:
|
if notes:
|
||||||
description = "{} ({})".format(description, " ".join(notes)).strip()
|
description = "{} ({})".format(description, " ".join(notes)).strip()
|
||||||
|
|
||||||
cog.out(f"``{cls.key()}``\n")
|
cog.out("``{}``\n".format(cls.key()))
|
||||||
cog.out(f" {description}\n\n")
|
cog.out(" {}\n\n".format(description))
|
||||||
for example in examples:
|
for example in examples:
|
||||||
if example.path:
|
if example.path:
|
||||||
value = live_examples[(example.path, example.key or cls.key())]
|
value = live_examples[(example.path, example.key or cls.key())]
|
||||||
cog.out(f" ``GET {example.path}``\n\n")
|
cog.out(" ``GET {}``\n\n".format(example.path))
|
||||||
else:
|
else:
|
||||||
value = example.value
|
value = example.value
|
||||||
if example.note:
|
if example.note:
|
||||||
cog.out(f" {example.note}\n\n")
|
cog.out(" {}\n\n".format(example.note))
|
||||||
cog.out(" .. code-block:: json\n\n")
|
cog.out(" .. code-block:: json\n\n")
|
||||||
cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
|
cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
|
||||||
cog.out("\n\n")
|
cog.out("\n\n")
|
||||||
|
|
@ -139,7 +139,7 @@ async def _fetch_live_examples(scoped_classes):
|
||||||
response = await datasette.client.get(example.path)
|
response = await datasette.client.get(example.path)
|
||||||
assert response.status_code == 200, example.path
|
assert response.status_code == 200, example.path
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert key in data, f"{key} missing from {example.path}"
|
assert key in data, "{} missing from {}".format(key, example.path)
|
||||||
examples[(example.path, key)] = data[key]
|
examples[(example.path, key)] = data[key]
|
||||||
finally:
|
finally:
|
||||||
for db in datasette.databases.values():
|
for db in datasette.databases.values():
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
from yaml import safe_dump
|
from yaml import safe_dump
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
|
||||||
def metadata_example(cog, data=None, yaml=None):
|
def metadata_example(cog, data=None, yaml=None):
|
||||||
|
|
@ -34,10 +33,10 @@ def config_example(
|
||||||
else:
|
else:
|
||||||
data = input
|
data = input
|
||||||
output_yaml = safe_dump(input, sort_keys=False)
|
output_yaml = safe_dump(input, sort_keys=False)
|
||||||
cog.out(f"\n.. tab:: {yaml_title}\n\n")
|
cog.out("\n.. tab:: {}\n\n".format(yaml_title))
|
||||||
cog.out(" .. code-block:: yaml\n\n")
|
cog.out(" .. code-block:: yaml\n\n")
|
||||||
cog.out(textwrap.indent(output_yaml, " "))
|
cog.out(textwrap.indent(output_yaml, " "))
|
||||||
cog.out(f"\n\n.. tab:: {json_title}\n\n")
|
cog.out("\n\n.. tab:: {}\n\n".format(json_title))
|
||||||
cog.out(" .. code-block:: json\n\n")
|
cog.out(" .. code-block:: json\n\n")
|
||||||
cog.out(textwrap.indent(json.dumps(data, indent=2), " "))
|
cog.out(textwrap.indent(json.dumps(data, indent=2), " "))
|
||||||
cog.out("\n")
|
cog.out("\n")
|
||||||
|
|
@ -45,10 +44,8 @@ def config_example(
|
||||||
|
|
||||||
def internal_schema(cog):
|
def internal_schema(cog):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from sqlite_utils import Database
|
|
||||||
|
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
from sqlite_utils import Database
|
||||||
|
|
||||||
ds = Datasette()
|
ds = Datasette()
|
||||||
db = ds.get_internal_database()
|
db = ds.get_internal_database()
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,9 @@ The page for arbitrary SQL queries (/database/-/query?sql=...) and stored querie
|
||||||
``db_is_immutable`` - ``bool``
|
``db_is_immutable`` - ``bool``
|
||||||
Boolean indicating if this database is immutable
|
Boolean indicating if this database is immutable
|
||||||
|
|
||||||
|
``default_table`` - ``str``
|
||||||
|
Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries.
|
||||||
|
|
||||||
``display_rows`` - ``list``
|
``display_rows`` - ``list``
|
||||||
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.
|
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,14 @@ def template_context(cog):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for name, doc in TEMPLATE_BASE_CONTEXT.items():
|
for name, doc in TEMPLATE_BASE_CONTEXT.items():
|
||||||
cog.out(f"``{name}``\n")
|
cog.out("``{}``\n".format(name))
|
||||||
cog.out(f" {doc}\n\n")
|
cog.out(" {}\n\n".format(doc))
|
||||||
|
|
||||||
for klass in PAGES.values():
|
for klass in PAGES.values():
|
||||||
title = "{} page".format(klass.__name__.removesuffix("Context"))
|
title = "{} page".format(klass.__name__.removesuffix("Context"))
|
||||||
intro = f"{klass.__doc__} Rendered using the ``{klass.documented_template}`` template."
|
intro = "{} Rendered using the ``{}`` template.".format(
|
||||||
|
klass.__doc__, klass.documented_template
|
||||||
|
)
|
||||||
_section(cog, title, intro)
|
_section(cog, title, intro)
|
||||||
if klass.extras_scope is not None:
|
if klass.extras_scope is not None:
|
||||||
cog.out(
|
cog.out(
|
||||||
|
|
@ -34,10 +36,10 @@ def template_context(cog):
|
||||||
"<json_api>` for this page.\n\n"
|
"<json_api>` for this page.\n\n"
|
||||||
)
|
)
|
||||||
for f in sorted(klass.documented_fields(), key=lambda f: f.name):
|
for f in sorted(klass.documented_fields(), key=lambda f: f.name):
|
||||||
cog.out(f"``{f.name}`` - ``{f.type_name}``\n")
|
cog.out("``{}`` - ``{}``\n".format(f.name, f.type_name))
|
||||||
cog.out(f" {f.help}\n\n")
|
cog.out(" {}\n\n".format(f.help))
|
||||||
|
|
||||||
|
|
||||||
def _section(cog, title, intro):
|
def _section(cog, title, intro):
|
||||||
cog.out("{}\n{}\n\n".format(title, "-" * len(title)))
|
cog.out("{}\n{}\n\n".format(title, "-" * len(title)))
|
||||||
cog.out(f"{intro}\n\n")
|
cog.out("{}\n\n".format(intro))
|
||||||
|
|
|
||||||
747
package-lock.json
generated
747
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -5,14 +5,22 @@
|
||||||
"prettier": "^3.0.0"
|
"prettier": "^3.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build:codemirror": "rollup -c",
|
||||||
"fix": "npm run prettier -- --write",
|
"fix": "npm run prettier -- --write",
|
||||||
"prettier": "prettier 'datasette/static/*[!.min|bundle].js'"
|
"prettier": "prettier 'datasette/static/*[!.min|bundle].js'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.3.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
|
"@codemirror/commands": "^6.10.4",
|
||||||
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/lint": "^6.9.7",
|
||||||
|
"@codemirror/search": "^6.7.1",
|
||||||
|
"@codemirror/state": "^6.7.1",
|
||||||
|
"@codemirror/view": "^6.43.6",
|
||||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||||
"@rollup/plugin-terser": "^0.1.0",
|
"@rollup/plugin-terser": "^0.1.0",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.2",
|
||||||
"rollup": "^3.30.0"
|
"rollup": "^3.30.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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