diff --git a/.github/workflows/deploy-branch-preview.yml b/.github/workflows/deploy-branch-preview.yml deleted file mode 100644 index e56d9c27..00000000 --- a/.github/workflows/deploy-branch-preview.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Deploy a Datasette branch preview to Vercel - -on: - workflow_dispatch: - inputs: - branch: - description: "Branch to deploy" - required: true - type: string - -jobs: - deploy-branch-preview: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - name: Install dependencies - run: | - pip install datasette-publish-vercel - - name: Deploy the preview - env: - VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }} - run: | - export BRANCH="${{ github.event.inputs.branch }}" - wget https://latest.datasette.io/fixtures.db - datasette publish vercel fixtures.db \ - --branch $BRANCH \ - --project "datasette-preview-$BRANCH" \ - --token $VERCEL_TOKEN \ - --scope datasette \ - --about "Preview of $BRANCH" \ - --about_url "https://github.com/simonw/datasette/tree/$BRANCH" diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 7349a1ab..b0640ae8 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out datasette - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -57,7 +57,7 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db - - name: And the counters writable canned query demo + - name: And the counters writable stored query demo run: | cat > plugins/counters.py <=0.2.2' \ --service "datasette-latest$SUFFIX" \ --secret $LATEST_DATASETTE_SECRET diff --git a/.github/workflows/documentation-links.yml b/.github/workflows/documentation-links.yml index a54bd83a..b8fb8aaa 100644 --- a/.github/workflows/documentation-links.yml +++ b/.github/workflows/documentation-links.yml @@ -1,6 +1,6 @@ name: Read the Docs Pull Request Preview on: - pull_request_target: + pull_request: types: - opened diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 77cce7d1..735e14e9 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -10,8 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repo - uses: actions/checkout@v4 - - uses: actions/cache@v4 + uses: actions/checkout@v6 + - uses: actions/cache@v5 name: Configure npm caching with: path: ~/.npm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2e8cea9c..87300593 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: @@ -35,7 +35,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -56,7 +56,7 @@ jobs: needs: [deploy] if: "!github.event.release.prerelease" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -92,7 +92,7 @@ jobs: needs: [deploy] if: "!github.event.release.prerelease" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build and push to Docker Hub env: DOCKER_USER: ${{ secrets.DOCKER_USER }} diff --git a/.github/workflows/push_docker_tag.yml b/.github/workflows/push_docker_tag.yml index afe8d6b2..e622ef4c 100644 --- a/.github/workflows/push_docker_tag.yml +++ b/.github/workflows/push_docker_tag.yml @@ -13,7 +13,7 @@ jobs: deploy_docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Build and push to Docker Hub env: DOCKER_USER: ${{ secrets.DOCKER_USER }} diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index d42ae96b..9a808194 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -9,7 +9,7 @@ jobs: spellcheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/stable-docs.yml b/.github/workflows/stable-docs.yml index 3119d617..59b5fbc0 100644 --- a/.github/workflows/stable-docs.yml +++ b/.github/workflows/stable-docs.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # We need all commits to find docs/ changes - name: Set up Git user diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 1b3d2f2c..c514048e 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out datasette - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/test-pyodide.yml b/.github/workflows/test-pyodide.yml index b490a9bf..5162c47a 100644 --- a/.github/workflows/test-pyodide.yml +++ b/.github/workflows/test-pyodide.yml @@ -12,7 +12,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python 3.10 uses: actions/setup-python@v6 with: @@ -20,7 +20,7 @@ jobs: cache: 'pip' cache-dependency-path: '**/pyproject.toml' - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright/ key: ${{ runner.os }}-browsers diff --git a/.github/workflows/test-sqlite-support.yml b/.github/workflows/test-sqlite-support.yml index c81a3c0b..23fce459 100644 --- a/.github/workflows/test-sqlite-support.yml +++ b/.github/workflows/test-sqlite-support.yml @@ -25,7 +25,7 @@ jobs: #"3.23.1" # 2018-04-10, before UPSERT ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a0f5477b..a1b2e9d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/.github/workflows/tmate-mac.yml b/.github/workflows/tmate-mac.yml index fcee0f21..a033cd92 100644 --- a/.github/workflows/tmate-mac.yml +++ b/.github/workflows/tmate-mac.yml @@ -10,6 +10,6 @@ jobs: build: runs-on: macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Setup tmate session uses: mxschmitt/action-tmate@v3 diff --git a/.github/workflows/tmate.yml b/.github/workflows/tmate.yml index 123f6c71..72af1eec 100644 --- a/.github/workflows/tmate.yml +++ b/.github/workflows/tmate.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Setup tmate session uses: mxschmitt/action-tmate@v3 env: diff --git a/.gitignore b/.gitignore index 12acd87e..8c058692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ build-metadata.json datasets.json +.playwright-mcp + scratchpad .vscode @@ -131,4 +133,4 @@ tests/*.dylib tests/*.so tests/*.dll -.idea \ No newline at end of file +.idea diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py new file mode 100644 index 00000000..103c616d --- /dev/null +++ b/datasette/_pytest_plugin.py @@ -0,0 +1,123 @@ +""" +Pytest plugin that automatically closes any Datasette instances constructed +during a pytest test — both in the test body and in function-scoped +fixtures. Instances constructed by session-, module-, class- or package- +scoped fixtures are left alone, because other tests in the session will +still want to use them. + +Registered as a pytest11 entry point in pyproject.toml so that downstream +projects using Datasette get the same FD-safety net for their own tests. + +Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the +equivalent ini file). +""" + +from __future__ import annotations + +import contextvars +import weakref + +import pytest + +_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar( + "datasette_active_instances", default=None +) + +_original_init = None + + +def _install_tracking(): + # datasette.app is imported lazily here rather than at module level: + # as a pytest11 entry point this module is imported during pytest + # startup, before pytest-cov starts measuring, so a module-level + # import would drag in all of datasette and make every import-time + # line in the package invisible to coverage + global _original_init + if _original_init is not None: + return + from datasette.app import Datasette + + _original_init = Datasette.__init__ + + def _tracking_init(self, *args, **kwargs): + _original_init(self, *args, **kwargs) + instances = _active_instances.get() + if instances is not None: + instances.append(weakref.ref(self)) + + Datasette.__init__ = _tracking_init + + +def pytest_configure(config): + if _enabled(config): + _install_tracking() + + +def pytest_addoption(parser): + parser.addini( + "datasette_autoclose", + help=( + "Automatically close Datasette instances created inside test " + "bodies and function-scoped fixtures (default: true)." + ), + default="true", + ) + + +def _enabled(config) -> bool: + value = config.getini("datasette_autoclose") + if isinstance(value, bool): + return value + return str(value).strip().lower() not in ("false", "0", "no", "off") + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_protocol(item, nextitem): + """Track Datasette instances across setup, call and teardown; close at end.""" + if not _enabled(item.config): + yield + return + refs: list[weakref.ref] = [] + token = _active_instances.set(refs) + try: + yield + finally: + _active_instances.reset(token) + for ref in reversed(refs): + ds = ref() + if ds is None: + continue + try: + ds.close() + except Exception as e: + item.warn( + pytest.PytestUnraisableExceptionWarning( + f"Error closing Datasette instance: {e!r}" + ) + ) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_fixture_setup(fixturedef, request): + """Exempt instances created by non-function-scoped fixtures. + + Session-, module-, class- and package-scoped fixtures produce Datasette + instances that must survive beyond the current test — other tests in + the session will still use them. When such a fixture creates one or + more Datasette instances during its setup, we snapshot the tracking + list before the fixture runs and subtract off any instances that were + added during its setup, so they don't get closed at test teardown. + """ + refs = _active_instances.get() + if refs is None: + yield + return + before_ids = {id(ref) for ref in refs} + yield + if fixturedef.scope != "function": + new_refs = [ref for ref in refs if id(ref) not in before_ids] + for new_ref in new_refs: + try: + refs.remove(new_ref) + except ValueError: + pass diff --git a/datasette/app.py b/datasette/app.py index 0f417ec9..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, @@ -58,16 +77,18 @@ from .views.special import ( AllowedResourcesView, PermissionRulesView, PermissionCheckView, - TablesView, + JumpView, InstanceSchemaView, DatabaseSchemaView, TableSchemaView, ) from .views.table import ( + TableAutocompleteView, TableInsertView, TableUpsertView, TableSetColumnTypeView, TableDropView, + TableFragmentView, table_view, ) from .views.row import RowView, RowDeleteView, RowUpdateView @@ -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, @@ -326,6 +356,7 @@ class Datasette: default_deny=False, ): self._startup_invoked = False + self._closed = False assert config_dir is None or isinstance( config_dir, Path ), "config_dir= should be a pathlib.Path" @@ -570,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: @@ -613,15 +647,36 @@ class Datasette: "select database_name, schema_version from catalog_databases" ) } - # Delete stale entries for databases that are no longer attached - stale_databases = set(current_schema_versions.keys()) - set( - self.databases.keys() + catalog_table_names = ( + "catalog_columns", + "catalog_foreign_keys", + "catalog_indexes", + "catalog_views", + "catalog_tables", + "catalog_databases", ) - for stale_db_name in stale_databases: - await internal_db.execute_write( - "DELETE FROM catalog_databases WHERE database_name = ?", - [stale_db_name], + # Delete stale entries for databases that are no longer attached + catalog_database_names = set(current_schema_versions.keys()) + for table in catalog_table_names[:-1]: + catalog_database_names.update( + row["database_name"] + for row in await internal_db.execute( + "select distinct database_name from {}".format(table) + ) + if row["database_name"] is not None ) + stale_databases = catalog_database_names - set(self.databases.keys()) + if stale_databases: + + def delete_stale_database_catalog(conn): + for stale_db_name in stale_databases: + for table in catalog_table_names: + conn.execute( + "DELETE FROM {} WHERE database_name = ?".format(table), + [stale_db_name], + ) + + await internal_db.execute_write_fn(delete_stale_database_catalog) for database_name, db in self.databases.items(): schema_version = (await db.execute("PRAGMA schema_version")).first()[0] # Compare schema versions to see if we should skip it @@ -709,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): @@ -834,6 +890,33 @@ class Datasette: new_databases.pop(name) self.databases = new_databases + def close(self): + """Release all resources held by this Datasette instance. + + Closes every attached Database (including the internal database), + shuts down the executor, and unlinks the temporary file used for + the internal database if one was created. Idempotent and one-way. + """ + if self._closed: + return + self._closed = True + first_exception = None + dbs = list(self.databases.values()) + [self._internal_database] + for db in dbs: + try: + db.close() + except Exception as e: + if first_exception is None: + first_exception = e + if self.executor is not None: + try: + self.executor.shutdown(wait=True, cancel_futures=True) + except Exception as e: + if first_exception is None: + first_exception = e + if first_exception is not None: + raise first_exception + def setting(self, key): return self._settings.get(key, None) @@ -958,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): @@ -1170,36 +1427,24 @@ class Datasette: return db_plugin_config + def static_hash(self, filename): + if not hasattr(self, "_static_hashes"): + self._static_hashes = {} + path = os.path.join(str(app_root), "datasette/static", filename) + signature = (os.path.getmtime(path), os.path.getsize(path)) + cached = self._static_hashes.get(filename) + if cached and cached["signature"] == signature: + return cached["hash"] + with open(path) as fp: + static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6] + self._static_hashes[filename] = { + "signature": signature, + "hash": static_hash, + } + return static_hash + def app_css_hash(self): - if not hasattr(self, "_app_css_hash"): - with open(os.path.join(str(app_root), "datasette/static/app.css")) as fp: - self._app_css_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[ - :6 - ] - return self._app_css_hash - - async def get_canned_queries(self, database_name, actor): - queries = {} - for more_queries in pm.hook.canned_queries( - datasette=self, - database=database_name, - actor=actor, - ): - more_queries = await await_me_maybe(more_queries) - queries.update(more_queries or {}) - # Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}} - for key in queries: - if not isinstance(queries[key], dict): - queries[key] = {"sql": queries[key]} - # Also make sure "name" is available: - queries[key]["name"] = key - return queries - - async def get_canned_query(self, database_name, query_name, actor): - queries = await self.get_canned_queries(database_name, actor) - query = queries.get(query_name) - if query: - return query + return self.static_hash("app.css") def _prepare_connection(self, conn, database): conn.row_factory = sqlite3.Row @@ -1584,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, @@ -1696,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", @@ -1992,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, @@ -2173,8 +2503,12 @@ class Datasette: r"/-/api$", ) add_route( - TablesView.as_view(self), - r"/-/tables(\.(?Pjson))?$", + JumpView.as_view(self), + r"/-/jump(\.(?Pjson))?$", + ) + add_route( + GlobalQueryListView.as_view(self), + r"/-/queries(\.(?Pjson))?$", ) add_route( InstanceSchemaView.as_view(self), @@ -2212,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$", @@ -2221,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+))?$", @@ -2249,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$", @@ -2310,10 +2696,13 @@ class Datasette: if not database.is_mutable: await database.table_counts(limit=60 * 60 * 1000) + async def _close_on_shutdown(): + self.close() + asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self) if self.setting("trace_debug"): asgi = AsgiTracer(asgi) - asgi = AsgiLifespan(asgi) + asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown]) asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup]) for wrapper in pm.hook.asgi_wrapper(datasette=self): asgi = wrapper(asgi) @@ -2332,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 @@ -2595,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 8b824462..e7fe1ed9 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -1,9 +1,9 @@ import asyncio import atexit from collections import namedtuple +import inspect import os from pathlib import Path -import janus import queue import sqlite_utils import sys @@ -25,14 +25,24 @@ 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")) +class DatasetteClosedError(RuntimeError): + """Raised when using a Datasette or Database instance after close().""" + + +_SHUTDOWN = object() + + class Database: # For table counts stop at this many rows: count_limit = 10000 @@ -75,6 +85,9 @@ class Database: self._cached_table_counts = None self._write_thread = None self._write_queue = None + self._closed = False + self._pending_execute_futures = set() + self._pending_execute_futures_lock = threading.Lock() # These are used when in non-threaded mode: self._read_connection = None self._write_connection = None @@ -83,6 +96,16 @@ class Database: if not is_temp_disk: self.mode = mode + def _check_not_closed(self): + if self._closed: + raise DatasetteClosedError( + "Database {!r} has been closed".format(self.name) + ) + + def _remove_pending_execute_future(self, future): + with self._pending_execute_futures_lock: + self._pending_execute_futures.discard(future) + @property def cached_table_counts(self): if self._cached_table_counts is not None: @@ -148,9 +171,62 @@ class Database: return conn def close(self): - # Close all connections - useful to avoid running out of file handles in tests + """Release all resources held by this database. + + Idempotent. After close() further calls to execute()/execute_fn()/ + execute_write()/execute_write_fn() raise DatasetteClosedError. + """ + if self._closed: + return + with self._pending_execute_futures_lock: + if self._closed: + return + self._closed = True + pending_execute_futures = tuple(self._pending_execute_futures) + # Shut down the write thread, if any, via a sentinel. The thread + # drains any writes already queued before the sentinel and then + # closes its own write connection and returns. + write_thread = self._write_thread + if write_thread is not None and self._write_queue is not None: + self._write_queue.put(_SHUTDOWN) + write_thread.join(timeout=10) + if write_thread.is_alive(): + sys.stderr.write( + "Datasette: write thread for {!r} did not exit within 10s\n".format( + self.name + ) + ) + sys.stderr.flush() + for future in pending_execute_futures: + try: + future.result() + except Exception: + pass + # Close anything still tracked in _all_file_connections for connection in self._all_file_connections: - connection.close() + try: + connection.close() + except Exception: + pass + self._all_file_connections = [] + # Drop per-thread cached read connections we can reach + try: + delattr(connections, self._thread_local_id) + except AttributeError: + pass + # Close non-threaded-mode cached connections if still open + if self._read_connection is not None: + try: + self._read_connection.close() + except Exception: + pass + self._read_connection = None + if self._write_connection is not None: + try: + self._write_connection.close() + except Exception: + pass + self._write_connection = None if self.is_temp_disk: self._cleanup_temp_file() @@ -162,15 +238,32 @@ 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) return results async def execute_write_script(self, sql, block=True, request=None): + self._check_not_closed() + def _inner(conn): return conn.executescript(sql) @@ -181,6 +274,8 @@ class Database: return results async def execute_write_many(self, sql, params_seq, block=True, request=None): + self._check_not_closed() + def _inner(conn): count = 0 @@ -202,13 +297,15 @@ class Database: return results async def execute_isolated_fn(self, fn): - # Open a new connection just for the duration of this function + self._check_not_closed() + # 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: @@ -216,12 +313,28 @@ 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() pending_events = [] def track_event(event): @@ -248,13 +361,16 @@ class Database: else: # For non-blocking writes, spawn a background task to # dispatch events after the write thread completes - task_id, reply_queue = result + task_id, reply_future = result async def _dispatch_events_after_write(): - write_result = await reply_queue.async_q.get() - if not isinstance(write_result, Exception): - for event in pending_events: - await self.ds.track_event(event) + try: + await reply_future + except Exception: + # if the write failed, don't emit success events + return + for event in pending_events: + await self.ds.track_event(event) asyncio.ensure_future(_dispatch_events_after_write()) result = task_id @@ -263,15 +379,21 @@ class Database: def _wrap_fn_with_hooks(self, fn, request, transaction, track_event): from .plugins import pm - # Wrap fn so it receives track_event if its signature supports it + # Wrap fn so it receives track_event if its signature supports it. + # Historically fn was called positionally, so any single-parameter + # name (conn, connection, db, ...) worked. Preserve that by only + # switching to keyword dependency injection when the callback + # explicitly opts in by declaring a `track_event` parameter. original_fn = fn - def fn_with_track_event(conn): - return call_with_supported_arguments( - original_fn, conn=conn, track_event=track_event - ) + if "track_event" in inspect.signature(original_fn).parameters: - fn = fn_with_track_event + def fn_with_track_event(conn): + return call_with_supported_arguments( + original_fn, conn=conn, track_event=track_event + ) + + fn = fn_with_track_event wrappers = pm.hook.write_wrapper( datasette=self.ds, @@ -302,18 +424,15 @@ class Database: ) self._write_thread.start() task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") - reply_queue = janus.Queue() + loop = asyncio.get_running_loop() + reply_future = loop.create_future() self._write_queue.put( - WriteTask(fn, task_id, reply_queue, isolated_connection, transaction) + WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction) ) if block: - result = await reply_queue.async_q.get() - if isinstance(result, Exception): - raise result - else: - return result + return await reply_future else: - return task_id, reply_queue + return task_id, reply_future def _execute_writes(self): # Infinite looping thread that protects the single write connection @@ -327,17 +446,22 @@ class Database: conn_exception = e while True: task = self._write_queue.get() + if task is _SHUTDOWN: + if conn is not None: + try: + conn.close() + except Exception: + pass + return + exception = None + result = None if conn_exception is not None: - result = conn_exception - else: - if task.isolated_connection: + exception = conn_exception + elif task.isolated_connection: + try: isolated_connection = self.connect(write=True) try: result = task.fn(isolated_connection) - except Exception as e: - sys.stderr.write("{}\n".format(e)) - sys.stderr.flush() - result = e finally: isolated_connection.close() try: @@ -345,20 +469,25 @@ class Database: except ValueError: # Was probably a memory connection pass - else: - try: - if task.transaction: - with conn: - result = task.fn(conn) - else: + except Exception as e: + sys.stderr.write("{}\n".format(e)) + sys.stderr.flush() + exception = e + else: + try: + if task.transaction: + with conn: result = task.fn(conn) - except Exception as e: - sys.stderr.write("{}\n".format(e)) - sys.stderr.flush() - result = e - task.reply_queue.sync_q.put(result) + else: + result = task.fn(conn) + except Exception as e: + sys.stderr.write("{}\n".format(e)) + sys.stderr.flush() + exception = e + _deliver_write_result(task, result, exception) async def execute_fn(self, fn): + self._check_not_closed() if self.ds.executor is None: # non-threaded mode if self._read_connection is None: @@ -375,9 +504,12 @@ class Database: setattr(connections, self._thread_local_id, conn) return fn(conn) - return await asyncio.get_event_loop().run_in_executor( - self.ds.executor, in_thread - ) + with self._pending_execute_futures_lock: + self._check_not_closed() + future = self.ds.executor.submit(in_thread) + self._pending_execute_futures.add(future) + future.add_done_callback(self._remove_pending_execute_future) + return await asyncio.wrap_future(future) async def execute( self, @@ -389,6 +521,7 @@ class Database: log_sql_errors=True, ): """Executes sql against db_name in a thread""" + self._check_not_closed() page_size = page_size or self.ds.page_size def sql_operation_in_thread(conn): @@ -594,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: @@ -772,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 @@ -792,16 +849,45 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event): class WriteTask: - __slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction") + __slots__ = ( + "fn", + "task_id", + "loop", + "reply_future", + "isolated_connection", + "transaction", + ) - def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction): + def __init__( + self, fn, task_id, loop, reply_future, isolated_connection, transaction + ): self.fn = fn self.task_id = task_id - self.reply_queue = reply_queue + self.loop = loop + self.reply_future = reply_future self.isolated_connection = isolated_connection self.transaction = transaction +def _deliver_write_result(task, result, exception): + # Called from the write thread. Delivers the result back to the + # awaiting coroutine on its event loop via call_soon_threadsafe. + def _set(): + if task.reply_future.done(): + # Awaiter was cancelled; nothing to do. + return + if exception is not None: + task.reply_future.set_exception(exception) + else: + task.reply_future.set_result(result) + + try: + task.loop.call_soon_threadsafe(_set) + except RuntimeError: + # Event loop has been closed; the awaiter is gone. + pass + + class QueryInterrupted(Exception): def __init__(self, e, sql, params): self.e = e @@ -816,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 new file mode 100644 index 00000000..8ea3c287 --- /dev/null +++ b/datasette/default_debug_menu.py @@ -0,0 +1,80 @@ +from datasette import hookimpl +from datasette.jump import JumpSQL + +DEBUG_MENU_ITEMS = ( + ( + "/-/databases", + "Databases", + "List of databases known to this Datasette instance.", + ), + ( + "/-/plugins", + "Installed plugins", + "Review loaded plugins, their versions and their registered hooks.", + ), + ( + "/-/versions", + "Version info", + "Check the Python, SQLite and dependency versions used by this server.", + ), + ( + "/-/settings", + "Settings", + "Inspect the active Datasette settings and configuration values.", + ), + ( + "/-/permissions", + "Debug permissions", + "Test permission checks for actors, actions and resources.", + ), + ( + "/-/messages", + "Debug messages", + "Try out temporary flash messages shown to users.", + ), + ( + "/-/allow-debug", + "Debug allow rules", + "Explore how allow blocks match actors against permission rules.", + ), + ( + "/-/debug/autocomplete", + "Debug autocomplete", + "Try out table autocomplete against a detected label column.", + ), + ( + "/-/threads", + "Debug threads", + "Inspect worker threads and database tasks.", + ), + ( + "/-/actor", + "Debug actor", + "View the actor object for the current signed-in user.", + ), + ( + "/-/patterns", + "Pattern portfolio", + "Browse Datasette UI patterns.", + ), +) + + +@hookimpl +def jump_items_sql(datasette, actor, request): + async def inner(): + if not await datasette.allowed(action="debug-menu", actor=actor): + return [] + + return [ + JumpSQL.menu_item( + label=label, + url=datasette.urls.path(path), + description=description, + search_text=f"debug {label} {description}", + item_type="debug", + ) + for path, label, description in DEBUG_MENU_ITEMS + ] + + return inner diff --git a/datasette/default_jump_items.py b/datasette/default_jump_items.py new file mode 100644 index 00000000..d215e7ec --- /dev/null +++ b/datasette/default_jump_items.py @@ -0,0 +1,82 @@ +from datasette import hookimpl +from datasette.jump import JumpSQL + + +@hookimpl +def jump_items_sql(datasette, actor, request): + async def inner(): + database_sql, database_params = await datasette.allowed_resources_sql( + action="view-database", actor=actor + ) + table_sql, table_params = await datasette.allowed_resources_sql( + action="view-table", actor=actor + ) + query_sql, query_params = await datasette.allowed_resources_sql( + action="view-query", actor=actor + ) + return [ + JumpSQL( + sql=f""" + WITH allowed_databases AS ( + {database_sql} + ) + SELECT + 'database' AS type, + parent AS label, + NULL AS description, + json_object( + 'method', 'database', + 'database', parent + ) AS url, + parent AS search_text, + NULL AS display_name + FROM allowed_databases + """, + params=database_params, + ), + JumpSQL( + sql=f""" + WITH allowed_tables AS ( + {table_sql} + ) + SELECT + CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type, + allowed_tables.parent || ': ' || allowed_tables.child AS label, + NULL AS description, + json_object( + 'method', 'table', + 'database', allowed_tables.parent, + 'table', allowed_tables.child + ) AS url, + allowed_tables.parent || ' ' || allowed_tables.child AS search_text, + NULL AS display_name + FROM allowed_tables + LEFT JOIN catalog_views + ON catalog_views.database_name = allowed_tables.parent + AND catalog_views.view_name = allowed_tables.child + """, + params=table_params, + ), + JumpSQL( + sql=f""" + WITH allowed_queries AS ( + {query_sql} + ) + SELECT + 'query' AS type, + allowed_queries.parent || ': ' || allowed_queries.child AS label, + NULL AS description, + json_object( + 'method', 'query', + 'database', allowed_queries.parent, + 'query', allowed_queries.child + ) AS url, + allowed_queries.parent || ' ' || allowed_queries.child AS search_text, + NULL AS display_name + FROM allowed_queries + """, + params=query_params, + ), + ] + + return inner diff --git a/datasette/default_menu_links.py b/datasette/default_menu_links.py deleted file mode 100644 index 85032387..00000000 --- a/datasette/default_menu_links.py +++ /dev/null @@ -1,41 +0,0 @@ -from datasette import hookimpl - - -@hookimpl -def menu_links(datasette, actor): - async def inner(): - if not await datasette.allowed(action="debug-menu", actor=actor): - return [] - - return [ - {"href": datasette.urls.path("/-/databases"), "label": "Databases"}, - { - "href": datasette.urls.path("/-/plugins"), - "label": "Installed plugins", - }, - { - "href": datasette.urls.path("/-/versions"), - "label": "Version info", - }, - { - "href": datasette.urls.path("/-/settings"), - "label": "Settings", - }, - { - "href": datasette.urls.path("/-/permissions"), - "label": "Debug permissions", - }, - { - "href": datasette.urls.path("/-/messages"), - "label": "Debug messages", - }, - { - "href": datasette.urls.path("/-/allow-debug"), - "label": "Debug allow rules", - }, - {"href": datasette.urls.path("/-/threads"), "label": "Debug threads"}, - {"href": datasette.urls.path("/-/actor"), "label": "Debug actor"}, - {"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"}, - ] - - return inner 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/fixtures.py b/datasette/fixtures.py new file mode 100644 index 00000000..7c85e16a --- /dev/null +++ b/datasette/fixtures.py @@ -0,0 +1,415 @@ +from datasette.utils.sqlite import sqlite3 +from datasette.utils import documented +import itertools +import random +import string + +__all__ = [ + "EXTRA_DATABASE_SQL", + "TABLES", + "TABLE_PARAMETERIZED_SQL", + "generate_compound_rows", + "generate_sortable_rows", + "populate_extra_database", + "populate_fixture_database", + "write_extra_database", + "write_fixture_database", +] + + +def generate_compound_rows(num): + """Generate rows for the compound_three_primary_keys fixture table.""" + for a, b, c in itertools.islice( + itertools.product(string.ascii_lowercase, repeat=3), num + ): + yield a, b, c, f"{a}-{b}-{c}" + + +def generate_sortable_rows(num): + """Generate rows for the sortable fixture table.""" + rand = random.Random(42) + for a, b in itertools.islice( + itertools.product(string.ascii_lowercase, repeat=2), num + ): + yield { + "pk1": a, + "pk2": b, + "content": f"{a}-{b}", + "sortable": rand.randint(-100, 100), + "sortable_with_nulls": rand.choice([None, rand.random(), rand.random()]), + "sortable_with_nulls_2": rand.choice([None, rand.random(), rand.random()]), + "text": rand.choice(["$null", "$blah"]), + } + + +TABLES = ( + """ +CREATE TABLE simple_primary_key ( + id integer primary key, + content text +); + +CREATE TABLE primary_key_multiple_columns ( + id varchar(30) primary key, + content text, + content2 text +); + +CREATE TABLE primary_key_multiple_columns_explicit_label ( + id varchar(30) primary key, + content text, + content2 text +); + +CREATE TABLE compound_primary_key ( + pk1 varchar(30), + pk2 varchar(30), + content text, + PRIMARY KEY (pk1, pk2) +); + +INSERT INTO compound_primary_key VALUES ('a', 'b', 'c'); +INSERT INTO compound_primary_key VALUES ('a/b', '.c-d', 'c'); +INSERT INTO compound_primary_key VALUES ('d', 'e', 'RENDER_CELL_DEMO'); + +CREATE TABLE compound_three_primary_keys ( + pk1 varchar(30), + pk2 varchar(30), + pk3 varchar(30), + content text, + PRIMARY KEY (pk1, pk2, pk3) +); +CREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content); + +CREATE TABLE foreign_key_references ( + pk varchar(30) primary key, + foreign_key_with_label integer, + foreign_key_with_blank_label integer, + foreign_key_with_no_label varchar(30), + foreign_key_compound_pk1 varchar(30), + foreign_key_compound_pk2 varchar(30), + FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id), + FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id), + FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id) + FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2) +); + +CREATE TABLE sortable ( + pk1 varchar(30), + pk2 varchar(30), + content text, + sortable integer, + sortable_with_nulls real, + sortable_with_nulls_2 real, + text text, + PRIMARY KEY (pk1, pk2) +); + +CREATE TABLE no_primary_key ( + content text, + a text, + b text, + c text +); + +CREATE TABLE [123_starts_with_digits] ( + content text +); + +CREATE VIEW paginated_view AS + SELECT + content, + '- ' || content || ' -' AS content_extra + FROM no_primary_key; + +CREATE TABLE "Table With Space In Name" ( + pk varchar(30) primary key, + content text +); + +CREATE TABLE "table/with/slashes.csv" ( + pk varchar(30) primary key, + content text +); + +CREATE TABLE "complex_foreign_keys" ( + pk varchar(30) primary key, + f1 integer, + f2 integer, + f3 integer, + FOREIGN KEY ("f1") REFERENCES [simple_primary_key](id), + FOREIGN KEY ("f2") REFERENCES [simple_primary_key](id), + FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id) +); + +CREATE TABLE "custom_foreign_key_label" ( + pk varchar(30) primary key, + foreign_key_with_custom_label text, + FOREIGN KEY ("foreign_key_with_custom_label") REFERENCES [primary_key_multiple_columns_explicit_label](id) +); + +CREATE TABLE tags ( + tag TEXT PRIMARY KEY +); + +CREATE TABLE searchable ( + pk integer primary key, + text1 text, + text2 text, + [name with . and spaces] text +); + +CREATE TABLE searchable_tags ( + searchable_id integer, + tag text, + PRIMARY KEY (searchable_id, tag), + FOREIGN KEY (searchable_id) REFERENCES searchable(pk), + FOREIGN KEY (tag) REFERENCES tags(tag) +); + +INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther'); +INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma'); + +INSERT INTO tags VALUES ("canine"); +INSERT INTO tags VALUES ("feline"); + +INSERT INTO searchable_tags (searchable_id, tag) VALUES + (1, "feline"), + (2, "canine") +; + +CREATE VIRTUAL TABLE "searchable_fts" + USING FTS5 (text1, text2, [name with . and spaces], content="searchable", content_rowid="pk"); +INSERT INTO "searchable_fts" (searchable_fts) VALUES ('rebuild'); + +CREATE TABLE [select] ( + [group] text, + [having] text, + [and] text, + [json] text +); +INSERT INTO [select] VALUES ('group', 'having', 'and', + '{"href": "http://example.com/", "label":"Example"}' +); + +CREATE TABLE infinity ( + value REAL +); +INSERT INTO infinity VALUES + (1e999), + (-1e999), + (1.5) +; + +CREATE TABLE facet_cities ( + id integer primary key, + name text +); +INSERT INTO facet_cities (id, name) VALUES + (1, 'San Francisco'), + (2, 'Los Angeles'), + (3, 'Detroit'), + (4, 'Memnonia') +; + +CREATE TABLE facetable ( + pk integer primary key, + created text, + planet_int integer, + on_earth integer, + state text, + _city_id integer, + _neighborhood text, + tags text, + complex_array text, + distinct_some_null, + n text, + FOREIGN KEY ("_city_id") REFERENCES [facet_cities](id) +); +INSERT INTO facetable + (created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n) +VALUES + ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Mission', '["tag1", "tag2"]', '[{"foo": "bar"}]', 'one', 'n1'), + ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Dogpatch', '["tag1", "tag3"]', '[]', 'two', 'n2'), + ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'SOMA', '[]', '[]', null, null), + ("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Tenderloin', '[]', '[]', null, null), + ("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Bernal Heights', '[]', '[]', null, null), + ("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Hayes Valley', '[]', '[]', null, null), + ("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Hollywood', '[]', '[]', null, null), + ("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Downtown', '[]', '[]', null, null), + ("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Los Feliz', '[]', '[]', null, null), + ("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Koreatown', '[]', '[]', null, null), + ("2019-01-16 08:00:00", 1, 1, 'MI', 3, 'Downtown', '[]', '[]', null, null), + ("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Greektown', '[]', '[]', null, null), + ("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Corktown', '[]', '[]', null, null), + ("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Mexicantown', '[]', '[]', null, null), + ("2019-01-17 08:00:00", 2, 0, 'MC', 4, 'Arcadia Planitia', '[]', '[]', null, null) +; + +CREATE TABLE binary_data ( + data BLOB +); + +-- Many 2 Many demo: roadside attractions! + +CREATE TABLE roadside_attractions ( + pk integer primary key, + name text, + address text, + url text, + latitude real, + longitude real +); +INSERT INTO roadside_attractions VALUES ( + 1, "The Mystery Spot", "465 Mystery Spot Road, Santa Cruz, CA 95065", "https://www.mysteryspot.com/", + 37.0167, -122.0024 +); +INSERT INTO roadside_attractions VALUES ( + 2, "Winchester Mystery House", "525 South Winchester Boulevard, San Jose, CA 95128", "https://winchestermysteryhouse.com/", + 37.3184, -121.9511 +); +INSERT INTO roadside_attractions VALUES ( + 3, "Burlingame Museum of PEZ Memorabilia", "214 California Drive, Burlingame, CA 94010", null, + 37.5793, -122.3442 +); +INSERT INTO roadside_attractions VALUES ( + 4, "Bigfoot Discovery Museum", "5497 Highway 9, Felton, CA 95018", "https://www.bigfootdiscoveryproject.com/", + 37.0414, -122.0725 +); + +CREATE TABLE attraction_characteristic ( + pk integer primary key, + name text +); +INSERT INTO attraction_characteristic VALUES ( + 1, "Museum" +); +INSERT INTO attraction_characteristic VALUES ( + 2, "Paranormal" +); + +CREATE TABLE roadside_attraction_characteristics ( + attraction_id INTEGER REFERENCES roadside_attractions(pk), + characteristic_id INTEGER REFERENCES attraction_characteristic(pk) +); +INSERT INTO roadside_attraction_characteristics VALUES ( + 1, 2 +); +INSERT INTO roadside_attraction_characteristics VALUES ( + 2, 2 +); +INSERT INTO roadside_attraction_characteristics VALUES ( + 4, 2 +); +INSERT INTO roadside_attraction_characteristics VALUES ( + 3, 1 +); +INSERT INTO roadside_attraction_characteristics VALUES ( + 4, 1 +); + +INSERT INTO simple_primary_key VALUES (1, 'hello'); +INSERT INTO simple_primary_key VALUES (2, 'world'); +INSERT INTO simple_primary_key VALUES (3, ''); +INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO'); +INSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC'); + +INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world'); +INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2'); + +INSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b'); +INSERT INTO foreign_key_references VALUES (2, null, null, null, null, null); + +INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1); +INSERT INTO custom_foreign_key_label VALUES (1, 1); + +INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey'); + +CREATE VIEW simple_view AS + SELECT content, upper(content) AS upper_content FROM simple_primary_key; + +CREATE VIEW searchable_view AS + SELECT * from searchable; + +CREATE VIEW searchable_view_configured_by_metadata AS + SELECT * from searchable; + +""" + + "\n".join( + [ + 'INSERT INTO no_primary_key VALUES ({i}, "a{i}", "b{i}", "c{i}");'.format( + i=i + 1 + ) + for i in range(201) + ] + ) + + '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n' + + "\n".join( + [ + 'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format( + a=a, b=b, c=c, content=content + ) + for a, b, c, content in generate_compound_rows(1001) + ] + ) + + "\n".join(["""INSERT INTO sortable VALUES ( + "{pk1}", "{pk2}", "{content}", {sortable}, + {sortable_with_nulls}, {sortable_with_nulls_2}, "{text}"); + """.format(**row).replace("None", "null") for row in generate_sortable_rows(201)]) +) + +TABLE_PARAMETERIZED_SQL = [ + ("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]), + ("insert into binary_data (data) values (?);", [b"\x15\x1c\x03\xc7\xad\x05\xfe"]), + ("insert into binary_data (data) values (null);", []), +] + +EXTRA_DATABASE_SQL = """ +CREATE TABLE searchable ( + pk integer primary key, + text1 text, + text2 text +); + +CREATE VIEW searchable_view AS SELECT * FROM searchable; + +INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog'); +INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel'); + +CREATE VIRTUAL TABLE "searchable_fts" + USING FTS3 (text1, text2, content="searchable"); +INSERT INTO "searchable_fts" (rowid, text1, text2) + SELECT rowid, text1, text2 FROM searchable; +""" + + +@documented(label="datasette_fixtures_populate_fixture_database") +def populate_fixture_database(conn): + """Populate a SQLite connection with Datasette's test fixture tables.""" + conn.executescript(TABLES) + for sql, params in TABLE_PARAMETERIZED_SQL: + with conn: + conn.execute(sql, params) + + +def populate_extra_database(conn): + """Populate a SQLite connection with the extra database used in tests.""" + conn.executescript(EXTRA_DATABASE_SQL) + + +def write_fixture_database(db_filename): + """Write Datasette's test fixture tables to a SQLite database file.""" + conn = sqlite3.connect(db_filename) + try: + populate_fixture_database(conn) + finally: + conn.close() + + +def write_extra_database(db_filename): + """Write the extra test database tables to a SQLite database file.""" + conn = sqlite3.connect(db_filename) + try: + populate_extra_database(conn) + finally: + conn.close() diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 27e20bd4..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""" @@ -157,34 +152,39 @@ def menu_links(datasette, actor, request): """Links for the navigation menu""" +@hookspec +def jump_items_sql(datasette, actor, request): + """SQL fragments for extra items in the jump menu""" + + @hookspec def row_actions(datasette, actor, request, database, table, row): - """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 @@ -228,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/jump.py b/datasette/jump.py new file mode 100644 index 00000000..d138e827 --- /dev/null +++ b/datasette/jump.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + + +@dataclass +class JumpSQL: + sql: str + params: dict[str, Any] | None = None + database: str | None = None + + @classmethod + def menu_item( + cls, + *, + label: str, + url: str, + description: str = "Menu item", + search_text: str | None = None, + display_name: str | None = None, + item_type: str = "menu", + ) -> "JumpSQL": + if search_text is None: + search_text = " ".join( + text for text in (label, display_name, description) if text is not None + ) + return cls( + sql=""" + SELECT + :type AS type, + :label AS label, + :description AS description, + :url AS url, + :search_text AS search_text, + :display_name AS display_name + """, + params={ + "type": item_type, + "label": label, + "description": description, + "url": url, + "search_text": search_text, + "display_name": display_name, + }, + ) + + +_PARAM_RE = re.compile(r"(? 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 b01b386c..f0fbc7f8 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -28,7 +28,10 @@ DEFAULT_PLUGINS = ( "datasette.default_column_types", "datasette.default_magic_parameters", "datasette.blob_renderer", - "datasette.default_menu_links", + "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 26717c43..5fe4502d 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -362,6 +362,32 @@ form.nav-menu-logout { .nav-menu-inner a { display: block; } +.nav-menu-inner button.button-as-link { + display: block; + width: 100%; + text-align: left; + font: inherit; +} +.nav-menu-inner .keyboard-shortcut { + float: right; + box-sizing: border-box; + min-width: 1.4em; + margin-left: 0.75rem; + padding: 0 0.35em; + border: 1px solid rgba(255,255,244,0.6); + border-radius: 3px; + background: rgba(255,255,244,0.12); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + line-height: 1.35; + text-align: center; + text-decoration: none; +} +@media (max-width: 640px) { + .nav-menu-inner .keyboard-shortcut { + display: none; + } +} /* Table/database actions menu */ .page-action-menu { @@ -680,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; @@ -761,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; @@ -818,7 +849,8 @@ dialog.mobile-column-actions-dialog::backdrop { } .mobile-column-actions-dialog .list-wrap { - flex: 1; + flex: 1 1 auto; + min-height: 0; overflow-y: auto; overflow-x: hidden; position: relative; @@ -988,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; @@ -1077,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; } @@ -1160,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; @@ -1207,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) { @@ -1261,6 +1956,10 @@ dialog.set-column-type-dialog::backdrop { font-size: 0.8em; } + .row-inline-actions { + margin-bottom: 0.35rem; + } + .select-wrapper { width: 100px; } @@ -1271,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; @@ -1307,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 { @@ -1352,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; @@ -1382,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 { - +
+

