mirror of
https://github.com/simonw/datasette.git
synced 2026-07-19 05:54:43 +02:00
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:
parent
28d811320b
commit
d12f0d2c59
4 changed files with 138 additions and 5 deletions
|
|
@ -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 {}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue