mirror of
https://github.com/simonw/datasette.git
synced 2026-07-20 14:34:36 +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 datasette.plugins import pm
|
||||||
|
|
||||||
from .base import DatasetteError, View, stream_csv
|
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 (
|
from .table_extras import (
|
||||||
QueryExtraContext,
|
QueryExtraContext,
|
||||||
resolve_query_extras,
|
resolve_query_extras,
|
||||||
|
|
@ -201,7 +204,7 @@ class DatabaseView(View):
|
||||||
"queries_count": queries_count,
|
"queries_count": queries_count,
|
||||||
"allow_execute_sql": allow_execute_sql,
|
"allow_execute_sql": allow_execute_sql,
|
||||||
"table_columns": (
|
"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),
|
"metadata": await datasette.get_database_metadata(database),
|
||||||
}
|
}
|
||||||
|
|
@ -242,7 +245,7 @@ class DatabaseView(View):
|
||||||
queries_count=queries_count,
|
queries_count=queries_count,
|
||||||
allow_execute_sql=allow_execute_sql,
|
allow_execute_sql=allow_execute_sql,
|
||||||
table_columns=(
|
table_columns=(
|
||||||
await _table_columns(datasette, database)
|
await _editor_schema(datasette, database)
|
||||||
if allow_execute_sql
|
if allow_execute_sql
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
|
|
@ -1094,7 +1097,7 @@ class QueryView(View):
|
||||||
datasette, database, request, rows, columns
|
datasette, database, request, rows, columns
|
||||||
),
|
),
|
||||||
table_columns=(
|
table_columns=(
|
||||||
await _table_columns(datasette, database)
|
await _editor_schema(datasette, database)
|
||||||
if allow_execute_sql
|
if allow_execute_sql
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from .query_helpers import (
|
||||||
_inserted_row_url,
|
_inserted_row_url,
|
||||||
_json_or_form_payload,
|
_json_or_form_payload,
|
||||||
_prepare_execute_write,
|
_prepare_execute_write,
|
||||||
|
_editor_schema,
|
||||||
_table_columns,
|
_table_columns,
|
||||||
_wants_json,
|
_wants_json,
|
||||||
)
|
)
|
||||||
|
|
@ -266,6 +267,7 @@ class ExecuteWriteView(BaseView):
|
||||||
write_template_tables = await _write_template_tables(
|
write_template_tables = await _write_template_tables(
|
||||||
self.ds, db, table_columns, hidden_table_names, request.actor
|
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_template_operations = _write_template_operations(write_template_tables)
|
||||||
write_create_table_template_sql = await _create_table_template_sql(
|
write_create_table_template_sql = await _create_table_template_sql(
|
||||||
self.ds, db, request.actor
|
self.ds, db, request.actor
|
||||||
|
|
@ -328,7 +330,7 @@ class ExecuteWriteView(BaseView):
|
||||||
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
|
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
|
||||||
"execute_disabled": bool(execute_disabled_reason),
|
"execute_disabled": bool(execute_disabled_reason),
|
||||||
"execute_disabled_reason": 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_tables": write_template_tables,
|
||||||
"write_template_operations": write_template_operations,
|
"write_template_operations": write_template_operations,
|
||||||
"write_create_table_template_sql": write_create_table_template_sql,
|
"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():
|
for view_name in await db.view_names():
|
||||||
table_columns[view_name] = []
|
table_columns[view_name] = []
|
||||||
return table_columns
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_get_and_remove_query():
|
async def test_add_get_and_remove_query():
|
||||||
ds = Datasette(memory=True)
|
ds = Datasette(memory=True)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue