Compare commits

...

9 commits

Author SHA1 Message Date
Alex Garcia
343e8258fb Add datasette-sql-editor ESM primitives module
Single source of truth for the CodeMirror setup: SQLiteDialect,
createSqlEditor() (delegable history with host undo/redo forwarding,
hostChange annotation for echo suppression, submit/escape callbacks,
fixed-tooltip mode, per-editor Compartment updateSchema), and
datasetteSchema() which fetches /-/editor-schema.json and maps it to a
lang-sql SQLNamespace identical to the server-inlined shape.
cm-editor.js is now a thin consumer; rollup emits both the IIFE and an
importable ESM bundle. Submit key is Mod-Enter (Cmd on mac as before,
now also Ctrl elsewhere) plus Shift-Enter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:07:21 -07:00
Alex Garcia
174099b707 Update test_execute_sql schema assertions for rich completion shape
Follow-up to d12f0d2c: the test regexes the inlined schema= JS and
still asserted the old flat list-of-strings shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:01:38 -07:00
Alex Garcia
cc1a24fb4f Pass defaultTable to the SQL editor from table-scoped pages
The table page's 'View and edit SQL' link now carries ?_table=<name>;
QueryView validates it against the actor-visible tables/views for the
database before exposing it as default_table, so the editor completes
that table's columns unprefixed. Stored/canned queries are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:59:23 -07:00
Alex Garcia
49f1660dcd Document DatabaseEditorSchemaView label for docs coverage test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:59:23 -07:00
Alex Garcia
b9716d4278 Add /{database}/-/editor-schema.json endpoint for SQL editor consumers
Neutral {database, tables: [{name, view, columns: [{name, type}]}]}
shape, gated on view-database + execute-sql with no table-name leak on
403, hidden tables excluded. /-/schema.json was already taken by the
DDL endpoint, hence editor-schema.json. _editor_schema() now maps from
the shared _schema_tables() introspection helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:56:21 -07:00
Alex Garcia
7e555b01f6 SQL editor: dynamic schema updates via Compartment (editor.updateSchema)
Per-view Compartment wraps the sql() extension; window.editor.updateSchema(
{schema, defaultTable, defaultSchema}) reconfigures autocomplete live.
Declares @codemirror/state as a direct dependency since it is now
imported directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:47:47 -07:00
Alex Garcia
d12f0d2c59 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>
2026-07-10 10:46:55 -07:00
Alex Garcia
28d811320b Version-agnostic CodeMirror bundle filenames, rollup.config.mjs build
cm-editor-6.0.1.{js,bundle.js} -> cm-editor.{js,bundle.js}; new
npm run build:codemirror replaces the documented rollup one-liner.
Cache busting already handled by the static() template helper's
?_hash= content hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:44:58 -07:00
Alex Garcia
79f6ac3f47 Upgrade CodeMirror to latest 6.x, fix sql() option names
codemirror 6.0.1 -> 6.0.2, @codemirror/lang-sql 6.3.3 -> 6.10.0
(autocomplete 6.20.3, view 6.43.6, state 6.7.1).
defaultTableName/defaultSchemaName were never valid SQLConfig options;
the real names are defaultTable/defaultSchema. Also enables
caseInsensitiveIdentifiers on the SQLite dialect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:39:42 -07:00
24 changed files with 1183 additions and 626 deletions

View file

@ -85,6 +85,7 @@ from .views.special import (
JumpView,
InstanceSchemaView,
DatabaseSchemaView,
DatabaseEditorSchemaView,
TableSchemaView,
)
from .views.table import (
@ -2716,6 +2717,10 @@ class Datasette:
DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
)
add_route(
DatabaseEditorSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/editor-schema\.json$",
)
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/parameters$",

File diff suppressed because one or more lines are too long

View file

@ -1,74 +0,0 @@
import { EditorView, basicSetup } from "codemirror";
import { keymap } from "@codemirror/view";
import { sql, SQLDialect } from "@codemirror/lang-sql";
// A variation of SQLite from lang-sql https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
const SQLite = SQLDialect.define({
// Based on https://www.sqlite.org/lang_keywords.html based on likely keywords to be used in select queries
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
keywords:
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
// https://www.sqlite.org/datatype3.html
types: "null integer real text blob",
builtin: "",
operatorChars: "*+-%<>!=&|/~",
identifierQuotes: '`"',
specialVar: "@:?$",
});
// Utility function from https://codemirror.net/docs/migration/
export function editorFromTextArea(textarea, conf = {}) {
// This could also be configured with a set of tables and columns for better autocomplete:
// https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables
let view = new EditorView({
doc: textarea.value,
extensions: [
keymap.of([
{
key: "Shift-Enter",
run: function () {
textarea.value = view.state.doc.toString();
textarea.form.submit();
return true;
},
},
{
key: "Meta-Enter",
run: function () {
textarea.value = view.state.doc.toString();
textarea.form.submit();
return true;
},
},
]),
// This has to be after the keymap or else the basicSetup keys will prevent
// Meta-Enter from running
basicSetup,
EditorView.lineWrapping,
sql({
dialect: SQLite,
schema: conf.schema,
tables: conf.tables,
defaultTableName: conf.defaultTableName,
defaultSchemaName: conf.defaultSchemaName,
}),
],
});
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
// Using CSS resize: both and scheduling a measurement when the element changes.
let editorDOM = view.contentDOM.closest(".cm-editor");
let observer = new ResizeObserver(function () {
view.requestMeasure();
});
observer.observe(editorDOM, { attributes: true });
textarea.parentNode.insertBefore(view.dom, textarea);
textarea.style.display = "none";
if (textarea.form) {
textarea.form.addEventListener("submit", () => {
textarea.value = view.state.doc.toString();
});
}
return view;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,51 @@
// IIFE entry point for Datasette's own SQL editor pages. Built as
// cm-editor.bundle.js (global name `cm`) and included by _codemirror.html.
//
// This is a thin consumer of the datasette-sql-editor.js primitives so there is
// a single CodeMirror implementation. rollup inlines the shared module into this
// bundle.
import { createSqlEditor, SQLiteDialect } from "./datasette-sql-editor.js";
// Re-exported so plugins/pages using the IIFE global can reach the curated
// dialect (cm.SQLiteDialect) without a second CodeMirror instance.
export { SQLiteDialect };
// Utility function from https://codemirror.net/docs/migration/. Wraps a textarea
// with a CodeMirror SQL editor, mirroring the textarea's value back on submit.
// Returns the EditorView (with an added updateSchema method) for backwards
// compatibility with existing callers (window.editor).
export function editorFromTextArea(textarea, conf = {}) {
const submit = (view) => {
textarea.value = view.state.doc.toString();
textarea.form.submit();
};
const handle = createSqlEditor(null, {
doc: textarea.value,
schema: conf.schema,
defaultTable: conf.defaultTable,
defaultSchema: conf.defaultSchema,
onSubmit: submit,
});
const view = handle.view;
// Preserve the historical public surface: callers use view.updateSchema(conf).
view.updateSchema = handle.updateSchema;
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
// Using CSS resize: both and scheduling a measurement when the element changes.
let editorDOM = view.contentDOM.closest(".cm-editor");
let observer = new ResizeObserver(function () {
view.requestMeasure();
});
observer.observe(editorDOM, { attributes: true });
textarea.parentNode.insertBefore(view.dom, textarea);
textarea.style.display = "none";
if (textarea.form) {
textarea.form.addEventListener("submit", () => {
textarea.value = view.state.doc.toString();
});
}
return view;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,326 @@
// datasette-sql-editor: ESM primitives for embedding Datasette's SQL editor.
//
// This is the single source of truth for Datasette's CodeMirror setup. The IIFE
// entry point (cm-editor.js, served as cm-editor.bundle.js for Datasette's own
// pages) is a thin consumer of these primitives, and plugin authors can import
// this module directly from /-/static/datasette-sql-editor.js to get a SQL
// editor that shares ONE CodeMirror instance per page (no duplicate
// @codemirror/state bug).
//
// Built by rollup.config.mjs into datasette-sql-editor.bundle.js.
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLineGutter,
highlightSpecialChars,
drawSelection,
dropCursor,
rectangularSelection,
crosshairCursor,
highlightActiveLine,
tooltips,
} from "@codemirror/view";
import { EditorState, Compartment, Annotation, Prec } from "@codemirror/state";
import {
foldGutter,
indentOnInput,
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
foldKeymap,
} from "@codemirror/language";
import { history, defaultKeymap, historyKeymap } from "@codemirror/commands";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import {
closeBrackets,
autocompletion,
closeBracketsKeymap,
completionKeymap,
} from "@codemirror/autocomplete";
import { lintKeymap } from "@codemirror/lint";
import { sql, SQLDialect } from "@codemirror/lang-sql";
// A curated variation of SQLite from lang-sql:
// https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
export const SQLiteDialect = SQLDialect.define({
// Based on https://www.sqlite.org/lang_keywords.html, restricted to likely
// keywords used in select queries.
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
keywords:
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
// https://www.sqlite.org/datatype3.html
types: "null integer real text blob",
builtin: "",
operatorChars: "*+-%<>!=&|/~",
identifierQuotes: '`"',
specialVar: "@:?$",
caseInsensitiveIdentifiers: true,
});
// Annotation used to tag host-originated changes (e.g. a ProseMirror/collab host
// pushing edits into the editor, or the exported `value` setter). Changes tagged
// with this annotation do NOT re-fire onChange, so hosts can suppress the echo of
// their own edits. Mirrors datasette-paper's `fromPM` pattern.
export const hostChange = Annotation.define();
// Builds the sql() language extension from a {schema, defaultTable, defaultSchema}
// conf object. Undefined fields are fine - lang-sql ignores them.
function sqlExtension(conf = {}) {
return sql({
dialect: SQLiteDialect,
schema: conf.schema,
defaultTable: conf.defaultTable,
defaultSchema: conf.defaultSchema,
});
}
// Replicates codemirror's basicSetup (node_modules/codemirror/dist/index.js) as a
// plain array so we can drop the undo history when `withHistory` is false. When
// history is off we optionally forward Mod-z / Mod-y / Mod-Shift-z to the host so
// an external undo stack (ProseMirror, collab) can own undo/redo.
function baseSetup(withHistory, onHostUndo, onHostRedo) {
const setup = [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
];
const bindings = [
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap,
];
if (withHistory) {
setup.push(history());
bindings.push(...historyKeymap);
} else {
if (onHostUndo) {
bindings.push({
key: "Mod-z",
preventDefault: true,
run: () => {
onHostUndo();
return true;
},
});
}
if (onHostRedo) {
bindings.push(
{
key: "Mod-y",
mac: "Mod-Shift-z",
preventDefault: true,
run: () => {
onHostRedo();
return true;
},
},
{
key: "Mod-Shift-z",
preventDefault: true,
run: () => {
onHostRedo();
return true;
},
},
);
}
}
setup.push(keymap.of(bindings));
return setup;
}
// createSqlEditor(parent, opts) -> handle
//
// opts:
// doc initial document string (default "")
// schema lang-sql SQLNamespace for autocomplete
// defaultTable unqualified-column default table
// defaultSchema default schema/attached-database name
// history include CM undo history (default true); false forwards
// undo/redo to onHostUndo/onHostRedo
// onHostUndo called on Mod-z when history is false
// onHostRedo called on Mod-y / Mod-Shift-z when history is false
// extensions extra CodeMirror extensions to append (default [])
// fixedTooltips use position:"fixed" tooltips for overflow-clipped containers
// onChange called (update) on user edits; host-annotated changes are
// suppressed
// onSubmit called (view) on Mod-Enter / Shift-Enter (highest precedence)
// onEscape called (view) on Escape
// lineWrapping soft-wrap long lines (default true)
//
// handle: {view, updateSchema(conf), destroy(), get value(), set value(v)}
export function createSqlEditor(parent, opts = {}) {
const {
doc = "",
schema,
defaultTable,
defaultSchema,
history: withHistory = true,
onHostUndo,
onHostRedo,
extensions = [],
fixedTooltips = false,
onChange,
onSubmit,
onEscape,
lineWrapping = true,
} = opts;
const sqlCompartment = new Compartment();
// Highest-precedence keymap so submit/escape win over the basic keymap.
const priorityBindings = [];
if (onSubmit) {
const runSubmit = () => {
onSubmit(view);
return true;
};
priorityBindings.push(
{ key: "Mod-Enter", run: runSubmit },
{ key: "Shift-Enter", run: runSubmit },
);
}
if (onEscape) {
priorityBindings.push({
key: "Escape",
run: () => {
onEscape(view);
return true;
},
});
}
const editorExtensions = [
Prec.highest(keymap.of(priorityBindings)),
...baseSetup(withHistory, onHostUndo, onHostRedo),
lineWrapping ? EditorView.lineWrapping : [],
fixedTooltips ? tooltips({ position: "fixed" }) : [],
sqlCompartment.of(sqlExtension({ schema, defaultTable, defaultSchema })),
onChange
? EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
// Suppress echoes of host-originated changes.
if (update.transactions.some((tr) => tr.annotation(hostChange))) {
return;
}
onChange(update);
})
: [],
...extensions,
];
let view = new EditorView({
doc,
extensions: editorExtensions,
...(parent ? { parent } : {}),
});
return {
view,
// Swap out the schema/defaultTable/defaultSchema used for autocomplete after
// the editor has been created.
// https://codemirror.net/examples/config/#dynamic-configuration
updateSchema(conf) {
view.dispatch({
effects: sqlCompartment.reconfigure(sqlExtension(conf)),
});
},
destroy() {
view.destroy();
},
get value() {
return view.state.doc.toString();
},
// Host-originated: tagged with hostChange so it does not re-fire onChange.
set value(newValue) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: newValue },
annotations: hostChange.of(true),
});
},
};
}
// Maps ticket 05's neutral editor-schema shape
// {tables: [{name, view: bool, columns: [{name, type}]}]}
// to a lang-sql SQLNamespace of Completion objects. Kept identical to
// _editor_schema() / _column_completion() in datasette/views/query_helpers.py so
// server-inlined and client-fetched schemas behave the same.
function columnCompletion(name, type) {
const completion = { label: name, type: "property", boost: 10 };
if (type) {
completion.detail = type;
}
return completion;
}
function schemaFromTables(tables) {
const schema = {};
for (const table of tables || []) {
const completions = (table.columns || []).map((column) =>
columnCompletion(column.name, column.type),
);
if (table.view) {
schema[table.name] = {
self: { label: table.name, type: "class", detail: "view" },
children: completions,
};
} else {
schema[table.name] = completions;
}
}
return schema;
}
// datasetteSchema(baseUrl, database) -> Promise<SQLNamespace>
//
// Fetches GET {baseUrl}/{database}/-/editor-schema.json (ticket 05) and maps the
// neutral payload to a lang-sql SQLNamespace ready to pass as opts.schema /
// updateSchema({schema}). baseUrl is Datasette's base_url (may be "" or "/" or a
// mount prefix). Throws a descriptive Error on a non-200 response.
export async function datasetteSchema(baseUrl, database) {
const base = (baseUrl || "").replace(/\/+$/, "");
const url = `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) {
throw new Error(
`datasetteSchema: failed to fetch ${url} (${response.status} ${response.statusText})`,
);
}
const data = await response.json();
return schemaFromTables(data.tables);
}
// Re-export the CodeMirror pieces callers need so plugin code shares this
// module's single CM instance instead of bundling its own.
export {
EditorView,
EditorState,
Compartment,
Annotation,
Prec,
keymap,
tooltips,
sql,
SQLDialect,
autocompletion,
completionKeymap,
};

View file

@ -1,5 +1,5 @@
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
<script src="{{ static('cm-editor.bundle.js') }}"></script>
<style>
.cm-editor {
resize: both;

View file

@ -15,6 +15,9 @@
if (sqlInput) {
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
schema,
{% if default_table is defined and default_table %}
defaultTable: {{ default_table|tojson }},
{% endif %}
}));
if (sqlFormat) {
sqlFormat.addEventListener("click", (ev) => {

View file

@ -126,7 +126,7 @@
{% endif %}
{% if query.sql and allow_execute_sql %}
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql, '_table': table}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
{% endif %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>

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 {}
),
@ -454,6 +457,11 @@ class QueryContext(Context):
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
}
)
default_table: str = field(
metadata={
"help": "Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for alternate JSON version of this page"}
)
@ -713,6 +721,15 @@ class QueryView(View):
# Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
# If the request carries a ?_table= pointing at a real (visible) table
# or view in this database, treat this as a table-scoped query - e.g.
# arriving here via the "View and edit SQL" link on a table page - so
# the SQL editor can offer that table's columns unprefixed. Anything
# else (including stored/canned queries, which may reference more
# than one table) leaves this as None.
requested_table = request.args.get("_table")
default_table = requested_table if requested_table in allowed_dict else None
# Are we a stored query?
stored_query = None
stored_query_write = False
@ -1094,10 +1111,11 @@ 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 {}
),
default_table=default_table,
columns=columns,
renderers=renderers,
url_csv=datasette.urls.path(

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,92 @@ 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 _schema_tables(datasette, database_name, *, include_hidden=True):
"""
Neutral introspection of a database's tables and views for SQL editors.
Returns an ordered list of dicts, one per table or view::
{"name": str, "view": bool,
"columns": [{"name": str, "type": str}, ...]}
``type`` is the SQLite declared column type (empty string when the column
has no declared type). Regular-table columns come from the internal
``catalog_columns`` catalog; views are absent from that catalog so their
columns are read directly via PRAGMA table_xinfo. Hidden tables (FTS shadow
tables and the like) are excluded unless ``include_hidden`` is True. This is
the shared, serialization-agnostic source for both ``_editor_schema`` (which
maps it to lang-sql Completion objects) and the ``/-/editor-schema.json``
endpoint (which emits it directly).
"""
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name, type from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(
{"name": row["name"], "type": row["type"]}
)
db = datasette.get_database(database_name)
hidden = set() if include_hidden else set(await db.hidden_table_names())
tables = []
for table_name, columns in table_columns.items():
if table_name in hidden:
continue
tables.append({"name": table_name, "view": False, "columns": columns})
# Views are not represented in catalog_columns, so pull their real columns
# directly (PRAGMA table_xinfo works against views too).
for view_name in await db.view_names():
columns = [
{"name": column.name, "type": column.type}
for column in await db.table_column_details(view_name)
]
tables.append({"name": view_name, "view": True, "columns": columns})
return tables
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.
"""
schema = {}
for table in await _schema_tables(datasette, database_name, include_hidden=True):
completions = [
_column_completion(column["name"], column["type"])
for column in table["columns"]
]
if table["view"]:
schema[table["name"]] = {
"self": {
"label": table["name"],
"type": "class",
"detail": "view",
},
"children": completions,
}
else:
schema[table["name"]] = completions
return schema

View file

@ -1345,6 +1345,59 @@ class DatabaseSchemaView(SchemaBaseView):
return await self.format_html_response(request, schemas)
class DatabaseEditorSchemaView(BaseView):
"""
JSON introspection of a database's tables, views and columns shaped for SQL
editor autocomplete consumers (the CodeMirror ``<datasette-sql-editor>``
component and external clients such as datasette-paper).
Distinct from :class:`DatabaseSchemaView` (``/<db>/-/schema.json``), which
returns the raw DDL as a SQL string gated on ``view-database`` alone. This
endpoint returns a neutral structured payload and is gated on both
``view-database`` and ``execute-sql`` the same permissions as the inline
editor schema handed to the SQL query page.
"""
name = "database_editor_schema"
has_json_alternate = False
async def get(self, request):
from .query_helpers import _schema_tables
database_name = request.url_vars["database"]
# view-database is checked first so actors without it cannot
# distinguish an existing database from a missing one, and a denied
# request only ever leaks the permission action name, never table names.
await self.ds.ensure_permission(
action="view-database",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
if database_name not in self.ds.databases:
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
error_body("Database not found", 404), status=404, headers=headers
)
await self.ds.ensure_permission(
action="execute-sql",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
await self.ds.refresh_schemas()
tables = await _schema_tables(self.ds, database_name, include_hidden=False)
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
{"database": database_name, "tables": tables}, headers=headers
)
class TableSchemaView(SchemaBaseView):
"""
Displays schema for a specific table.

View file

@ -434,13 +434,15 @@ Datasette bundles `CodeMirror <https://codemirror.net/>`__ for the SQL editing i
npm i codemirror @codemirror/lang-sql
* Build the bundle using the version number from package.json with::
* Build the bundle with::
node_modules/.bin/rollup datasette/static/cm-editor-6.0.1.js \
-f iife \
-n cm \
-o datasette/static/cm-editor-6.0.1.bundle.js \
-p @rollup/plugin-node-resolve \
-p @rollup/plugin-terser
npm install && npm run build:codemirror
* Update the version reference in the ``codemirror.html`` template.
This runs ``rollup -c`` against the ``rollup.config.mjs`` file at the root of the repository, which builds two bundles:
* ``datasette/static/cm-editor.bundle.js`` - the minified IIFE bundle (global name ``cm``) used by Datasette's own pages, built from ``datasette/static/cm-editor.js``.
* ``datasette/static/datasette-sql-editor.bundle.js`` - a self-contained ESM bundle built from ``datasette/static/datasette-sql-editor.js``, which plugin authors can import directly (e.g. ``import {createSqlEditor, datasetteSchema} from "/-/static/datasette-sql-editor.bundle.js"``). ``cm-editor.js`` is a thin consumer of this shared module, so there is a single CodeMirror implementation.
The bundle filenames do not include the CodeMirror version number, so no template needs to be updated.
* Commit the rebuilt ``datasette/static/cm-editor.bundle.js`` and ``datasette/static/datasette-sql-editor.bundle.js`` - the bundles are checked into the repository.

View file

@ -152,6 +152,62 @@ Values for named SQL parameters can be provided as additional query string param
The response uses the same default representation described above.
.. _json_api_editor_schema:
.. _DatabaseEditorSchemaView:
Schema for SQL editors
----------------------
The ``/-/editor-schema.json`` endpoint returns a machine-readable description of
a database's tables, views and columns, shaped for SQL editor autocomplete. It
powers Datasette's own CodeMirror SQL editor and is available for external
consumers such as embeddable editor components.
::
GET /<database>/-/editor-schema.json
Access requires both the :ref:`actions_view_database` and
:ref:`actions_execute_sql` permissions for the database - the same gate as the
inline editor schema on the SQL query page. A request that fails either check
receives a ``403`` JSON error that does not reveal any table or column names.
The response is a neutral structure - a ``database`` name and a list of
``tables``, each with a ``view`` flag (``true`` for SQL views) and a list of
``columns`` carrying the SQLite declared ``type`` (an empty string when the
column has no declared type):
.. code-block:: json
{
"database": "fixtures",
"tables": [
{
"name": "facetable",
"view": false,
"columns": [
{"name": "pk", "type": "INTEGER"},
{"name": "state", "type": "TEXT"}
]
},
{
"name": "paginated_view",
"view": true,
"columns": [
{"name": "content", "type": "TEXT"}
]
}
]
}
Hidden tables - such as the shadow tables that back SQLite full-text search -
are excluded from the response.
This endpoint is distinct from the :ref:`database schema endpoint <DatabaseSchemaView>`
at ``/<database>/-/schema.json``, which returns the raw ``CREATE`` statements as
a SQL string.
.. _json_api_shapes:
Different shapes

View file

@ -168,6 +168,9 @@ The page for arbitrary SQL queries (/database/-/query?sql=...) and stored querie
``db_is_immutable`` - ``bool``
Boolean indicating if this database is immutable
``default_table`` - ``str``
Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries.
``display_rows`` - ``list``
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.

747
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,14 +5,22 @@
"prettier": "^3.0.0"
},
"scripts": {
"build:codemirror": "rollup -c",
"fix": "npm run prettier -- --write",
"prettier": "prettier 'datasette/static/*[!.min|bundle].js'"
},
"dependencies": {
"@codemirror/lang-sql": "^6.3.3",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^0.1.0",
"codemirror": "^6.0.1",
"codemirror": "^6.0.2",
"rollup": "^3.30.0"
}
}

30
rollup.config.mjs Normal file
View file

@ -0,0 +1,30 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
const plugins = [nodeResolve(), terser()];
export default [
// IIFE bundle for Datasette's own pages (global name `cm`, included by
// _codemirror.html). The shared datasette-sql-editor.js module is inlined.
{
input: "datasette/static/cm-editor.js",
output: {
file: "datasette/static/cm-editor.bundle.js",
format: "iife",
name: "cm",
},
plugins,
},
// Self-contained ESM bundle for plugin authors to import directly, e.g.
// import {createSqlEditor, datasetteSchema} from
// "/-/static/datasette-sql-editor.bundle.js"
// No bare specifiers remain; all @codemirror/* deps are inlined.
{
input: "datasette/static/datasette-sql-editor.js",
output: {
file: "datasette/static/datasette-sql-editor.bundle.js",
format: "es",
},
plugins,
},
];

View file

@ -284,6 +284,53 @@ async def test_query_page_with_no_sql(ds_client):
assert 'class="rows-and-columns"' not in response.text
@pytest.mark.asyncio
async def test_table_page_view_and_edit_sql_link_carries_table(ds_client):
# The table page's "View and edit SQL" link should point at the query
# page with a &_table= param identifying the focal table, so the SQL
# editor can offer that table's columns unprefixed.
response = await ds_client.get("/fixtures/facetable")
assert response.status_code == 200
soup = Soup(response.content, "html.parser")
link = soup.find("span", string="View and edit SQL").find_parent("a")
assert link is not None
assert "_table=facetable" in link["href"]
@pytest.mark.asyncio
async def test_query_page_default_table_from_table_scoped_link(ds_client):
# Following the table page's edit-SQL link should result in a query page
# whose SQL editor is initialized with defaultTable set to that table.
table_response = await ds_client.get("/fixtures/facetable")
soup = Soup(table_response.content, "html.parser")
href = soup.find("span", string="View and edit SQL").find_parent("a")["href"]
response = await ds_client.get(href, follow_redirects=True)
assert response.status_code == 200
assert 'defaultTable: "facetable"' in response.text
@pytest.mark.asyncio
async def test_query_page_no_default_table_without_table_scope(ds_client):
# The plain database query page (no focal table) should not set
# defaultTable at all.
response = await ds_client.get("/fixtures/-/query?sql=select+1")
assert response.status_code == 200
assert "defaultTable" not in response.text
@pytest.mark.asyncio
async def test_query_page_ignores_invalid_table_param(ds_client):
# A ?_table= value that isn't a real table/view in this database should
# not be reflected back into the page - and should not break execution
# of the query itself (leading-underscore params are not treated as SQL
# bind parameters unless they appear as :name in the SQL).
response = await ds_client.get(
"/fixtures/-/query?sql=select+1&_table=not_a_real_table"
)
assert response.status_code == 200
assert "defaultTable" not in response.text
@pytest.mark.asyncio
async def test_query_csv_with_no_sql_is_400(ds_client):
# https://github.com/simonw/datasette/issues/2743

