From d12f0d2c5939ac0289b5fd06cca2eba6687b3b21 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Fri, 10 Jul 2026 10:46:55 -0700 Subject: [PATCH] Rich SQL editor completions: column types, view columns, ranking _editor_schema() emits lang-sql Completion objects (column type as detail, boost above keywords) and gives views their real columns via PRAGMA table_xinfo in a self/children container labelled 'view'. _table_columns() is unchanged for the write-template path. Note the table_columns field in the database JSON context now carries this richer shape. Co-Authored-By: Claude Fable 5 --- datasette/views/database.py | 11 +++-- datasette/views/execute_write.py | 4 +- datasette/views/query_helpers.py | 51 +++++++++++++++++++++ tests/test_queries.py | 77 ++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 10dc66ae..d9ca8012 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -36,7 +36,10 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm from .base import DatasetteError, View, stream_csv -from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns +from .query_helpers import ( + _ensure_stored_query_execution_permissions, + _editor_schema, +) from .table_extras import ( QueryExtraContext, resolve_query_extras, @@ -201,7 +204,7 @@ class DatabaseView(View): "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, "table_columns": ( - await _table_columns(datasette, database) if allow_execute_sql else {} + await _editor_schema(datasette, database) if allow_execute_sql else {} ), "metadata": await datasette.get_database_metadata(database), } @@ -242,7 +245,7 @@ class DatabaseView(View): queries_count=queries_count, allow_execute_sql=allow_execute_sql, table_columns=( - await _table_columns(datasette, database) + await _editor_schema(datasette, database) if allow_execute_sql else {} ), @@ -1094,7 +1097,7 @@ class QueryView(View): datasette, database, request, rows, columns ), table_columns=( - await _table_columns(datasette, database) + await _editor_schema(datasette, database) if allow_execute_sql else {} ), diff --git a/datasette/views/execute_write.py b/datasette/views/execute_write.py index dd35b127..f51b1f45 100644 --- a/datasette/views/execute_write.py +++ b/datasette/views/execute_write.py @@ -21,6 +21,7 @@ from .query_helpers import ( _inserted_row_url, _json_or_form_payload, _prepare_execute_write, + _editor_schema, _table_columns, _wants_json, ) @@ -266,6 +267,7 @@ class ExecuteWriteView(BaseView): write_template_tables = await _write_template_tables( self.ds, db, table_columns, hidden_table_names, request.actor ) + editor_schema = await _editor_schema(self.ds, db.name) write_template_operations = _write_template_operations(write_template_tables) write_create_table_template_sql = await _create_table_template_sql( self.ds, db, request.actor @@ -328,7 +330,7 @@ class ExecuteWriteView(BaseView): "sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX, "execute_disabled": bool(execute_disabled_reason), "execute_disabled_reason": execute_disabled_reason, - "table_columns": table_columns, + "table_columns": editor_schema, "write_template_tables": write_template_tables, "write_template_operations": write_template_operations, "write_create_table_template_sql": write_create_table_template_sql, diff --git a/datasette/views/query_helpers.py b/datasette/views/query_helpers.py index 588891d4..0e02f2eb 100644 --- a/datasette/views/query_helpers.py +++ b/datasette/views/query_helpers.py @@ -634,3 +634,54 @@ async def _table_columns(datasette, database_name): for view_name in await db.view_names(): table_columns[view_name] = [] return table_columns + + +def _column_completion(name, type_): + # A @codemirror/lang-sql Completion object for a single column. boost keeps + # columns ranked above bare SQL keywords in the autocomplete popup. + completion = { + "label": name, + "type": "property", + "boost": 10, + } + if type_: + completion["detail"] = type_ + return completion + + +async def _editor_schema(datasette, database_name): + """ + Build a lang-sql SQLNamespace for the CodeMirror SQL editor autocomplete. + + Returns a dict keyed by table or view name. Table values are lists of + Completion objects (one per column, carrying the column's SQLite type as + ``detail``). Views are wrapped in a ``{"self": Completion, "children": [...]}`` + container so the popup can label them as views while still completing their + real columns. See @codemirror/lang-sql >= 6.6 SQLNamespace / Completion. + """ + internal_db = datasette.get_internal_database() + result = await internal_db.execute( + "select table_name, name, type from catalog_columns where database_name = ?", + [database_name], + ) + schema = {} + for row in result.rows: + schema.setdefault(row["table_name"], []).append( + _column_completion(row["name"], row["type"]) + ) + # Views are not represented in catalog_columns, so pull their real columns + # directly (PRAGMA table_xinfo works against views too). + db = datasette.get_database(database_name) + for view_name in await db.view_names(): + columns = await db.table_column_details(view_name) + schema[view_name] = { + "self": { + "label": view_name, + "type": "class", + "detail": "view", + }, + "children": [ + _column_completion(column.name, column.type) for column in columns + ], + } + return schema diff --git a/tests/test_queries.py b/tests/test_queries.py index ffa948a9..bd2e2b95 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -94,6 +94,83 @@ async def test_queries_internal_table_schema(): ] +@pytest.mark.asyncio +async def test_editor_schema_rich_completions(): + from datasette.fixtures import TABLES + from datasette.views.query_helpers import _editor_schema + + ds = Datasette(memory=True) + db = ds.add_memory_database("editor_schema") + await ds.invoke_startup() + await db.execute_write_script(TABLES) + await ds.refresh_schemas() + + schema = await _editor_schema(ds, "editor_schema") + + # Tables are plain lists of Completion objects carrying type + boost + attractions = schema["roadside_attractions"] + assert isinstance(attractions, list) + pk_completion = next(c for c in attractions if c["label"] == "pk") + assert pk_completion == { + "label": "pk", + "type": "property", + "boost": 10, + "detail": "INTEGER", + } + # Every column completion is boosted above bare keywords + assert all(c["boost"] == 10 and c["type"] == "property" for c in attractions) + + # Views are wrapped in a self/children container labelled as a view + view = schema["paginated_view"] + assert view["self"] == { + "label": "paginated_view", + "type": "class", + "detail": "view", + } + child_labels = [c["label"] for c in view["children"]] + assert child_labels == ["content", "content_extra"] + # Column type inherited from the underlying table flows through as detail; + # the computed expression column has no declared type so carries no detail + content = next(c for c in view["children"] if c["label"] == "content") + assert content["detail"] == "TEXT" + content_extra = next(c for c in view["children"] if c["label"] == "content_extra") + assert "detail" not in content_extra + + # Whole payload must be JSON-serializable + json.dumps(schema) + + +@pytest.mark.asyncio +async def test_database_page_editor_schema_permission_gated(): + import secrets + from datasette.database import Database + from datasette.fixtures import TABLES + + async def schema_for(datasette): + # Unique memory name so parallel instances don't share a backing DB + name = f"editor_gated_{secrets.token_hex(8)}" + db = datasette.add_database( + Database(datasette, memory_name=name), name="editor_gated" + ) + await datasette.invoke_startup() + await db.execute_write_script(TABLES) + await datasette.refresh_schemas() + response = await datasette.client.get("/editor_gated.json") + assert response.status_code == 200 + return response.json()["table_columns"] + + # execute-sql allowed (default): rich schema is emitted + allowed = await schema_for(Datasette(memory=True)) + assert isinstance(allowed["roadside_attractions"], list) + assert allowed["paginated_view"]["self"]["detail"] == "view" + + # execute-sql denied: no schema leak, empty dict + denied = await schema_for( + Datasette(memory=True, settings={"default_allow_sql": False}) + ) + assert denied == {} + + @pytest.mark.asyncio async def test_add_get_and_remove_query(): ds = Datasette(memory=True)