diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 18c01fdc..b0640ae8 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -57,7 +57,7 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db - - name: And the counters writable canned query demo + - name: And the counters writable stored query demo run: | cat > plugins/counters.py <=0.2.2' \ --service "datasette-latest$SUFFIX" \ --secret $LATEST_DATASETTE_SECRET diff --git a/.gitignore b/.gitignore index 12acd87e..8c058692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ build-metadata.json datasets.json +.playwright-mcp + scratchpad .vscode @@ -131,4 +133,4 @@ tests/*.dylib tests/*.so tests/*.dll -.idea \ No newline at end of file +.idea diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py index 5fb6b473..103c616d 100644 --- a/datasette/_pytest_plugin.py +++ b/datasette/_pytest_plugin.py @@ -19,23 +19,38 @@ import weakref import pytest -from datasette.app import Datasette - _active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar( "datasette_active_instances", default=None ) -_original_init = Datasette.__init__ +_original_init = None -def _tracking_init(self, *args, **kwargs): - _original_init(self, *args, **kwargs) - instances = _active_instances.get() - if instances is not None: - instances.append(weakref.ref(self)) +def _install_tracking(): + # datasette.app is imported lazily here rather than at module level: + # as a pytest11 entry point this module is imported during pytest + # startup, before pytest-cov starts measuring, so a module-level + # import would drag in all of datasette and make every import-time + # line in the package invisible to coverage + global _original_init + if _original_init is not None: + return + from datasette.app import Datasette + + _original_init = Datasette.__init__ + + def _tracking_init(self, *args, **kwargs): + _original_init(self, *args, **kwargs) + instances = _active_instances.get() + if instances is not None: + instances.append(weakref.ref(self)) + + Datasette.__init__ = _tracking_init -Datasette.__init__ = _tracking_init +def pytest_configure(config): + if _enabled(config): + _install_tracking() def pytest_addoption(parser): diff --git a/datasette/app.py b/datasette/app.py index 75f05d88..545a65c8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import contextvars -from typing import TYPE_CHECKING, Any, Dict, Iterable, List +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence if TYPE_CHECKING: from datasette.permissions import Resource @@ -42,12 +42,31 @@ from jinja2.exceptions import TemplateNotFound from .events import Event from .column_types import SQLiteType +from . import stored_queries, write_sql from .views import Context -from .views.database import database_download, DatabaseView, TableCreateView, QueryView +from .views.database import ( + database_download, + DatabaseView, + TableCreateView, + QueryView, +) +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, @@ -64,10 +83,12 @@ from .views.special import ( TableSchemaView, ) from .views.table import ( + TableAutocompleteView, TableInsertView, TableUpsertView, TableSetColumnTypeView, TableDropView, + TableFragmentView, table_view, ) from .views.row import RowView, RowDeleteView, RowUpdateView @@ -273,6 +294,15 @@ DEFAULT_NOT_SET = object() ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) +def _permission_cache_key(actor, action, parent, child): + # Key on the full serialized actor so actors differing in any field + # (e.g. token restrictions) never share cache entries + actor_key = ( + json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None + ) + return (actor_key, action, parent, child) + + async def favicon(request, send): await asgi_send_file( send, @@ -571,6 +601,9 @@ class Datasette: # TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log # a warning to user that they should delete their metadata.json file + async def _save_queries_from_config(self): + await stored_queries.save_queries_from_config(self) + def get_jinja_environment(self, request: Request = None) -> Environment: environment = self._jinja_env if request: @@ -731,6 +764,7 @@ class Datasette: await await_me_maybe(hook) # Ensure internal tables and metadata are populated before startup hooks await self._refresh_schemas() + await self._save_queries_from_config() # Load column_types from config into internal DB await self._apply_column_types_config() for hook in pm.hook.startup(datasette=self): @@ -1007,6 +1041,180 @@ class Datasette: [database_name, resource_name, column_name, key, value], ) + @staticmethod + def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None: + return stored_queries.query_row_to_stored_query(row) + + @staticmethod + def _query_options_json(options): + return stored_queries.query_options_json(options) + + async def add_query( + self, + database: str, + name: str, + sql: str, + *, + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, + ) -> None: + return await stored_queries.add_query( + self, + database, + name, + sql, + title=title, + description=description, + description_html=description_html, + hide_sql=hide_sql, + fragment=fragment, + parameters=parameters, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + on_success_message=on_success_message, + on_success_message_sql=on_success_message_sql, + on_success_redirect=on_success_redirect, + on_error_message=on_error_message, + on_error_redirect=on_error_redirect, + replace=replace, + ) + + async def update_query( + self, + database: str, + name: str, + *, + sql=stored_queries.UNCHANGED, + title=stored_queries.UNCHANGED, + description=stored_queries.UNCHANGED, + description_html=stored_queries.UNCHANGED, + hide_sql=stored_queries.UNCHANGED, + fragment=stored_queries.UNCHANGED, + parameters=stored_queries.UNCHANGED, + is_write=stored_queries.UNCHANGED, + is_private=stored_queries.UNCHANGED, + is_trusted=stored_queries.UNCHANGED, + source=stored_queries.UNCHANGED, + owner_id=stored_queries.UNCHANGED, + on_success_message=stored_queries.UNCHANGED, + on_success_message_sql=stored_queries.UNCHANGED, + on_success_redirect=stored_queries.UNCHANGED, + on_error_message=stored_queries.UNCHANGED, + on_error_redirect=stored_queries.UNCHANGED, + ) -> None: + return await stored_queries.update_query( + self, + database, + name, + sql=sql, + title=title, + description=description, + description_html=description_html, + hide_sql=hide_sql, + fragment=fragment, + parameters=parameters, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + on_success_message=on_success_message, + on_success_message_sql=on_success_message_sql, + on_success_redirect=on_success_redirect, + on_error_message=on_error_message, + on_error_redirect=on_error_redirect, + ) + + async def remove_query( + self, database: str, name: str, source: str | None = None + ) -> None: + return await stored_queries.remove_query(self, database, name, source=source) + + async def get_query( + self, database: str, name: str + ) -> stored_queries.StoredQuery | None: + return await stored_queries.get_query(self, database, name) + + async def count_queries( + self, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + ) -> int: + return await stored_queries.count_queries( + self, + database, + actor=actor, + q=q, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + ) + + async def list_queries( + self, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, + ) -> stored_queries.StoredQueryPage: + return await stored_queries.list_queries( + self, + database, + actor=actor, + limit=limit, + cursor=cursor, + q=q, + is_write=is_write, + is_private=is_private, + is_trusted=is_trusted, + source=source, + owner_id=owner_id, + include_private=include_private, + ) + + async def ensure_query_write_permissions( + self, database, sql, *, actor=None, params=None, analysis=None + ): + # Raise Forbidden or QueryWriteRejected if SQL should not run + return await write_sql.ensure_query_write_permissions( + self, database, sql, actor=actor, params=params, analysis=analysis + ) + # Column types API async def _get_resource_column_details(self, database: str, resource: str): @@ -1238,29 +1446,6 @@ class Datasette: def app_css_hash(self): return self.static_hash("app.css") - async def get_canned_queries(self, database_name, actor): - queries = {} - for more_queries in pm.hook.canned_queries( - datasette=self, - database=database_name, - actor=actor, - ): - more_queries = await await_me_maybe(more_queries) - queries.update(more_queries or {}) - # Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}} - for key in queries: - if not isinstance(queries[key], dict): - queries[key] = {"sql": queries[key]} - # Also make sure "name" is available: - queries[key]["name"] = key - return queries - - async def get_canned_query(self, database_name, query_name, actor): - queries = await self.get_canned_queries(database_name, actor) - query = queries.get(query_name) - if query: - return query - def _prepare_connection(self, conn, database): conn.row_factory = sqlite3.Row conn.text_factory = lambda x: str(x, "utf-8", "replace") @@ -1644,46 +1829,124 @@ class Datasette: # For global actions, resource can be omitted: can_debug = await datasette.allowed(action="permissions-debug", actor=actor) """ - from datasette.utils.actions_sql import check_permission_for_resource + results = await self.allowed_many( + actions=[action], resource=resource, actor=actor + ) + return results[action] - # For global actions, resource remains None + async def allowed_many( + self, + *, + actions: Sequence[str], + resource: "Resource" = None, + actor: dict | None = None, + ) -> dict[str, bool]: + """ + Check several actions against one resource for one actor. - # Check if this action has also_requires - if so, check that action first - action_obj = self.actions.get(action) - if action_obj and action_obj.also_requires: - # Must have the required action first - if not await self.allowed( - action=action_obj.also_requires, - resource=resource, + Resolves every action (plus any also_requires dependencies) with a + single internal database query, instead of one or two queries per + action. Results are stored in the request-scoped permission cache, + so subsequent datasette.allowed() calls for the same checks within + the same request are served from the cache. + + Example: + from datasette.resources import TableResource + results = await datasette.allowed_many( + actions=["edit-schema", "drop-table", "insert-row"], + resource=TableResource(database="data", table="exercise"), actor=actor, - ): - return False + ) + # {"edit-schema": True, "drop-table": True, "insert-row": False} + """ + from datasette.utils.actions_sql import check_permissions_for_actions + from datasette.permissions import ( + _permission_check_cache, + _skip_permission_checks, + ) # For global actions, resource is None parent = resource.parent if resource else None child = resource.child if resource else None - result = await check_permission_for_resource( - datasette=self, - actor=actor, - action=action, - parent=parent, - child=child, - ) + # Expand also_requires dependencies (transitively) so that each + # dependency is resolved within the same batch + expanded = [] - # Log the permission check for debugging - self._permission_checks.append( - PermissionCheck( - when=datetime.datetime.now(datetime.timezone.utc).isoformat(), + def add_action(name): + if name in expanded: + return + action_obj = self.actions.get(name) + if action_obj is None: + raise ValueError(f"Unknown action: {name}") + expanded.append(name) + if action_obj.also_requires: + add_action(action_obj.also_requires) + + requested = list(dict.fromkeys(actions)) + for name in requested: + add_action(name) + + # Consult the request-scoped cache, unless permission checks are + # being skipped (skip-mode verdicts must never be cached) + skip = _skip_permission_checks.get() + cache = None if skip else _permission_check_cache.get() + + final = {} + to_check = [] + for name in expanded: + if cache is not None: + key = _permission_cache_key(actor, name, parent, child) + if key in cache: + final[name] = cache[key] + continue + to_check.append(name) + + raw = {} + if to_check: + raw = await check_permissions_for_actions( + datasette=self, actor=actor, - action=action, + actions=to_check, parent=parent, child=child, - result=result, ) - ) - return result + def resolve(name): + # final verdict = own rules AND verdict of also_requires chain + if name in final: + return final[name] + result = raw[name] + action_obj = self.actions.get(name) + if result and action_obj.also_requires: + result = resolve(action_obj.also_requires) + final[name] = result + return result + + for name in expanded: + resolve(name) + + # Cache the freshly computed checks + if cache is not None: + for name in to_check: + cache[_permission_cache_key(actor, name, parent, child)] = final[name] + + # Log every check (including cache hits) for the debug page, + # dependencies before the actions that required them + when = datetime.datetime.now(datetime.timezone.utc).isoformat() + for name in reversed(expanded): + self._permission_checks.append( + PermissionCheck( + when=when, + actor=actor, + action=name, + parent=parent, + child=child, + result=final[name], + ) + ) + + return {name: final[name] for name in requested} async def ensure_permission( self, @@ -1756,6 +2019,11 @@ class Datasette: other_table = fk["other_table"] other_column = fk["other_column"] + if other_column is None: + other_pks = await db.primary_keys(other_table) + if len(other_pks) != 1: + return {} + other_column = other_pks[0] visible, _ = await self.check_visibility( actor, action="view-table", @@ -2052,6 +2320,8 @@ class Datasette: and "ds_actor" in request.cookies and request.actor, "app_css_hash": self.app_css_hash(), + "edit_tools_js_hash": self.static_hash("edit-tools.js"), + "table_js_hash": self.static_hash("table.js"), "zip": zip, "body_scripts": body_scripts, "format_bytes": format_bytes, @@ -2236,6 +2506,10 @@ class Datasette: JumpView.as_view(self), r"/-/jump(\.(?Pjson))?$", ) + add_route( + GlobalQueryListView.as_view(self), + r"/-/queries(\.(?Pjson))?$", + ) add_route( InstanceSchemaView.as_view(self), r"/-/schema(\.(?Pjson|md))?$", @@ -2272,6 +2546,10 @@ class Datasette: wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) + add_route( + AutocompleteDebugView.as_view(self), + r"/-/debug/autocomplete$", + ) add_route( wrap_view(database_download, self), r"/(?P[^\/\.]+)\.db$", @@ -2281,14 +2559,54 @@ class Datasette: r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") + add_route( + QueryListView.as_view(self), + r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", + ) + add_route( + QueryCreateAnalyzeView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/analyze$", + ) + add_route( + QueryStoreView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/store$", + ) + add_route( + ExecuteWriteAnalyzeView.as_view(self), + r"/(?P[^\/\.]+)/-/execute-write/analyze$", + ) + add_route( + ExecuteWriteView.as_view(self), + r"/(?P[^\/\.]+)/-/execute-write$", + ) add_route( DatabaseSchemaView.as_view(self), r"/(?P[^\/\.]+)/-/schema(\.(?Pjson|md))?$", ) + add_route( + QueryParametersView.as_view(self), + r"/(?P[^\/\.]+)/-/query/parameters$", + ) add_route( wrap_view(QueryView, self), r"/(?P[^\/\.]+)/-/query(\.(?P\w+))?$", ) + add_route( + QueryDefinitionView.as_view(self), + r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/definition$", + ) + add_route( + QueryEditView.as_view(self), + r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/edit$", + ) + add_route( + QueryUpdateView.as_view(self), + r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/update$", + ) + add_route( + QueryDeleteView.as_view(self), + r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/delete$", + ) add_route( wrap_view(table_view, self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)(\.(?P\w+))?$", @@ -2309,6 +2627,14 @@ class Datasette: TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", ) + add_route( + TableFragmentView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/fragment$", + ) + add_route( + TableAutocompleteView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/autocomplete$", + ) add_route( TableDropView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", @@ -2395,7 +2721,16 @@ class DatasetteRouter: if raw_path: path = raw_path.decode("ascii") path = path.partition("?")[0] - return await self.route_path(scope, receive, send, path) + # Give each request a fresh permission check cache, so repeated + # datasette.allowed() checks within the request are memoized but + # results never persist beyond it + from datasette.permissions import _permission_check_cache + + cache_token = _permission_check_cache.set({}) + try: + return await self.route_path(scope, receive, send, path) + finally: + _permission_check_cache.reset(cache_token) async def route_path(self, scope, receive, send, path): # Strip off base_url if present before routing @@ -2658,19 +2993,22 @@ def wrap_view_function(view_fn, datasette): def permanent_redirect(path, forward_query_string=False, forward_rest=False): - return wrap_view( - lambda request, send: Response.redirect( + def view(request, send): + redirect_path = ( path + (request.url_vars["rest"] if forward_rest else "") + ( ("?" + request.query_string) if forward_query_string and request.query_string else "" - ), - status=301, - ), - datasette=None, - ) + ) + ) + route_path = request.scope.get("route_path") + if route_path and request.path.endswith(route_path): + redirect_path = request.path[: -len(route_path)] + redirect_path + return Response.redirect(redirect_path, status=301) + + return wrap_view(view, datasette=None) _curly_re = re.compile(r"({.*?})") diff --git a/datasette/cli.py b/datasette/cli.py index 93aa22ef..90a33e80 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -21,6 +21,7 @@ from .app import ( SQLITE_LIMIT_ATTACHED, pm, ) +from .inspect import inspect_tables from .utils import ( LoadExtension, StartupError, @@ -154,14 +155,14 @@ async def inspect_(files, sqlite_extensions): app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) data = {} for name, database in app.databases.items(): - counts = await database.table_counts(limit=3600 * 1000) + tables = await database.execute_fn(lambda conn: inspect_tables(conn, {})) data[name] = { "hash": database.hash, "size": database.size, "file": database.path, "tables": { - table_name: {"count": table_count} - for table_name, table_count in counts.items() + table_name: {"count": table["count"]} + for table_name, table in tables.items() }, } return data diff --git a/datasette/column_types.py b/datasette/column_types.py index 7320e1d6..11a14ec0 100644 --- a/datasette/column_types.py +++ b/datasette/column_types.py @@ -6,19 +6,17 @@ class SQLiteType(Enum): INTEGER = "INTEGER" REAL = "REAL" BLOB = "BLOB" - NULL = "NULL" + NUMERIC = "NUMERIC" @classmethod - def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None": + def from_declared_type(cls, declared_type: str | None) -> "SQLiteType": if declared_type is None: - return cls.NULL + return cls.BLOB normalized = declared_type.strip().upper() if not normalized: - return cls.NULL + return cls.BLOB - if normalized == cls.NULL.value: - return cls.NULL if "INT" in normalized: return cls.INTEGER if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")): @@ -31,7 +29,7 @@ class SQLiteType(Enum): ): return cls.REAL - return None + return cls.NUMERIC class ColumnType: diff --git a/datasette/database.py b/datasette/database.py index 66d50ffa..e7fe1ed9 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -25,11 +25,14 @@ from .utils import ( table_columns, table_column_details, ) -from .utils.sqlite import sqlite_version +from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables +from .utils.sqlite import sqlite_hidden_table_names from .inspect import inspect_hash connections = threading.local() +EXECUTE_WRITE_RETURNING_LIMIT = 10 + AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file")) @@ -235,11 +238,24 @@ class Database: except OSError: pass - async def execute_write(self, sql, params=None, block=True, request=None): + async def execute_write( + self, + sql, + params=None, + block=True, + request=None, + return_all=False, + returning_limit=EXECUTE_WRITE_RETURNING_LIMIT, + ): self._check_not_closed() + if returning_limit < 0: + raise ValueError("returning_limit must be >= 0") def _inner(conn): - return conn.execute(sql, params or []) + cursor = conn.execute(sql, params or []) + return ExecuteWriteResult.from_cursor( + cursor, return_all=return_all, returning_limit=returning_limit + ) with trace("sql", database=self.name, sql=sql.strip(), params=params): results = await self.execute_write_fn(_inner, block=block, request=request) @@ -282,13 +298,14 @@ class Database: async def execute_isolated_fn(self, fn): self._check_not_closed() - # Open a new connection just for the duration of this function + # Open a new connection just for the duration of this function, # blocking the write queue to avoid any writes occurring during it - if self.ds.executor is None: - # non-threaded mode - isolated_connection = self.connect(write=True) + write = self.is_mutable + + def _run(): + isolated_connection = self.connect(write=write) try: - result = fn(isolated_connection) + return fn(isolated_connection) finally: isolated_connection.close() try: @@ -296,10 +313,25 @@ class Database: except ValueError: # Was probably a memory connection pass - return result - else: - # Threaded mode - send to write thread - return await self._send_to_write_thread(fn, isolated_connection=True) + + if self.ds.executor is None: + # non-threaded mode + return _run() + if not write: + # Immutable database - no writes can ever occur, so there is no + # write queue to block; run against a fresh read-only connection + return await asyncio.get_running_loop().run_in_executor( + self.ds.executor, _run + ) + # Threaded mode - send to write thread + return await self._send_to_write_thread(fn, isolated_connection=True) + + async def analyze_sql(self, sql, params=None) -> SQLAnalysis: + self._check_not_closed() + + return await self.execute_isolated_fn( + lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name) + ) async def execute_write_fn(self, fn, block=True, transaction=True, request=None): self._check_not_closed() @@ -426,20 +458,21 @@ class Database: if conn_exception is not None: exception = conn_exception elif task.isolated_connection: - isolated_connection = self.connect(write=True) try: - result = task.fn(isolated_connection) + isolated_connection = self.connect(write=True) + try: + result = task.fn(isolated_connection) + finally: + isolated_connection.close() + try: + self._all_file_connections.remove(isolated_connection) + except ValueError: + # Was probably a memory connection + pass except Exception as e: sys.stderr.write("{}\n".format(e)) sys.stderr.flush() exception = e - finally: - isolated_connection.close() - try: - self._all_file_connections.remove(isolated_connection) - except ValueError: - # Was probably a memory connection - pass else: try: if task.transaction: @@ -694,83 +727,7 @@ class Database: t for t in db_config["tables"] if db_config["tables"][t].get("hidden") ] - if sqlite_version()[1] >= 37: - hidden_tables += [x[0] for x in await self.execute(""" - with shadow_tables as ( - select name - from pragma_table_list - where [type] = 'shadow' - order by name - ), - core_tables as ( - select name - from sqlite_master - WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4') - OR substr(name, 1, 1) == '_' - ), - combined as ( - select name from shadow_tables - union all - select name from core_tables - ) - select name from combined order by 1 - """)] - else: - hidden_tables += [x[0] for x in await self.execute(""" - WITH base AS ( - SELECT name - FROM sqlite_master - WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4') - OR substr(name, 1, 1) == '_' - ), - fts_suffixes AS ( - SELECT column1 AS suffix - FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config')) - ), - fts5_names AS ( - SELECT name - FROM sqlite_master - WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%' - ), - fts5_shadow_tables AS ( - SELECT - printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name - FROM fts5_names - JOIN fts_suffixes - ), - fts3_suffixes AS ( - SELECT column1 AS suffix - FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize')) - ), - fts3_names AS ( - SELECT name - FROM sqlite_master - WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%' - OR sql LIKE '%VIRTUAL TABLE%USING FTS4%' - ), - fts3_shadow_tables AS ( - SELECT - printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name - FROM fts3_names - JOIN fts3_suffixes - ), - final AS ( - SELECT name FROM base - UNION ALL - SELECT name FROM fts5_shadow_tables - UNION ALL - SELECT name FROM fts3_shadow_tables - ) - SELECT name FROM final ORDER BY 1 - """)] - # Also hide any FTS tables that have a content= argument - hidden_tables += [x[0] for x in await self.execute(""" - SELECT name - FROM sqlite_master - WHERE sql LIKE '%VIRTUAL TABLE%' - AND sql LIKE '%USING FTS%' - AND sql LIKE '%content=%' - """)] + hidden_tables += await self.execute_fn(sqlite_hidden_table_names) has_spatialite = await self.execute_fn(detect_spatialite) if has_spatialite: @@ -872,10 +829,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event): # Execute the actual write try: result = fn(conn) - except Exception: + except Exception as e: # Throw exception into generator so it can handle it try: - gen.throw(*sys.exc_info()) + gen.throw(e) except StopIteration: pass # Re-raise the original exception @@ -945,6 +902,44 @@ class MultipleValues(Exception): pass +class ExecuteWriteResult: + def __init__(self, rowcount, lastrowid, description, rows, truncated): + self.rowcount = rowcount + self.lastrowid = lastrowid + self.description = description + self.truncated = truncated + self._rows = rows + + @classmethod + def from_cursor( + cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT + ): + rows = [] + truncated = False + description = cursor.description + lastrowid = cursor.lastrowid + try: + if description is not None: + if return_all: + rows = cursor.fetchall() + else: + rows = cursor.fetchmany(returning_limit + 1) + if len(rows) > returning_limit: + rows = rows[:returning_limit] + truncated = True + rowcount = cursor.rowcount + finally: + cursor.close() + if description is not None and not return_all and truncated: + rowcount = -1 + return cls(rowcount, lastrowid, description, rows, truncated) + + def fetchall(self): + rows = self._rows + self._rows = [] + return rows + + class Results: def __init__(self, rows, truncated, description): self.rows = rows diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 149a4e5f..2f78570b 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -48,12 +48,26 @@ def register_actions(): resource_class=DatabaseResource, also_requires="view-database", ), + Action( + name="execute-write-sql", + abbr="ews", + description="Execute writable SQL queries", + resource_class=DatabaseResource, + also_requires="view-database", + ), Action( name="create-table", abbr="ct", description="Create tables", resource_class=DatabaseResource, ), + Action( + name="store-query", + abbr="sq", + description="Create stored queries", + resource_class=DatabaseResource, + also_requires="execute-sql", + ), # Table-level actions (child-level) Action( name="view-table", @@ -104,4 +118,16 @@ def register_actions(): description="View named query results", resource_class=QueryResource, ), + Action( + name="update-query", + abbr="uq", + description="Update stored queries", + resource_class=QueryResource, + ), + Action( + name="delete-query", + abbr="dq", + description="Delete stored queries", + resource_class=QueryResource, + ), ) diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py index 24493994..f90a733e 100644 --- a/datasette/default_column_types.py +++ b/datasette/default_column_types.py @@ -76,6 +76,12 @@ class JsonColumnType(ColumnType): return None +class TextareaColumnType(ColumnType): + name = "textarea" + description = "Multiline text" + sqlite_types = (SQLiteType.TEXT,) + + @hookimpl def register_column_types(datasette): - return [UrlColumnType, EmailColumnType, JsonColumnType] + return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType] diff --git a/datasette/default_database_actions.py b/datasette/default_database_actions.py new file mode 100644 index 00000000..e0cb3cdf --- /dev/null +++ b/datasette/default_database_actions.py @@ -0,0 +1,24 @@ +from datasette import hookimpl +from datasette.resources import DatabaseResource + + +@hookimpl +def database_actions(datasette, actor, database, request): + async def inner(): + if not datasette.get_database(database).is_mutable: + return [] + if not await datasette.allowed( + action="execute-write-sql", + resource=DatabaseResource(database), + actor=actor, + ): + return [] + return [ + { + "href": datasette.urls.database(database) + "/-/execute-write", + "label": "Execute write SQL", + "description": "Run writable SQL with table permission checks.", + } + ] + + return inner diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py index 6127b2a6..8ea3c287 100644 --- a/datasette/default_debug_menu.py +++ b/datasette/default_debug_menu.py @@ -37,6 +37,11 @@ DEBUG_MENU_ITEMS = ( "Debug allow rules", "Explore how allow blocks match actors against permission rules.", ), + ( + "/-/debug/autocomplete", + "Debug autocomplete", + "Try out table autocomplete against a detected label column.", + ), ( "/-/threads", "Debug threads", diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index 9e3bb648..6cd46f04 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -17,13 +17,6 @@ UNION/INTERSECT operations. The order of evaluation is: from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from datasette.app import Datasette - -from datasette import hookimpl - # Re-export all hooks and public utilities from .restrictions import ( actor_restrictions_sql as actor_restrictions_sql, @@ -33,16 +26,9 @@ from .restrictions import ( from .root import root_user_permissions_sql as root_user_permissions_sql from .config import config_permissions_sql as config_permissions_sql from .defaults import ( + # Avoid "datasette.default_permissions" does not explicitly export attribute default_allow_sql_check as default_allow_sql_check, default_action_permissions_sql as default_action_permissions_sql, + default_query_permissions_sql as default_query_permissions_sql, DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS, ) - - -@hookimpl -def canned_queries(datasette: "Datasette", database: str, actor) -> dict: - """Return canned queries defined in datasette.yaml configuration.""" - queries = ( - ((datasette.config or {}).get("databases") or {}).get(database) or {} - ).get("queries") or {} - return queries diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 4c74219d..5bc74425 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -67,3 +67,48 @@ async def default_action_permissions_sql( return PermissionSQL.allow(reason=reason) return None + + +@hookimpl(specname="permission_resources_sql") +async def default_query_permissions_sql( + datasette: "Datasette", + actor: Optional[dict], + action: str, +) -> Optional[PermissionSQL]: + actor_id = actor.get("id") if isinstance(actor, dict) else None + + if action not in {"view-query", "update-query", "delete-query"}: + return None + + params = {"query_owner_id": actor_id} + rule_sqls = [] + if actor_id is not None: + if action in {"update-query", "delete-query"}: + # Query owner can update/delete query + rule_sqls.append(""" + SELECT database_name AS parent, name AS child, 1 AS allow, + 'query owner' AS reason + FROM queries + WHERE source = 'user' + AND owner_id = :query_owner_id + """) + else: + # Query owner can view-query + rule_sqls.append(""" + SELECT database_name AS parent, name AS child, 1 AS allow, + 'query owner' AS reason + FROM queries + WHERE owner_id = :query_owner_id + """) + + # restriction_sql enforces private queries ONLY visible/mutable by owner + return PermissionSQL( + sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, + restriction_sql=""" + SELECT database_name AS parent, name AS child + FROM queries + WHERE is_private = 0 + OR owner_id = :query_owner_id + """, + params=params, + ) diff --git a/datasette/default_query_actions.py b/datasette/default_query_actions.py new file mode 100644 index 00000000..2183e70b --- /dev/null +++ b/datasette/default_query_actions.py @@ -0,0 +1,48 @@ +from datasette import hookimpl +from datasette.resources import QueryResource + + +@hookimpl +def query_actions(datasette, actor, database, query_name, request): + # Only stored queries (with a name) can be edited or deleted + if not query_name: + return None + + async def inner(): + query = await datasette.get_query(database, query_name) + if query is None: + return [] + # Config-defined and trusted queries are managed outside the UI + if query.source == "config" or query.is_trusted: + return [] + + links = [] + if await datasette.allowed( + action="update-query", + resource=QueryResource(database, query_name), + actor=actor, + ): + links.append( + { + "href": datasette.urls.table(database, query_name) + "/-/edit", + "label": "Edit this query", + "description": ( + "Change the title, description, SQL or visibility." + ), + } + ) + if await datasette.allowed( + action="delete-query", + resource=QueryResource(database, query_name), + actor=actor, + ): + links.append( + { + "href": datasette.urls.table(database, query_name) + "/-/delete", + "label": "Delete this query", + "description": "Permanently remove this saved query.", + } + ) + return links + + return inner diff --git a/datasette/extras.py b/datasette/extras.py new file mode 100644 index 00000000..5cab52a4 --- /dev/null +++ b/datasette/extras.py @@ -0,0 +1,118 @@ +import re +from dataclasses import dataclass +from enum import Enum +from typing import ClassVar + +from asyncinject import Registry + + +def extra_names_from_request(request): + extra_bits = request.args.getlist("_extra") + extras = set() + for bit in extra_bits: + extras.update(part for part in bit.split(",") if part) + return extras + + +class ExtraScope(Enum): + TABLE = "table" + ROW = "row" + QUERY = "query" + + +@dataclass(frozen=True) +class ExtraExample: + path: str | None = None + key: str | None = None + value: object | None = None + note: str | None = None + + +class Provider: + name: ClassVar[str | None] = None + scopes: ClassVar[set[ExtraScope]] = set() + public: ClassVar[bool] = False + + @classmethod + def key(cls): + return cls.name or _camel_to_snake(cls.__name__) + + @classmethod + def available_for(cls, scope): + return scope in cls.scopes + + async def resolve(self, context): + raise NotImplementedError + + +class Extra(Provider): + description: ClassVar[str | None] = None + example: ClassVar[ExtraExample | None] = None + examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {} + public: ClassVar[bool] = True + expensive: ClassVar[bool] = False + docs_note: ClassVar[str | None] = None + + @classmethod + def example_for_scope(cls, scope): + return cls.examples.get(scope, cls.example) + + +class ExtraRegistry: + def __init__(self, classes): + self.classes = list(classes) + self.classes_by_name = {cls.key(): cls for cls in self.classes} + # Lazily-built shared state, keyed by scope. Safe to share across + # requests because Extra instances are stateless and asyncinject's + # Registry keeps per-call state local to each resolve_multi() call. + # If extras classes ever become registerable at runtime (e.g. via a + # plugin hook) these caches will need invalidating. + self._scope_registries = {} + self._allowed_names = {} + + def classes_for_scope(self, scope, include_internal=True): + classes = [ + cls + for cls in self.classes + if cls.available_for(scope) and (include_internal or cls.public) + ] + return classes + + def public_classes_for_scope(self, scope): + return self.classes_for_scope(scope, include_internal=False) + + def _registry_for_scope(self, scope): + registry = self._scope_registries.get(scope) + if registry is None: + registry = Registry() + for cls in self.classes_for_scope(scope): + registry.register(cls().resolve, name=cls.key()) + self._scope_registries[scope] = registry + return registry + + def _allowed_names_for_scope(self, scope, include_internal): + key = (scope, include_internal) + names = self._allowed_names.get(key) + if names is None: + names = { + cls.key() + for cls in self.classes_for_scope( + scope, include_internal=include_internal + ) + } + self._allowed_names[key] = names + return names + + async def resolve(self, requested, context, scope, include_internal=False): + allowed_names = self._allowed_names_for_scope(scope, include_internal) + requested_names = [name for name in requested if name in allowed_names] + resolved = await self._registry_for_scope(scope).resolve_multi( + requested_names, results={"context": context} + ) + return {name: resolved[name] for name in requested_names} + + +def _camel_to_snake(name): + name = re.sub(r"(Extra|Provider)$", "", name) + name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() diff --git a/datasette/facets.py b/datasette/facets.py index bc4b6904..abe0605e 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -83,7 +83,7 @@ class Facet: self.ds = ds self.request = request self.database = database - # For foreign key expansion. Can be None for e.g. canned SQL queries: + # For foreign key expansion. Can be None for e.g. stored SQL queries: self.table = table self.sql = sql or f"select * from [{table}]" self.params = params or [] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index cf95abcb..7c56f882 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -137,11 +137,6 @@ def permission_resources_sql(datasette, actor, action): """ -@hookspec -def canned_queries(datasette, database, actor): - """Return a dictionary of canned query definitions or an awaitable function that returns them""" - - @hookspec def register_magic_parameters(datasette): """Return a list of (name, function) magic parameter functions""" @@ -164,32 +159,32 @@ def jump_items_sql(datasette, actor, request): @hookspec def row_actions(datasette, actor, request, database, table, row): - """Links for the row actions menu""" + """Items for the row actions menu""" @hookspec def table_actions(datasette, actor, database, table, request): - """Links for the table actions menu""" + """Items for the table actions menu""" @hookspec def view_actions(datasette, actor, database, view, request): - """Links for the view actions menu""" + """Items for the view actions menu""" @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): - """Links for the query and canned query actions menu""" + """Items for the query and stored query actions menu""" @hookspec def database_actions(datasette, actor, database, request): - """Links for the database actions menu""" + """Items for the database actions menu""" @hookspec def homepage_actions(datasette, actor, request): - """Links for the homepage actions menu""" + """Items for the homepage actions menu""" @hookspec @@ -233,8 +228,8 @@ def top_query(datasette, request, database, sql): @hookspec -def top_canned_query(datasette, request, database, query_name): - """HTML to include at the top of the canned query page""" +def top_stored_query(datasette, request, database, query_name): + """HTML to include at the top of the stored query page""" @hookspec diff --git a/datasette/permissions.py b/datasette/permissions.py index 917c58ab..786dc026 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -8,6 +8,14 @@ _skip_permission_checks = contextvars.ContextVar( "skip_permission_checks", default=False ) +# Request-scoped cache of permission check results. The ASGI router sets +# this to a fresh dict at the start of each request, so cached verdicts +# never outlive a request or leak between actors. Keys are +# (actor_json, action, parent, child) tuples, values are booleans. +_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar( + "permission_check_cache", default=None +) + class SkipPermissions: """Context manager to temporarily skip permission checks. @@ -58,6 +66,16 @@ class Resource(ABC): self.child = child self._private = None # Sentinel to track if private was set + def __str__(self) -> str: + return "/".join( + str(part) for part in (self.parent, self.child) if part is not None + ) + + def __repr__(self) -> str: + return "{}(parent={!r}, child={!r})".format( + self.__class__.__name__, self.parent, self.child + ) + @property def private(self) -> bool: """ diff --git a/datasette/plugins.py b/datasette/plugins.py index f532ac60..f0fbc7f8 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -30,6 +30,8 @@ DEFAULT_PLUGINS = ( "datasette.blob_renderer", "datasette.default_debug_menu", "datasette.default_jump_items", + "datasette.default_database_actions", + "datasette.default_query_actions", "datasette.handle_exception", "datasette.forbidden", "datasette.events", diff --git a/datasette/renderer.py b/datasette/renderer.py index acf23e59..f40e3dbb 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -1,4 +1,5 @@ import json +from datasette.extras import extra_names_from_request from datasette.utils import ( value_as_boolean, remove_infinites, @@ -108,7 +109,7 @@ def json_renderer(request, args, data, error, truncated=None): # Don't include "columns" in output # https://github.com/simonw/datasette/issues/2136 - if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"): + if isinstance(data, dict) and "columns" not in extra_names_from_request(request): data.pop("columns", None) # Handle _nl option for _shape=array diff --git a/datasette/resources.py b/datasette/resources.py index 236b3598..ee2e6d98 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -41,7 +41,7 @@ class TableResource(Resource): class QueryResource(Resource): - """A canned query in a database.""" + """A stored query in a database.""" name = "query" parent_class = DatabaseResource @@ -51,42 +51,8 @@ class QueryResource(Resource): @classmethod async def resources_sql(cls, datasette, actor=None) -> str: - from datasette.plugins import pm - from datasette.utils import await_me_maybe - - # Get all databases from catalog - db = datasette.get_internal_database() - result = await db.execute("SELECT database_name FROM catalog_databases") - databases = [row[0] for row in result.rows] - - # Gather canned queries for this actor from all databases. - # This keeps allowed_resources("view-query", actor=...) consistent with - # actor-specific canned_queries() implementations. - query_pairs = [] - for database_name in databases: - # Call the hook to get queries (including from config via default plugin) - for queries_result in pm.hook.canned_queries( - datasette=datasette, - database=database_name, - actor=actor, - ): - queries = await await_me_maybe(queries_result) - if queries: - for query_name in queries.keys(): - query_pairs.append((database_name, query_name)) - - # Build SQL - if not query_pairs: - return "SELECT NULL AS parent, NULL AS child WHERE 0" - - # Generate UNION ALL query - selects = [] - for db_name, query_name in query_pairs: - # Escape single quotes by doubling them - db_escaped = db_name.replace("'", "''") - query_escaped = query_name.replace("'", "''") - selects.append( - f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child" - ) - - return " UNION ALL ".join(selects) + return """ + SELECT q.database_name AS parent, q.name AS child + FROM queries q + JOIN catalog_databases cd ON cd.database_name = q.database_name + """ diff --git a/datasette/static/app.css b/datasette/static/app.css index c21d0dc4..5fe4502d 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -706,6 +706,11 @@ button.core[type=button] { color: #666; padding-right: 0.25em; } +/* The label may wrap (word-break: break-all on the li) but the count should + stay on one line - https://github.com/simonw/datasette/issues/2754 */ +.facet-count { + white-space: nowrap; +} .facet-info li, .facet-info ul { margin: 0; @@ -787,9 +792,9 @@ p.zero-results { dialog.mobile-column-actions-dialog { --ink: #0f0f0f; - --paper: #f5f3ef; + --paper: #eef6ff; --muted: #6b6b6b; - --rule: #e2dfd8; + --rule: #d8e6f5; --accent: #1a56db; --card: #ffffff; border: none; @@ -1015,9 +1020,9 @@ dialog.mobile-column-actions-dialog::backdrop { dialog.set-column-type-dialog { --ink: #0f0f0f; - --paper: #f5f3ef; + --paper: #eef6ff; --muted: #6b6b6b; - --rule: #e2dfd8; + --rule: #d8e6f5; --accent: #1a56db; --card: #ffffff; border: none; @@ -1104,7 +1109,7 @@ dialog.set-column-type-dialog::backdrop { padding: 14px 16px; border: 1px solid var(--rule); border-radius: 8px; - background: #fcfbf9; + background: #fbfdff; cursor: pointer; } @@ -1187,6 +1192,607 @@ dialog.set-column-type-dialog::backdrop { cursor: wait; } +.row-mutation-status { + margin: 0 0 0.75rem; + padding: 8px 10px; + border-left: 4px solid #54AC8E; + background: rgba(103,201,141,0.12); + color: #222; +} + +.row-mutation-status[hidden] { + display: none; +} + +.row-mutation-status-error { + border-left-color: #D0021B; + background: rgba(208,2,27,0.12); +} + +.table-row-toolbar { + margin: 0 0 0.75rem; +} + +button.table-insert-row { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +button.table-insert-row svg { + display: block; + flex-shrink: 0; +} + +dialog.row-delete-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(440px, calc(100vw - 32px)); + max-width: 95vw; + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.row-delete-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-delete-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.row-delete-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.row-delete-dialog .modal-title { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-delete-message, +.row-delete-error { + margin: 0; + padding: 16px 24px 0; +} + +.row-delete-message { + color: var(--ink); + font-size: 0.95rem; +} + +.row-delete-id { + display: inline; + padding: 2px 5px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +.row-delete-error { + color: #b91c1c; + font-size: 0.9rem; +} + +.row-delete-dialog .modal-footer { + padding: 18px 20px 14px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); + margin-top: 18px; +} + +.row-delete-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.row-delete-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-delete-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-delete-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-delete-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-delete-dialog .btn:disabled { + opacity: 0.65; + cursor: wait; +} + +dialog.row-edit-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(720px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.row-edit-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-edit-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.row-edit-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.row-edit-dialog .modal-title { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-edit-dialog .modal-title .row-dialog-action, +.row-delete-dialog .modal-title .row-dialog-action { + flex: 0 0 auto; + white-space: nowrap; +} + +.row-edit-dialog .modal-title code, +.row-delete-dialog .modal-title code { + display: inline; + flex: 0 0 auto; + padding: 2px 5px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +.row-edit-dialog .modal-title .row-dialog-label, +.row-delete-dialog .modal-title .row-dialog-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-edit-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.row-edit-summary, +.row-edit-loading, +.row-edit-error { + margin: 0; + padding: 12px 24px 0; +} + +.row-edit-summary, +.row-edit-loading { + color: var(--muted); + font-size: 0.9rem; +} + +.row-edit-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.row-edit-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.row-edit-fields { + display: grid; + gap: 14px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.row-edit-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.row-edit-label { + padding-top: 8px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.row-edit-control-wrap { + display: grid; + gap: 5px; +} + +.row-edit-input { + box-sizing: border-box; + width: 100%; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; +} + +textarea.row-edit-input { + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.row-edit-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.row-edit-input[aria-invalid="true"] { + border-color: #b42318; + background: #fff8f7; +} + +.row-edit-input[aria-invalid="true"]:focus { + border-color: #b42318; + outline-color: rgba(180, 35, 24, 0.16); +} + +.row-edit-input[readonly] { + color: var(--muted); + background: var(--paper); +} + +.row-edit-default { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + align-items: center; + gap: 8px; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 7px 8px 7px 10px; + background: var(--paper); + color: var(--ink); +} + +.row-edit-default[hidden], +.row-edit-custom-value[hidden] { + display: none; +} + +.row-edit-default-text { + min-width: 0; + overflow-wrap: anywhere; +} + +.row-edit-default-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.row-edit-custom-value { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + gap: 8px; + align-items: center; + min-height: 45px; + padding-right: 8px; +} + +.row-edit-default-button { + appearance: none; + border: 1px solid var(--rule); + border-radius: 4px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + line-height: 1.2; + padding: 6px 8px; + white-space: nowrap; + width: 100%; + align-self: center; +} + +.row-edit-default-button:hover, +.row-edit-default-button:focus { + background: #f8fafc; +} + +.row-edit-default-button:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.row-edit-field-meta { + color: var(--muted); + font-size: 0.78rem; +} + +.row-edit-field-validation-error { + color: #b42318; + display: block; + margin-top: 2px; +} + +.row-edit-field-validation-error[hidden] { + display: none; +} + +.row-edit-field-meta-autocomplete { + line-height: 1.2; + min-height: 1.2em; +} + +.row-edit-fk-pk { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.row-edit-fk-link { + overflow-wrap: anywhere; +} + +.row-edit-empty { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +datasette-autocomplete { + display: block; + position: relative; + max-width: 38rem; +} + +datasette-autocomplete input[type="text"], +.debug-autocomplete-form input[type="text"] { + box-sizing: border-box; + width: 100%; + max-width: 38rem; +} + +.datasette-autocomplete-list { + background: #fff; + border: 1px solid var(--rule); + border-radius: 5px; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); + box-sizing: border-box; + left: 0; + max-height: 16rem; + overflow-y: auto; + position: fixed; + right: auto; + top: auto; + z-index: 10000; +} + +.datasette-autocomplete-list[hidden] { + display: none; +} + +.datasette-autocomplete-option { + cursor: pointer; + padding: 7px 9px; +} + +.datasette-autocomplete-option:hover, +.datasette-autocomplete-option[aria-selected="true"] { + background: var(--paper); +} + +.datasette-autocomplete-option[aria-selected="true"] { + background: var(--paper); + font-weight: 600; +} + +.datasette-autocomplete-status { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.debug-autocomplete-demo { + margin: 1rem 0; +} + +.debug-autocomplete-selected { + max-width: 46rem; +} + +.row-edit-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.row-edit-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.row-edit-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-edit-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-edit-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-edit-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-edit-dialog .btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.row-link-with-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.row-inline-actions { + display: inline-flex; + gap: 0.2rem; + align-items: center; +} + +.row-inline-action { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + min-height: 24px; + min-width: 24px; + padding: 2px; + position: relative; +} + +.row-inline-action:hover, +.row-inline-action:focus { + background: rgba(74, 85, 104, 0.07); +} + +.row-inline-action:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.row-inline-action-icon { + display: block; + height: 13px; + width: 13px; +} + @media (max-width: 640px) { dialog.mobile-column-actions-dialog { width: 95vw; @@ -1234,6 +1840,68 @@ dialog.set-column-type-dialog::backdrop { padding-left: 18px; padding-right: 18px; } + + dialog.row-delete-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-delete-dialog .modal-header, + .row-delete-message, + .row-delete-error { + padding-left: 18px; + padding-right: 18px; + } + + .row-delete-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + dialog.row-edit-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-edit-dialog .modal-header, + .row-edit-summary, + .row-edit-loading, + .row-edit-fields { + padding-left: 18px; + padding-right: 18px; + } + + .row-edit-error { + margin-left: 18px; + margin-right: 18px; + } + + .row-edit-field { + grid-template-columns: 1fr; + gap: 5px; + } + + .row-edit-label { + padding-top: 0; + } + + .row-edit-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + + .row-inline-action { + min-height: 30px; + min-width: 30px; + padding: 4px; + } + + .row-inline-action-icon { + height: 14px; + width: 14px; + } } @media only screen and (max-width: 576px) { @@ -1288,6 +1956,10 @@ dialog.set-column-type-dialog::backdrop { font-size: 0.8em; } + .row-inline-actions { + margin-bottom: 0.35rem; + } + .select-wrapper { width: 100px; } @@ -1298,6 +1970,7 @@ dialog.set-column-type-dialog::backdrop { width: 140px; } button.choose-columns-mobile, + button.table-insert-row, button.column-actions-mobile { display: inline-flex; align-items: center; @@ -1334,6 +2007,15 @@ dialog.set-column-type-dialog::backdrop { button.choose-columns-mobile { margin-right: 0.5rem; } + + .table-row-toolbar { + margin-bottom: 0.75rem; + } + + button.table-insert-row { + width: 100%; + margin-bottom: 0; + } } svg.dropdown-menu-icon { @@ -1379,18 +2061,32 @@ svg.dropdown-menu-icon { .dropdown-menu a:link, .dropdown-menu a:visited, .dropdown-menu a:hover, -.dropdown-menu a:focus -.dropdown-menu a:active { +.dropdown-menu a:focus, +.dropdown-menu a:active, +.dropdown-menu button.action-menu-button { text-decoration: none; display: block; padding: 4px 8px 2px 8px; color: #222; white-space: nowrap; } -.dropdown-menu a:hover { +.dropdown-menu button.action-menu-button { + appearance: none; + background: none; + border: none; + box-sizing: border-box; + cursor: pointer; + font: inherit; + text-align: left; + width: 100%; +} +.dropdown-menu a:hover, +.dropdown-menu button.action-menu-button:hover, +.dropdown-menu button.action-menu-button:focus { background-color: #eee; } .dropdown-menu .dropdown-description { + display: block; margin: 0; color: #666; font-size: 0.8em; @@ -1409,11 +2105,15 @@ svg.dropdown-menu-icon { border-bottom: 5px solid #666; } -.canned-query-edit-sql { +.stored-query-edit-sql { padding-left: 0.5em; position: relative; top: 1px; } +.save-query { + display: inline-block; + margin-left: 0.45em; +} .blob-download { display: block; diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js new file mode 100644 index 00000000..c615000e --- /dev/null +++ b/datasette/static/autocomplete.js @@ -0,0 +1,344 @@ +(function () { + function autocompleteValueFromRow(row) { + var pks = (row && row.pks) || {}; + var keys = Object.keys(pks); + if (!keys.length) { + return ""; + } + if (keys.length === 1) { + return String(pks[keys[0]]); + } + return keys + .map(function (key) { + return key + "=" + pks[key]; + }) + .join(", "); + } + + function autocompleteLabelFromRow(row) { + var value = autocompleteValueFromRow(row); + if (row.label && String(row.label) !== value) { + return row.label + " (" + value + ")"; + } + return value; + } + + if (!window.customElements || customElements.get("datasette-autocomplete")) { + return; + } + + class DatasetteAutocomplete extends HTMLElement { + constructor() { + super(); + this.input = null; + this.listbox = null; + this.status = null; + this.results = []; + this.activeIndex = -1; + this.fetchId = 0; + this.searchTimer = null; + this.boundInput = this.handleInput.bind(this); + this.boundKeydown = this.handleKeydown.bind(this); + this.boundBlur = this.handleBlur.bind(this); + this.boundFocus = this.handleFocus.bind(this); + this.boundPositionListbox = this.positionListbox.bind(this); + } + + connectedCallback() { + if (this.input) { + return; + } + this.input = this.querySelector("input"); + if (!this.input) { + return; + } + + var inputId = + this.input.id || + "datasette-autocomplete-" + Math.random().toString(36).slice(2); + this.input.id = inputId; + var listboxId = inputId + "-listbox"; + var statusId = inputId + "-status"; + + this.classList.add("datasette-autocomplete"); + this.input.setAttribute("role", "combobox"); + this.input.setAttribute("aria-autocomplete", "list"); + this.input.setAttribute("aria-expanded", "false"); + this.input.setAttribute("aria-controls", listboxId); + this.input.setAttribute("autocomplete", "off"); + + this.listbox = document.createElement("div"); + this.listbox.className = "datasette-autocomplete-list"; + this.listbox.id = listboxId; + this.listbox.setAttribute("role", "listbox"); + this.listbox.hidden = true; + + this.status = document.createElement("span"); + this.status.className = "datasette-autocomplete-status"; + this.status.id = statusId; + this.status.setAttribute("role", "status"); + this.status.setAttribute("aria-live", "polite"); + + this.input.setAttribute( + "aria-describedby", + [this.input.getAttribute("aria-describedby"), statusId] + .filter(Boolean) + .join(" "), + ); + + this.appendChild(this.listbox); + this.appendChild(this.status); + + this.input.addEventListener("input", this.boundInput); + this.input.addEventListener("keydown", this.boundKeydown); + this.input.addEventListener("blur", this.boundBlur); + this.input.addEventListener("focus", this.boundFocus); + } + + disconnectedCallback() { + if (!this.input) { + return; + } + this.input.removeEventListener("input", this.boundInput); + this.input.removeEventListener("keydown", this.boundKeydown); + this.input.removeEventListener("blur", this.boundBlur); + this.input.removeEventListener("focus", this.boundFocus); + } + + handleInput() { + this.scheduleSearch(); + } + + handleFocus() { + if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) { + this.scheduleSearch(); + } + } + + handleBlur() { + window.setTimeout(() => this.close(), 150); + } + + handleKeydown(ev) { + if (ev.key === "Escape") { + if (!this.listbox.hidden) { + ev.preventDefault(); + this.close(); + } + return; + } + if (ev.key === "ArrowDown") { + ev.preventDefault(); + if (this.listbox.hidden) { + this.scheduleSearch(); + } else { + this.setActiveIndex(this.activeIndex + 1); + } + return; + } + if (ev.key === "ArrowUp") { + ev.preventDefault(); + if (!this.listbox.hidden) { + this.setActiveIndex(this.activeIndex - 1); + } + return; + } + if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) { + ev.preventDefault(); + this.chooseIndex(this.activeIndex); + } + } + + scheduleSearch() { + window.clearTimeout(this.searchTimer); + this.searchTimer = window.setTimeout(() => this.search(), 150); + } + + async search() { + var query = this.input.value.trim(); + var initial = !query && this.hasAttribute("suggest-on-focus"); + if (!query && !initial) { + this.close(); + this.status.textContent = ""; + return; + } + var src = this.getAttribute("src"); + if (!src) { + return; + } + + var url = new URL(src, location.href); + url.searchParams.set("q", query); + if (initial) { + url.searchParams.set("_initial", "1"); + } else { + url.searchParams.delete("_initial"); + } + var fetchId = this.fetchId + 1; + this.fetchId = fetchId; + this.status.textContent = "Searching..."; + + try { + var response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + var data = await response.json(); + if (fetchId !== this.fetchId) { + return; + } + this.results = (data && data.rows) || []; + this.render(); + } catch (_error) { + if (fetchId !== this.fetchId) { + return; + } + this.results = []; + this.close(); + this.status.textContent = "Could not load suggestions"; + } + } + + render() { + this.listbox.textContent = ""; + this.activeIndex = -1; + if (!this.results.length) { + this.close(); + this.status.textContent = "No matches"; + return; + } + + this.results.forEach((row, index) => { + var option = document.createElement("div"); + option.className = "datasette-autocomplete-option"; + option.id = this.input.id + "-option-" + index; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", "false"); + option.dataset.index = String(index); + option.dataset.value = autocompleteValueFromRow(row); + option.textContent = autocompleteLabelFromRow(row); + option.addEventListener("mousedown", (ev) => { + ev.preventDefault(); + this.chooseIndex(index); + }); + this.listbox.appendChild(option); + }); + + this.listbox.hidden = false; + this.input.setAttribute("aria-expanded", "true"); + this.status.textContent = + this.results.length + (this.results.length === 1 ? " match" : " matches"); + this.positionListbox(); + this.setActiveIndex(0); + } + + positionListbox() { + if (!this.input || !this.listbox || this.listbox.hidden) { + return; + } + + var gap = 3; + var margin = 8; + var inputRect = this.input.getBoundingClientRect(); + this.listbox.style.maxHeight = ""; + var defaultMaxHeight = parseFloat( + window.getComputedStyle(this.listbox).maxHeight, + ); + if (!Number.isFinite(defaultMaxHeight)) { + defaultMaxHeight = 256; + } + var scrollHeight = Math.ceil(this.listbox.scrollHeight); + var desiredHeight = Math.min(scrollHeight, defaultMaxHeight); + var availableBelow = Math.max( + 0, + (window.innerHeight || document.documentElement.clientHeight) - + inputRect.bottom - + gap - + margin, + ); + + this.listbox.style.left = inputRect.left + "px"; + this.listbox.style.top = inputRect.bottom + gap + "px"; + this.listbox.style.width = inputRect.width + "px"; + if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) { + this.listbox.style.maxHeight = "none"; + } else { + this.listbox.style.maxHeight = + Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) + + "px"; + } + window.addEventListener("resize", this.boundPositionListbox); + document.addEventListener("scroll", this.boundPositionListbox, true); + } + + setActiveIndex(index) { + var options = this.listbox.querySelectorAll("[role='option']"); + if (!options.length) { + this.activeIndex = -1; + this.input.removeAttribute("aria-activedescendant"); + return; + } + if (index < 0) { + index = options.length - 1; + } + if (index >= options.length) { + index = 0; + } + options.forEach((option, optionIndex) => { + option.setAttribute( + "aria-selected", + optionIndex === index ? "true" : "false", + ); + }); + this.activeIndex = index; + this.input.setAttribute("aria-activedescendant", options[index].id); + } + + chooseIndex(index) { + var row = this.results[index]; + if (!row) { + return; + } + var value = autocompleteValueFromRow(row); + var label = autocompleteLabelFromRow(row); + this.input.value = value; + this.input.dispatchEvent(new Event("change", { bubbles: true })); + this.close(); + this.status.textContent = "Selected " + label; + this.dispatchEvent( + new CustomEvent("datasette-autocomplete-select", { + bubbles: true, + detail: { + row: row, + value: value, + label: label, + }, + }), + ); + } + + close() { + if (this.listbox) { + this.listbox.hidden = true; + this.listbox.textContent = ""; + this.listbox.style.left = ""; + this.listbox.style.maxHeight = ""; + this.listbox.style.top = ""; + this.listbox.style.width = ""; + } + if (this.input) { + this.input.setAttribute("aria-expanded", "false"); + this.input.removeAttribute("aria-activedescendant"); + } + window.removeEventListener("resize", this.boundPositionListbox); + document.removeEventListener("scroll", this.boundPositionListbox, true); + this.activeIndex = -1; + } + } + + customElements.define("datasette-autocomplete", DatasetteAutocomplete); +})(); diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index 133e7cb0..198641f3 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement { diff --git a/datasette/templates/_facet_results.html b/datasette/templates/_facet_results.html index 034e9678..570bb37e 100644 --- a/datasette/templates/_facet_results.html +++ b/datasette/templates/_facet_results.html @@ -12,9 +12,9 @@
    {% for facet_value in facet_info.results %} {% if not facet_value.selected %} -
  • {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
  • +
  • {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
  • {% else %} -
  • {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }}
  • +
  • {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }}
  • {% endif %} {% endfor %} {% if facet_info.truncated %} diff --git a/datasette/templates/_query_form_styles.html b/datasette/templates/_query_form_styles.html new file mode 100644 index 00000000..cf2dd42c --- /dev/null +++ b/datasette/templates/_query_form_styles.html @@ -0,0 +1,138 @@ + diff --git a/datasette/templates/_query_results.html b/datasette/templates/_query_results.html new file mode 100644 index 00000000..5e1e2f72 --- /dev/null +++ b/datasette/templates/_query_results.html @@ -0,0 +1,20 @@ +{% if display_rows %} +
+ + + {% for column in columns %}{% endfor %} + + + + {% for row in display_rows %} + + {% for column, td in zip(columns, row) %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ td }}
+{% elif show_zero_results %} +

0 results

+{% endif %} diff --git a/datasette/templates/_sql_parameter_scripts.html b/datasette/templates/_sql_parameter_scripts.html new file mode 100644 index 00000000..9b83889e --- /dev/null +++ b/datasette/templates/_sql_parameter_scripts.html @@ -0,0 +1,307 @@ + diff --git a/datasette/templates/_sql_parameter_styles.html b/datasette/templates/_sql_parameter_styles.html new file mode 100644 index 00000000..bc6838f5 --- /dev/null +++ b/datasette/templates/_sql_parameter_styles.html @@ -0,0 +1,58 @@ + diff --git a/datasette/templates/_sql_parameters.html b/datasette/templates/_sql_parameters.html new file mode 100644 index 00000000..b5c1bde8 --- /dev/null +++ b/datasette/templates/_sql_parameters.html @@ -0,0 +1,10 @@ +{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %} +
+ {% if parameter_names %} +

Parameters

+ {% for parameter in parameter_names %} + {% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %} +

{% if sql_parameters_allow_expand|default(false) %} {% endif %}

+ {% endfor %} + {% endif %} +
diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index f47a325f..171b6442 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -22,7 +22,7 @@ {% for row in display_rows %} - + {% for cell in row %} {{ cell.value }} {% endfor %} diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index dc393c20..58e2a88c 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -19,7 +19,7 @@