View file

@ -295,13 +295,24 @@ def test_execute_sql(config):
# Extract the schema= portion of the JavaScript
schema_json = schema_re.search(response_text).group(1)
schema = json.loads(schema_json)
assert set(schema["attraction_characteristic"]) == {"name", "pk"}
assert schema["paginated_view"] == []
assert {c["label"] for c in schema["attraction_characteristic"]} == {
"name",
"pk",
}
# Views are self/children containers carrying their real columns
assert schema["paginated_view"]["self"]["detail"] == "view"
assert {c["label"] for c in schema["paginated_view"]["children"]} == {
"content",
"content_extra",
}
assert form_fragment in response_text
query_response = client.get("/fixtures/-/query?sql=select+1", cookies=cookies)
assert query_response.status == 200
schema2 = json.loads(schema_re.search(query_response.text).group(1))
assert set(schema2["attraction_characteristic"]) == {"name", "pk"}
assert {c["label"] for c in schema2["attraction_characteristic"]} == {
"name",
"pk",
}
assert (
client.get("/fixtures/facet_cities?_where=id=3", cookies=cookies).status
== 200

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)

View file

@ -1,3 +1,5 @@
import json
import pytest
import pytest_asyncio
from datasette.app import Datasette
@ -245,3 +247,165 @@ async def test_table_not_exists(schema_ds):
response = await schema_ds.client.get("/schema_public_db/nonexistent/-/schema.md")
assert response.status_code == 404
assert "not found" in response.text.lower()
# ---------------------------------------------------------------------------
# /<database>/-/editor-schema.json — neutral structured schema for SQL editors
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(scope="module")
async def editor_schema_ds():
"""Datasette instance exercising the editor-schema endpoint.
- public db: tables + a view + an FTS table (hidden shadow tables)
- private db: gated behind view-database (allow root only)
- noexec db: view-database allowed for anyone, execute-sql denied
"""
ds = Datasette(
config={
"databases": {
"editor_private_db": {"allow": {"id": "root"}},
"editor_noexec_db": {
# Everyone may view the database, but nobody may run SQL
"allow_sql": {"id": "root"},
},
}
}
)
public_db = ds.add_memory_database("editor_public_db")
await public_db.execute_write(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
)
await public_db.execute_write(
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT)"
)
await public_db.execute_write(
"CREATE VIEW recent_posts AS SELECT id, title FROM posts ORDER BY id DESC"
)
# An FTS table produces hidden shadow tables (users_fts_data, etc.)
await public_db.execute_write(
"CREATE VIRTUAL TABLE users_fts USING fts5(name, content=users)"
)
private_db = ds.add_memory_database("editor_private_db")
await private_db.execute_write(
"CREATE TABLE secret_data (id INTEGER PRIMARY KEY, value TEXT)"
)
noexec_db = ds.add_memory_database("editor_noexec_db")
await noexec_db.execute_write(
"CREATE TABLE locked (id INTEGER PRIMARY KEY, value TEXT)"
)
await ds.invoke_startup()
await ds.refresh_schemas()
return ds
@pytest.mark.asyncio
async def test_editor_schema_allowed_shape(editor_schema_ds):
"""Authorized fetch returns tables, columns, types and views in the
documented neutral shape."""
response = await editor_schema_ds.client.get(
"/editor_public_db/-/editor-schema.json"
)
assert response.status_code == 200
data = response.json()
assert data["database"] == "editor_public_db"
assert isinstance(data["tables"], list)
by_name = {t["name"]: t for t in data["tables"]}
# Regular table with columns + declared types
users = by_name["users"]
assert users["view"] is False
assert users["columns"] == [
{"name": "id", "type": "INTEGER"},
{"name": "name", "type": "TEXT"},
]
posts = by_name["posts"]
assert posts["view"] is False
assert {c["name"] for c in posts["columns"]} == {"id", "title", "body"}
# View is flagged and carries its real columns
view = by_name["recent_posts"]
assert view["view"] is True
assert [c["name"] for c in view["columns"]] == ["id", "title"]
# Whole payload is JSON-serializable and every entry matches the shape
for table in data["tables"]:
assert set(table) == {"name", "view", "columns"}
for column in table["columns"]:
assert set(column) == {"name", "type"}
@pytest.mark.asyncio
async def test_editor_schema_excludes_hidden_tables(editor_schema_ds):
"""FTS shadow tables (hidden_table_names) must not appear."""
response = await editor_schema_ds.client.get(
"/editor_public_db/-/editor-schema.json"
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert not any("_fts_" in name or name.endswith("_fts") for name in names), names
# Sanity: the visible objects are still there
assert {"users", "posts", "recent_posts"} <= names
@pytest.mark.asyncio
async def test_editor_schema_denied_view_database_403_no_leak(editor_schema_ds):
"""Anonymous user lacking view-database gets a 403 that leaks no names."""
response = await editor_schema_ds.client.get(
"/editor_private_db/-/editor-schema.json"
)
assert response.status_code == 403
body = response.text
assert "secret_data" not in body
data = response.json()
assert data["ok"] is False
assert "secret_data" not in json.dumps(data)
# The permitted actor can read it
response = await editor_schema_ds.client.get(
"/editor_private_db/-/editor-schema.json", actor={"id": "root"}
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert "secret_data" in names
@pytest.mark.asyncio
async def test_editor_schema_denied_execute_sql_403_no_leak(editor_schema_ds):
"""A viewer who lacks execute-sql gets a 403 with no schema data."""
# Anonymous user may view editor_noexec_db but not run SQL against it
response = await editor_schema_ds.client.get(
"/editor_noexec_db/-/editor-schema.json"
)
assert response.status_code == 403
data = response.json()
assert data["ok"] is False
assert "tables" not in data
assert "locked" not in json.dumps(data)
# The actor granted execute-sql can read the schema
response = await editor_schema_ds.client.get(
"/editor_noexec_db/-/editor-schema.json", actor={"id": "root"}
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert "locked" in names
@pytest.mark.asyncio
async def test_editor_schema_database_not_found(editor_schema_ds):
"""A non-existent database returns a 404 JSON error."""
response = await editor_schema_ds.client.get(
"/nonexistent_db/-/editor-schema.json"
)
assert response.status_code == 404
data = response.json()
assert data["ok"] is False
assert "not found" in data["error"].lower()