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 <noreply@anthropic.com>
This commit is contained in:
Alex Garcia 2026-07-10 10:46:55 -07:00
commit d12f0d2c59
4 changed files with 138 additions and 5 deletions

View file

@ -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 {}
),

View file

@ -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,

View file

@ -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

View file

@ -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)