GET -
+
@@ -29,7 +29,7 @@
POST - +
diff --git a/datasette/templates/base.html b/datasette/templates/base.html index e1767deb..82ab48dd 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -71,6 +71,6 @@ {% if select_templates %}{% endif %} - + diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 42b4ca0b..371f6a22 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -5,6 +5,7 @@ {% block extra_head %} {{- super() -}} {% include "_codemirror.html" %} +{% include "_sql_parameter_styles.html" %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} @@ -25,9 +26,13 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} - +

Custom SQL query

-

+

+ {% set parameter_names = [] %} + {% set parameter_values = {} %} + {% set sql_parameters_allow_expand = false %} + {% include "_sql_parameters.html" %}

@@ -53,6 +58,9 @@

  • {{ query.title or query.name }}{% if query.private %} 🔒{% endif %}
  • {% endfor %} + {% if queries_more %} +

    View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}

    + {% endif %} {% endif %} {% if tables %} @@ -87,5 +95,11 @@ {% endif %} {% include "_codemirror_foot.html" %} +{% include "_sql_parameter_scripts.html" %} + {% endblock %} diff --git a/datasette/templates/debug_autocomplete.html b/datasette/templates/debug_autocomplete.html new file mode 100644 index 00000000..84dbc14f --- /dev/null +++ b/datasette/templates/debug_autocomplete.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Debug autocomplete{% endblock %} + +{% block extra_head %} +{{ super() }} + +{% endblock %} + +{% block content %} +

    Debug autocomplete

    + + +

    + + +

    +

    + + +

    +

    + + +{% if error %} +

    {{ error }}

    +{% elif autocomplete_url %} +

    {{ database_name }} / {{ table_name }}

    + {% if label_column %} +

    Label column: {{ label_column }}

    + {% else %} +

    No label column detected. Results will use primary key values.

    + {% endif %} +
    + + + + +
    +

    Selected row

    +
    No row selected.
    + +{% else %} +

    Suggested tables

    + {% if suggestions %} +

    Showing up to five tables with a detected label column.

    + + + + + + + + + + {% for suggestion in suggestions %} + + + + + + {% endfor %} + +
    DatabaseTableLabel column
    {{ suggestion.database }}{{ suggestion.table }}{{ suggestion.label_column }}
    + {% else %} +

    No tables with detected label columns found.

    + {% endif %} +

    Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.

    +{% endif %} + +{% endblock %} diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html new file mode 100644 index 00000000..949850ed --- /dev/null +++ b/datasette/templates/execute_write.html @@ -0,0 +1,299 @@ +{% extends "base.html" %} + +{% block title %}Write to this database{% endblock %} + +{% block extra_head %} +{{- super() -}} +{% include "_codemirror.html" %} + +{% include "_execute_write_analysis_styles.html" %} +{% include "_sql_parameter_styles.html" %} +{% endblock %} + +{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %} + +{% block crumbs %} +{{ crumbs.nav(request=request, database=database) }} +{% endblock %} + +{% block content %} + +

    Write to this database

    + +

    Execute SQL to insert, update or delete rows in this database.

    + +{% if execution_message %} +

    {{ execution_message }}{% for link in execution_links %} {{ link.label }}{% endfor %}

    +{% endif %} + +{% if execute_write_returns_rows %} +

    Returned rows

    + {% if execute_write_truncated %} +

    Only the first {{ "{:,}".format(execute_write_display_rows|length) }} returned rows are shown.

    + {% endif %} + {% set columns = execute_write_columns %} + {% set display_rows = execute_write_display_rows %} + {% set show_zero_results = true %} + {% include "_query_results.html" %} +{% endif %} + +
    + {% if write_template_tables %} +
    +
    + Start with a template +

    + + + {% for operation in write_template_operations %} + + {% endfor %} +

    +
    +
    + {% else %} +

    There are no tables that you can currently edit.

    + {% endif %} + +

    + + {% set sql_parameters_section_id = "execute-write-parameters-section" %} + {% set sql_parameters_allow_expand = true %} + {% include "_sql_parameters.html" %} + +
    +

    Query operations

    + {% if analysis_error %} +

    {{ analysis_error }}

    + {% elif analysis_rows %} +
    + + + + + + + + + + + {% for row in analysis_rows %} + + + + + + + + {% endfor %} + +
    OperationDatabaseTableRequired permissionAllowed
    {{ row.operation }}{{ row.database }}{{ row.table }}{% if row.required_permission %}{{ row.required_permission }}{% endif %}{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}
    + {% else %} +

    Analysis will show each affected table and required permission.

    + {% endif %} +
    + +

    + + {{ execute_disabled_reason or "" }} + {% if save_query_url %}Save this query{% endif %} +

    +
    + + + +{% include "_codemirror_foot.html" %} +{% include "_sql_parameter_scripts.html" %} +{% include "_execute_write_analysis_scripts.html" %} + + + +{% if write_template_tables %} + +{% endif %} + +{% endblock %} diff --git a/datasette/templates/patterns.html b/datasette/templates/patterns.html index 7770f7d4..a46478a7 100644 --- a/datasette/templates/patterns.html +++ b/datasette/templates/patterns.html @@ -11,7 +11,7 @@