Jump to

+

Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

+
+
-
+
Navigate Enter Select @@ -189,6 +316,7 @@ class NavigationSearch extends HTMLElement { setupEventListeners() { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); + const closeButton = this.shadowRoot.querySelector(".close-search"); const resultsContainer = this.shadowRoot.querySelector(".results-container"); @@ -200,6 +328,17 @@ class NavigationSearch extends HTMLElement { } }); + document.addEventListener("click", (e) => { + const trigger = e.target.closest("[data-navigation-search-open]"); + if (trigger) { + e.preventDefault(); + const details = trigger.closest("details"); + const restoreTarget = details?.querySelector("summary") || trigger; + details?.removeAttribute("open"); + this.openMenu(restoreTarget); + } + }); + // Input event input.addEventListener("input", (e) => { this.handleSearch(e.target.value); @@ -221,8 +360,19 @@ class NavigationSearch extends HTMLElement { } }); + closeButton.addEventListener("click", () => { + this.closeMenu(); + }); + // Click on result item resultsContainer.addEventListener("click", (e) => { + const clearRecent = e.target.closest("[data-clear-recent-items]"); + if (clearRecent) { + e.preventDefault(); + this.clearRecentItems(); + return; + } + const item = e.target.closest(".result-item"); if (item) { const index = parseInt(item.dataset.index); @@ -237,6 +387,15 @@ class NavigationSearch extends HTMLElement { } }); + dialog.addEventListener("cancel", (e) => { + e.preventDefault(); + this.closeMenu(); + }); + + dialog.addEventListener("close", () => { + this.onMenuClosed(); + }); + // Initial load this.loadInitialData(); } @@ -251,6 +410,106 @@ class NavigationSearch extends HTMLElement { ); } + setElementAttribute(element, name, value) { + if (!element) { + return; + } + if (typeof element.setAttribute === "function") { + element.setAttribute(name, value); + } else { + element[name] = String(value); + } + } + + removeElementAttribute(element, name) { + if (!element) { + return; + } + if (typeof element.removeAttribute === "function") { + element.removeAttribute(name); + } else { + delete element[name]; + } + } + + focusRestoreTarget(trigger) { + if (trigger && typeof trigger.focus === "function") { + return trigger; + } + if ( + document.activeElement && + typeof document.activeElement.focus === "function" + ) { + return document.activeElement; + } + return null; + } + + setNavigationTriggersExpanded(expanded) { + if (typeof document.querySelectorAll !== "function") { + return; + } + document + .querySelectorAll("[data-navigation-search-open]") + .forEach((trigger) => { + this.setElementAttribute( + trigger, + "aria-expanded", + expanded ? "true" : "false", + ); + }); + } + + resultOptionId(index) { + return `${this.listboxId}-option-${index}`; + } + + updateComboboxState() { + const dialog = this.shadowRoot.querySelector("dialog"); + const input = this.shadowRoot.querySelector(".search-input"); + const matches = this.renderedMatches || []; + this.setElementAttribute( + input, + "aria-expanded", + dialog && dialog.open && matches.length > 0 ? "true" : "false", + ); + + if ( + dialog && + dialog.open && + this.selectedIndex >= 0 && + this.selectedIndex < matches.length + ) { + this.setElementAttribute( + input, + "aria-activedescendant", + this.resultOptionId(this.selectedIndex), + ); + } else { + this.removeElementAttribute(input, "aria-activedescendant"); + } + } + + setStatus(message) { + const status = this.shadowRoot.querySelector(`#${this.statusId}`); + if (status) { + status.textContent = message || ""; + } + } + + resultsStatus(count, truncated) { + if (truncated) { + return "More than 100 results. Keep typing to narrow the list."; + } + if (count === 0) { + return "No results found."; + } + if (count === 1) { + return "1 result."; + } + return `${count} results.`; + } + loadInitialData() { const itemsAttr = this.getAttribute("items"); if (itemsAttr) { @@ -267,6 +526,11 @@ class NavigationSearch extends HTMLElement { handleSearch(query) { clearTimeout(this.debounceTimer); + if (query.trim()) { + this.setStatus("Searching..."); + } else { + this.setStatus(""); + } this.debounceTimer = setTimeout(() => { const url = this.getAttribute("url"); @@ -289,65 +553,262 @@ class NavigationSearch extends HTMLElement { this.matches = data.matches || []; this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); + if (query.trim()) { + this.setStatus(this.resultsStatus(this.matches.length, data.truncated)); + } else { + this.setStatus(""); + } } catch (e) { console.error("Failed to fetch search results:", e); this.matches = []; this.renderResults(); + this.setStatus("Search failed."); } } filterLocalItems(query) { if (!query.trim()) { - this.matches = []; + this.matches = this.allItems || []; } else { const lowerQuery = query.toLowerCase(); this.matches = (this.allItems || []).filter( (item) => item.name.toLowerCase().includes(lowerQuery) || + (item.display_name || "").toLowerCase().includes(lowerQuery) || item.url.toLowerCase().includes(lowerQuery), ); } this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); + if (query.trim()) { + this.setStatus(this.resultsStatus(this.matches.length, false)); + } else { + this.setStatus(""); + } } - renderResults() { - const container = this.shadowRoot.querySelector(".results-container"); - const input = this.shadowRoot.querySelector(".search-input"); + recentItemsStorageKey() { + return "datasette.navigationSearch.recentItems"; + } - if (this.matches.length === 0) { - const message = input.value.trim() - ? "No results found" - : "Start typing to search..."; - container.innerHTML = `
${message}
`; + loadRecentItems() { + if (typeof localStorage === "undefined") { + return []; + } + + try { + const raw = localStorage.getItem(this.recentItemsStorageKey()); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter((item) => item && item.name && item.url) + .map((item) => ({ + name: String(item.name), + display_name: item.display_name ? String(item.display_name) : "", + url: String(item.url), + type: item.type ? String(item.type) : "", + description: item.description ? String(item.description) : "", + })) + .slice(0, 5); + } catch (e) { + return []; + } + } + + saveRecentItem(match) { + if ( + typeof localStorage === "undefined" || + !match || + !match.name || + !match.url + ) { return; } - container.innerHTML = this.matches - .map( - (match, index) => ` -
recentItem.url !== item.url, + ); + localStorage.setItem( + this.recentItemsStorageKey(), + JSON.stringify([item, ...recentItems].slice(0, 5)), + ); + } catch (e) { + // localStorage may be unavailable, full, or disabled. + } + } + + clearRecentItems() { + if (typeof localStorage === "undefined") { + return; + } + + try { + localStorage.removeItem(this.recentItemsStorageKey()); + } catch (e) { + localStorage.setItem(this.recentItemsStorageKey(), "[]"); + } + this.renderResults(); + this.setStatus("Recent items cleared."); + } + + jumpSections() { + const manager = window.__DATASETTE__; + if (!manager || typeof manager.makeJumpSections !== "function") { + return []; + } + const sections = manager.makeJumpSections({ + navigationSearch: this, + }); + return Array.isArray(sections) + ? sections.filter( + (section) => section && typeof section.render === "function", + ) + : []; + } + + jumpSectionsHtml(jumpSections) { + return jumpSections + .map((section, index) => { + const id = section.id + ? ` data-jump-section-id="${this.escapeHtml(section.id)}"` + : ""; + return `
`; + }) + .join(""); + } + + renderJumpSections(container, jumpSections) { + jumpSections.forEach((section, index) => { + const node = container.querySelector( + `[data-jump-section-index="${index}"]`, + ); + if (!node) { + return; + } + section.render(node, { + navigationSearch: this, + container, + input: this.shadowRoot.querySelector(".search-input"), + }); + }); + } + + resultItemHtml(match, index) { + const displayName = match.display_name || match.name; + const label = + match.display_name && match.display_name !== match.name + ? `
${this.escapeHtml(match.name)}
` + : ""; + const type = match.type + ? `
${this.escapeHtml(match.type)}
` + : ""; + const description = match.description + ? `
${this.escapeHtml( + match.description, + )}
` + : ""; + return ` +
-
${this.escapeHtml( - match.name, - )}
+ ${type} +
${this.escapeHtml(displayName)}
+ ${label}
${this.escapeHtml(match.url)}
+ ${description}
- `, + `; + } + + renderResults() { + const container = this.shadowRoot.querySelector(".results-container"); + const input = this.shadowRoot.querySelector(".search-input"); + const showStartContent = !input.value.trim(); + const jumpSections = showStartContent ? this.jumpSections() : []; + const startBlock = showStartContent + ? this.jumpSectionsHtml(jumpSections) + : ""; + const recentItems = showStartContent ? this.loadRecentItems() : []; + const defaultMatches = showStartContent ? [] : this.matches; + const renderedMatches = [...recentItems, ...defaultMatches]; + this.renderedMatches = renderedMatches; + const emptyListbox = `
`; + + if (renderedMatches.length) { + if ( + this.selectedIndex < 0 || + this.selectedIndex >= renderedMatches.length + ) { + this.selectedIndex = 0; + } + } else { + this.selectedIndex = -1; + } + + if (renderedMatches.length === 0) { + if (startBlock) { + container.innerHTML = startBlock + emptyListbox; + this.renderJumpSections(container, jumpSections); + } else if (showStartContent) { + container.innerHTML = emptyListbox; + } else { + const message = input.value.trim() + ? "No results found" + : "Start typing to search..."; + container.innerHTML = `${emptyListbox}
${message}
`; + } + this.updateComboboxState(); + return; + } + + const recentHeading = recentItems.length + ? `
Recent
` + : ""; + const recentGroup = recentItems.length + ? `
${recentItems + .map((match, index) => this.resultItemHtml(match, index)) + .join("")}
` + : ""; + const recentActions = recentItems.length + ? `
` + : ""; + const defaultHtml = defaultMatches + .map((match, index) => + this.resultItemHtml(match, recentItems.length + index), ) .join(""); + container.innerHTML = + startBlock + + recentHeading + + `
${recentGroup}${defaultHtml}
` + + recentActions; + this.renderJumpSections(container, jumpSections); + this.updateComboboxState(); // Scroll selected item into view if (this.selectedIndex >= 0) { - const selectedItem = container.children[this.selectedIndex]; + const selectedItem = container.querySelector( + `.result-item[data-index="${this.selectedIndex}"]`, + ); if (selectedItem) { selectedItem.scrollIntoView({ block: "nearest" }); } @@ -355,22 +816,27 @@ class NavigationSearch extends HTMLElement { } moveSelection(direction) { + const matches = this.renderedMatches || this.matches; const newIndex = this.selectedIndex + direction; - if (newIndex >= 0 && newIndex < this.matches.length) { + if (newIndex >= 0 && newIndex < matches.length) { this.selectedIndex = newIndex; this.renderResults(); } } selectCurrentItem() { - if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) { + const matches = this.renderedMatches || this.matches; + if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) { this.selectItem(this.selectedIndex); } } selectItem(index) { - const match = this.matches[index]; + const matches = this.renderedMatches || this.matches; + const match = matches[index]; if (match) { + this.saveRecentItem(match); + // Dispatch custom event this.dispatchEvent( new CustomEvent("select", { @@ -383,32 +849,59 @@ class NavigationSearch extends HTMLElement { // Navigate to URL window.location.href = match.url; - this.closeMenu(); + this.closeMenu({ restoreFocus: false }); } } - openMenu() { + openMenu(trigger) { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); - dialog.showModal(); + this.restoreFocusTarget = this.focusRestoreTarget(trigger); + this.shouldRestoreFocus = true; + if (!dialog.open) { + dialog.showModal(); + } + this.setNavigationTriggersExpanded(true); input.value = ""; input.focus(); - // Reset state - start with no items shown + // Reset state, then populate the default jump list. this.matches = []; this.selectedIndex = -1; this.renderResults(); + this.setStatus(""); } - closeMenu() { + closeMenu(options = {}) { const dialog = this.shadowRoot.querySelector("dialog"); - dialog.close(); + this.shouldRestoreFocus = options.restoreFocus !== false; + if (dialog.open) { + dialog.close(); + } else { + this.onMenuClosed(); + } + } + + onMenuClosed() { + const input = this.shadowRoot.querySelector(".search-input"); + this.setElementAttribute(input, "aria-expanded", "false"); + this.removeElementAttribute(input, "aria-activedescendant"); + this.setNavigationTriggersExpanded(false); + this.setStatus(""); + if ( + this.shouldRestoreFocus && + this.restoreFocusTarget && + typeof this.restoreFocusTarget.focus === "function" + ) { + this.restoreFocusTarget.focus(); + } + this.restoreFocusTarget = null; } escapeHtml(text) { const div = document.createElement("div"); - div.textContent = text; + div.textContent = text == null ? "" : text; return div.innerHTML; } } diff --git a/datasette/static/table.js b/datasette/static/table.js index e9115453..f160f3f3 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -12,7 +12,6 @@ var DROPDOWN_ICON_SVG = ` dict[str, Any]: + data = { + "database": query.database, + "name": query.name, + "sql": query.sql, + "title": query.title, + "description": query.description, + "description_html": query.description_html, + "hide_sql": query.hide_sql, + "fragment": query.fragment, + "params": list(query.parameters), + "parameters": list(query.parameters), + "is_write": query.is_write, + "is_private": query.is_private, + "is_trusted": query.is_trusted, + "source": query.source, + "owner_id": query.owner_id, + "on_success_message": query.on_success_message, + "on_success_message_sql": query.on_success_message_sql, + "on_success_redirect": query.on_success_redirect, + "on_error_message": query.on_error_message, + "on_error_redirect": query.on_error_redirect, + } + if query.private is not None: + data["private"] = query.private + return data + + +def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]: + return { + "queries": [stored_query_to_dict(query) for query in page.queries], + "next": page.next, + "has_more": page.has_more, + "limit": page.limit, + } + + +async def save_queries_from_config(datasette: Any) -> None: + # Apply configured query entries from datasette.yaml to the internal table. + await datasette.get_internal_database().execute_write( + "DELETE FROM queries WHERE source = 'config'" + ) + for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items(): + for query_name, query_config in (db_config.get("queries") or {}).items(): + if not isinstance(query_config, dict): + query_config = {"sql": query_config} + await datasette.add_query( + dbname, + query_name, + query_config["sql"], + title=query_config.get("title"), + description=query_config.get("description"), + description_html=query_config.get("description_html"), + hide_sql=bool(query_config.get("hide_sql")), + fragment=query_config.get("fragment"), + parameters=query_config.get("params"), + is_write=bool(query_config.get("write")), + is_trusted=bool(query_config.get("is_trusted", True)), + source="config", + on_success_message=query_config.get("on_success_message"), + on_success_message_sql=query_config.get("on_success_message_sql"), + on_success_redirect=query_config.get("on_success_redirect"), + on_error_message=query_config.get("on_error_message"), + on_error_redirect=query_config.get("on_error_redirect"), + ) + + +def query_row_to_stored_query( + row: Any, private: bool | None = None +) -> StoredQuery | None: + if row is None: + return None + parameters = json.loads(row["parameters"] or "[]") + options = json.loads(row["options"] or "{}") + return StoredQuery( + database=row["database_name"], + name=row["name"], + sql=row["sql"], + title=row["title"], + description=row["description"], + description_html=row["description_html"], + hide_sql=bool(options.get("hide_sql")), + fragment=options.get("fragment"), + parameters=parameters, + is_write=bool(row["is_write"]), + is_private=bool(row["is_private"]), + is_trusted=bool(row["is_trusted"]), + source=row["source"], + owner_id=row["owner_id"], + on_success_message=options.get("on_success_message"), + on_success_message_sql=options.get("on_success_message_sql"), + on_success_redirect=options.get("on_success_redirect"), + on_error_message=options.get("on_error_message"), + on_error_redirect=options.get("on_error_redirect"), + private=private, + ) + + +def query_options_json(options: dict[str, Any]) -> str: + options_dict = {} + for field in QUERY_OPTION_FIELDS: + value = options.get(field) + if field == "hide_sql": + if value: + options_dict[field] = True + elif value is not None: + options_dict[field] = value + return json.dumps(options_dict, sort_keys=True) + + +async def add_query( + datasette: Any, + database: str, + name: str, + sql: str, + *, + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, +) -> None: + parameters_json = json.dumps(list(parameters or [])) + options_json = query_options_json( + { + "hide_sql": hide_sql, + "fragment": fragment, + "on_success_message": on_success_message, + "on_success_message_sql": on_success_message_sql, + "on_success_redirect": on_success_redirect, + "on_error_message": on_error_message, + "on_error_redirect": on_error_redirect, + } + ) + sql_statement = """ + INSERT INTO queries ( + database_name, name, sql, title, description, description_html, + options, parameters, is_write, is_private, is_trusted, source, owner_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + if replace: + sql_statement += """ + ON CONFLICT(database_name, name) DO UPDATE SET + sql = excluded.sql, + title = excluded.title, + description = excluded.description, + description_html = excluded.description_html, + options = excluded.options, + parameters = excluded.parameters, + is_write = excluded.is_write, + is_private = excluded.is_private, + is_trusted = excluded.is_trusted, + source = excluded.source, + owner_id = excluded.owner_id, + updated_at = CURRENT_TIMESTAMP + """ + await datasette.get_internal_database().execute_write( + sql_statement, + [ + database, + name, + sql, + title, + description, + description_html, + options_json, + parameters_json, + int(bool(is_write)), + int(bool(is_private)), + int(bool(is_trusted)), + source, + owner_id, + ], + ) + + +async def update_query( + datasette: Any, + database: str, + name: str, + *, + sql=UNCHANGED, + title=UNCHANGED, + description=UNCHANGED, + description_html=UNCHANGED, + hide_sql=UNCHANGED, + fragment=UNCHANGED, + parameters=UNCHANGED, + is_write=UNCHANGED, + is_private=UNCHANGED, + is_trusted=UNCHANGED, + source=UNCHANGED, + owner_id=UNCHANGED, + on_success_message=UNCHANGED, + on_success_message_sql=UNCHANGED, + on_success_redirect=UNCHANGED, + on_error_message=UNCHANGED, + on_error_redirect=UNCHANGED, +) -> None: + fields = { + "sql": sql, + "title": title, + "description": description, + "description_html": description_html, + "parameters": parameters, + "is_write": is_write, + "is_private": is_private, + "is_trusted": is_trusted, + "source": source, + "owner_id": owner_id, + } + option_fields = { + "hide_sql": hide_sql, + "fragment": fragment, + "on_success_message": on_success_message, + "on_success_message_sql": on_success_message_sql, + "on_success_redirect": on_success_redirect, + "on_error_message": on_error_message, + "on_error_redirect": on_error_redirect, + } + updates = [] + params = [] + for field, value in fields.items(): + if value is UNCHANGED: + continue + if field in {"is_write", "is_private", "is_trusted"}: + value = int(bool(value)) + elif field == "parameters": + value = json.dumps(list(value or [])) + updates.append(f"{field} = ?") + params.append(value) + changed_options = { + field: value for field, value in option_fields.items() if value is not UNCHANGED + } + if changed_options: + rows = await datasette.get_internal_database().execute( + """ + SELECT options FROM queries + WHERE database_name = ? AND name = ? + """, + [database, name], + ) + row = rows.first() + options = json.loads(row["options"] or "{}") if row is not None else {} + for field, value in changed_options.items(): + if field == "hide_sql": + if value: + options[field] = True + else: + options.pop(field, None) + elif value is None: + options.pop(field, None) + else: + options[field] = value + updates.append("options = ?") + params.append(json.dumps(options, sort_keys=True)) + if not updates: + return + updates.append("updated_at = CURRENT_TIMESTAMP") + params.extend([database, name]) + await datasette.get_internal_database().execute_write( + """ + UPDATE queries + SET {} + WHERE database_name = ? AND name = ? + """.format(", ".join(updates)), + params, + ) + + +async def remove_query( + datasette: Any, database: str, name: str, source: str | None = None +) -> None: + sql = "DELETE FROM queries WHERE database_name = ? AND name = ?" + params = [database, name] + if source is not None: + sql += " AND source = ?" + params.append(source) + await datasette.get_internal_database().execute_write(sql, params) + + +async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None: + rows = await datasette.get_internal_database().execute( + """ + SELECT * FROM queries + WHERE database_name = ? AND name = ? + """, + [database, name], + ) + return query_row_to_stored_query(rows.first()) + + +async def count_queries( + datasette: Any, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, +) -> int: + allowed_sql, allowed_params = await datasette.allowed_resources_sql( + action="view-query", + actor=actor, + parent=database, + ) + params = dict(allowed_params) + where_clauses = [] + if database is not None: + params["query_database"] = database + where_clauses.append("q.database_name = :query_database") + + if q: + where_clauses.append(""" + ( + q.name LIKE :query_search + OR q.title LIKE :query_search + OR q.description LIKE :query_search + OR q.sql LIKE :query_search + ) + """) + params["query_search"] = "%{}%".format(q) + if is_write is not None: + where_clauses.append("q.is_write = :query_is_write") + params["query_is_write"] = int(bool(is_write)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) + if source is not None: + where_clauses.append("q.source = :query_source") + params["query_source"] = source + if owner_id is not None: + where_clauses.append("q.owner_id = :query_owner_id") + params["query_owner_id"] = owner_id + + row = ( + await datasette.get_internal_database().execute( + """ + SELECT count(*) AS count + FROM queries q + JOIN ( + {allowed_sql} + ) allowed + ON allowed.parent = q.database_name + AND allowed.child = q.name + WHERE {where} + """.format( + allowed_sql=allowed_sql, + where=" AND ".join(where_clauses) or "1 = 1", + ), + params, + ) + ).first() + return row["count"] + + +async def list_queries( + datasette: Any, + database: str | None = None, + *, + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, +) -> StoredQueryPage: + limit = min(max(1, int(limit)), 1000) + allowed_sql, allowed_params = await datasette.allowed_resources_sql( + action="view-query", + actor=actor, + parent=database, + include_is_private=include_private, + ) + params = dict(allowed_params) + params.update({"limit": limit + 1}) + sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))" + where_clauses = [] + order_by = "q.database_name, sort_key, q.name" + if database is not None: + params["query_database"] = database + where_clauses.append("q.database_name = :query_database") + order_by = "sort_key, q.name" + + if cursor: + try: + components = urlsafe_components(cursor) + except ValueError: + components = [] + if database is None and len(components) == 3: + where_clauses.append(""" + ( + q.database_name > :cursor_database + OR ( + q.database_name = :cursor_database + AND ( + {sort_key_sql} > :cursor_sort_key + OR ( + {sort_key_sql} = :cursor_sort_key + AND q.name > :cursor_name + ) + ) + ) + ) + """.format(sort_key_sql=sort_key_sql)) + params["cursor_database"] = components[0] + params["cursor_sort_key"] = components[1] + params["cursor_name"] = components[2] + elif database is not None and len(components) == 2: + where_clauses.append(""" + ( + {sort_key_sql} > :cursor_sort_key + OR ( + {sort_key_sql} = :cursor_sort_key + AND q.name > :cursor_name + ) + ) + """.format(sort_key_sql=sort_key_sql)) + params["cursor_sort_key"] = components[0] + params["cursor_name"] = components[1] + + if q: + where_clauses.append(""" + ( + q.name LIKE :query_search + OR q.title LIKE :query_search + OR q.description LIKE :query_search + OR q.sql LIKE :query_search + ) + """) + params["query_search"] = "%{}%".format(q) + if is_write is not None: + where_clauses.append("q.is_write = :query_is_write") + params["query_is_write"] = int(bool(is_write)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) + if source is not None: + where_clauses.append("q.source = :query_source") + params["query_source"] = source + if owner_id is not None: + where_clauses.append("q.owner_id = :query_owner_id") + params["query_owner_id"] = owner_id + + private_select = ", allowed.is_private AS private" if include_private else "" + rows = list( + ( + await datasette.get_internal_database().execute( + """ + SELECT q.*, {sort_key_sql} AS sort_key{private_select} + FROM queries q + JOIN ( + {allowed_sql} + ) allowed + ON allowed.parent = q.database_name + AND allowed.child = q.name + WHERE {where} + ORDER BY {order_by} + LIMIT :limit + """.format( + allowed_sql=allowed_sql, + private_select=private_select, + sort_key_sql=sort_key_sql, + where=" AND ".join(where_clauses) or "1 = 1", + order_by=order_by, + ), + params, + ) + ).rows + ) + has_more = len(rows) > limit + if has_more: + rows = rows[:limit] + + queries = [] + for row in rows: + query = query_row_to_stored_query( + row, private=bool(row["private"]) if include_private else None + ) + assert query is not None + queries.append(query) + + next_token = None + if has_more and rows: + last_row = rows[-1] + if database is None: + next_token = "{},{},{}".format( + tilde_encode(last_row["database_name"]), + tilde_encode(last_row["sort_key"]), + tilde_encode(last_row["name"]), + ) + else: + next_token = "{},{}".format( + tilde_encode(last_row["sort_key"]), + tilde_encode(last_row["name"]), + ) + return StoredQueryPage( + queries=queries, + next=next_token, + has_more=has_more, + limit=limit, + ) diff --git a/datasette/templates/_action_menu.html b/datasette/templates/_action_menu.html index 7d1d4a55..86089992 100644 --- a/datasette/templates/_action_menu.html +++ b/datasette/templates/_action_menu.html @@ -1,7 +1,7 @@ {% if action_links %}
-{% endif %} \ No newline at end of file +{% endif %} diff --git a/datasette/templates/_close_open_menus.html b/datasette/templates/_close_open_menus.html index 3302d77d..a24633d0 100644 --- a/datasette/templates/_close_open_menus.html +++ b/datasette/templates/_close_open_menus.html @@ -13,4 +13,50 @@ document.body.addEventListener('click', (ev) => { (details) => details.open && details != detailsClickedWithin ).forEach(details => details.open = false); }); + +/* Sync aria-expanded and add keyboard navigation for details-menu elements */ +document.querySelectorAll('details.details-menu').forEach(function(details) { + var summary = details.querySelector('summary'); + details.addEventListener('toggle', function() { + if (summary) { + summary.setAttribute('aria-expanded', details.open ? 'true' : 'false'); + } + if (details.open) { + /* Focus first menu item when menu opens */ + var firstItem = details.querySelector('[role="menuitem"]'); + if (firstItem) { firstItem.focus(); } + } + }); +}); + +document.body.addEventListener('keydown', function(ev) { + /* Keyboard navigation for open details-menu elements */ + var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]')); + if (!openDetails.length) { return; } + + if (ev.key === 'Escape') { + openDetails.forEach(function(details) { + details.open = false; + var summary = details.querySelector('summary'); + if (summary) { summary.focus(); } + }); + return; + } + + if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { + var focused = document.activeElement; + openDetails.forEach(function(details) { + var items = Array.from(details.querySelectorAll('[role="menuitem"]')); + if (!items.length) { return; } + var idx = items.indexOf(focused); + if (idx === -1) { return; } + ev.preventDefault(); + if (ev.key === 'ArrowDown') { + items[(idx + 1) % items.length].focus(); + } else { + items[(idx - 1 + items.length) % items.length].focus(); + } + }); + } +}); diff --git a/datasette/templates/_execute_write_analysis_scripts.html b/datasette/templates/_execute_write_analysis_scripts.html new file mode 100644 index 00000000..a19bae13 --- /dev/null +++ b/datasette/templates/_execute_write_analysis_scripts.html @@ -0,0 +1,111 @@ + diff --git a/datasette/templates/_execute_write_analysis_styles.html b/datasette/templates/_execute_write_analysis_styles.html new file mode 100644 index 00000000..165cfe9f --- /dev/null +++ b/datasette/templates/_execute_write_analysis_styles.html @@ -0,0 +1,41 @@ + 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 ba34b60f..171b6442 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -1,6 +1,6 @@
-{% if display_rows %} +{% if display_columns %}
@@ -22,7 +22,7 @@ {% for row in display_rows %} - + {% for cell in row %} {% endfor %} @@ -31,6 +31,7 @@
{{ cell.value }}
-{% else %} +{% endif %} +{% if not display_rows %}

0 records

{% endif %} diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index dc393c20..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 21f8c693..82ab48dd 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -20,7 +20,7 @@
{% if actor %}
{{ display_actor(actor) }} @@ -72,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 @@