This commit is contained in:
Alex Garcia 2026-07-10 11:58:18 -07:00 committed by GitHub
commit 5a62fa0d37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1719 additions and 657 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,675 @@
// 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;
}
export 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);
}
// readOnlyState(ro) -> extensions that toggle editability. EditorState.readOnly
// blocks document edits; EditorView.editable additionally drops the
// contenteditable attribute so screen readers and the cursor reflect the
// read-only state.
function readOnlyState(ro) {
return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
}
// Theme that plumbs a small set of CSS custom properties into the editor so
// embedders can restyle without reaching into CodeMirror internals. The fallbacks
// reproduce Datasette's current editor appearance (monospace family, the
// page-inherited font size, a transparent background over the page/plugin
// background) so mounting the element on an existing Datasette page is visually a
// no-op — including in dark mode, which Datasette implements entirely through the
// surrounding page's colors, not editor-specific CSS. CodeMirror themes are static
// CSS-in-JS, but var() references pass straight through to the generated rules and
// resolve at render time, so an embedder's :root / @media (prefers-color-scheme)
// overrides just work.
const sqlEditorTheme = EditorView.theme({
"&": {
fontSize: "var(--datasette-sql-editor-font-size, inherit)",
background: "var(--datasette-sql-editor-bg, transparent)",
},
".cm-content, .cm-gutters": {
fontFamily: "var(--datasette-sql-editor-font-family, monospace)",
},
});
// <datasette-sql-editor> — a form-associated, light-DOM custom element wrapping
// createSqlEditor(). The module auto-registers the default tag on import (see the
// bottom of this file); call registerSqlEditorElement("my-tag") to also/instead
// register it under a different name.
//
// Attributes (all optional):
// name form-field name for the submitted SQL (form participation)
// database Datasette database name; when set and schema-url is absent the
// schema URL is derived as
// {base-url}/{database}/-/editor-schema.json
// base-url Datasette base_url prefix used for the derived schema URL ("")
// schema-url explicit URL returning the neutral {tables:[...]} schema payload
// default-table unqualified-column default table for autocomplete
// readonly boolean; mounts the editor read-only
// autofocus boolean; focuses the editor once mounted
// The initial document is, in priority order: a programmatically-set value; the
// value of a light-DOM <textarea> first child (a progressive-enhancement form
// field that keeps working with JS disabled - it is adopted then removed); or
// the element's trimmed textContent. The light DOM is cleared on mount.
//
// Properties:
// value get/set the document (set is host-tagged: no "input" event)
// schema set -> updateSchema({schema, defaultTable})
// view get the raw EditorView escape hatch (null before mount)
// readOnly get/set via a Compartment
// extensions get/set extra CodeMirror extensions; honored ONLY before the
// element connects (createSqlEditor builds the extension set once)
// Methods: focus(), updateSchema(conf), format().
// Events (all bubble):
// input {detail:{origin:"user"}} on user edits (host edits suppressed)
// submit cancelable; default action requestSubmit()s internals.form
// ready once mounted (schema may still be fetching — see below)
// editor-escape on Escape at the editor top level
export class DatasetteSqlEditorElement extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this._handle = null;
this._internals = null;
this._readOnly = false;
this._readOnlyCompartment = new Compartment();
this._extensions = [];
this._pendingDoc = null;
this._initialDoc = "";
// attachInternals is guarded: Safari < 16.4 has no ElementInternals. Without
// it the editor still works fully, but form participation
// (setFormValue/reset) is a no-op, so this field degrades to contributing
// nothing on submit. Documented as an accepted graceful degradation.
try {
this._internals = this.attachInternals ? this.attachInternals() : null;
} catch (err) {
this._internals = null;
}
}
connectedCallback() {
if (this._handle) return; // already mounted (e.g. move within the DOM)
// Parser-timing guard. When this element's definition loads BEFORE the parser
// reaches the element (e.g. a non-deferred <script> in <head>, which is how
// Datasette's own pages load the bundle), the browser upgrades and connects
// the element at its start tag - before its light-DOM children (the initial
// document / fallback <textarea>) have been parsed. Reading them now would
// yield an empty document. Detect that case - still parsing, nothing set
// programmatically, no children yet - and defer mounting to DOMContentLoaded,
// by which point the element's subtree is fully parsed. The listener is
// registered as the parser sees this element (before any later inline
// script's DOMContentLoaded handler), so consumers reading .view on
// DOMContentLoaded still observe a mounted editor.
if (
this.ownerDocument.readyState === "loading" &&
this._pendingDoc == null &&
!this.firstChild
) {
this.ownerDocument.addEventListener(
"DOMContentLoaded",
() => this.connectedCallback(),
{ once: true },
);
return;
}
// Progressive-enhancement fallback: a light-DOM <textarea> first child is a
// real form field that keeps working with JavaScript disabled. When present,
// adopt its value as the initial document and remove it, so it does not also
// submit a duplicate field alongside the value the element contributes via
// setFormValue.
const firstEl = this.firstElementChild;
const fallbackTextarea =
firstEl && firstEl.tagName === "TEXTAREA" ? firstEl : null;
let fallbackDoc = null;
if (fallbackTextarea) {
fallbackDoc = fallbackTextarea.value;
fallbackTextarea.remove();
}
const initialDoc =
this._pendingDoc != null
? this._pendingDoc
: fallbackDoc != null
? fallbackDoc
: this.textContent.trim();
this._initialDoc = initialDoc;
this._pendingDoc = null;
// Clear the light-DOM text so it doesn't render behind the editor.
this.textContent = "";
this._readOnly = this.hasAttribute("readonly");
const defaultTable = this.getAttribute("default-table") || undefined;
this._handle = createSqlEditor(this, {
doc: initialDoc,
defaultTable,
extensions: [
sqlEditorTheme,
this._readOnlyCompartment.of(readOnlyState(this._readOnly)),
...(this._extensions || []),
],
onChange: () => {
this._syncFormValue();
this.dispatchEvent(
new CustomEvent("input", {
bubbles: true,
detail: { origin: "user" },
}),
);
},
onSubmit: () => {
const proceed = this.dispatchEvent(
new CustomEvent("submit", { bubbles: true, cancelable: true }),
);
if (!proceed) return; // default prevented
const form = this._internals && this._internals.form;
if (!form) return;
// requestSubmit() runs constraint validation and submit handlers, exactly
// like clicking a submit button; fall back to submit() where unsupported.
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
form.submit();
}
},
onEscape: () => {
this.dispatchEvent(new CustomEvent("editor-escape", { bubbles: true }));
},
});
this._syncFormValue();
// Fetch schema (if configured) without blocking the editor: a failure
// downgrades to keyword-only completion and never breaks editing.
const schemaUrl = this._resolveSchemaUrl();
if (schemaUrl) {
fetch(schemaUrl, { credentials: "same-origin" })
.then((response) => {
if (!response.ok) {
throw new Error(
`schema fetch ${schemaUrl} -> ${response.status} ${response.statusText}`,
);
}
return response.json();
})
.then((data) => {
this.updateSchema({ schema: schemaFromTables(data.tables) });
})
.catch((err) => {
console.warn(
"datasette-sql-editor: schema fetch failed; keyword-only completion",
err,
);
});
}
if (this.hasAttribute("autofocus")) {
this._handle.view.focus();
}
// "ready" fires after mount; schema may still be in flight (it applies later
// via updateSchema). Dispatched synchronously so listeners attached before the
// element is inserted observe it.
this.dispatchEvent(new CustomEvent("ready", { bubbles: true }));
}
disconnectedCallback() {
if (this._handle) {
// Preserve the document across DOM moves (disconnect + reconnect):
// connectedCallback prefers _pendingDoc over textContent, and the dead
// editor's DOM must not be left behind to be misread as initial content.
this._pendingDoc = this._handle.value;
this._handle.destroy();
this._handle = null;
this.replaceChildren();
}
}
formResetCallback() {
if (!this._handle) return;
this._handle.value = this._initialDoc; // hostChange-tagged: no "input" event
this._syncFormValue();
}
_resolveSchemaUrl() {
const explicit = this.getAttribute("schema-url");
if (explicit) return explicit;
const database = this.getAttribute("database");
if (!database) return null;
const base = (this.getAttribute("base-url") || "").replace(/\/+$/, "");
return `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
}
_syncFormValue() {
if (this._internals && this._internals.setFormValue) {
this._internals.setFormValue(this.value);
}
}
// ---- properties -------------------------------------------------------
get value() {
return this._handle ? this._handle.value : this._pendingDoc || "";
}
set value(newValue) {
const v = newValue == null ? "" : String(newValue);
if (this._handle) {
this._handle.value = v; // hostChange-tagged: suppresses the "input" event
this._syncFormValue();
} else {
this._pendingDoc = v;
}
}
set schema(ns) {
this.updateSchema({ schema: ns });
}
get view() {
return this._handle ? this._handle.view : null;
}
get readOnly() {
return this._readOnly;
}
set readOnly(value) {
this._readOnly = !!value;
if (this._handle) {
this._handle.view.dispatch({
effects: this._readOnlyCompartment.reconfigure(
readOnlyState(this._readOnly),
),
});
}
}
get extensions() {
return this._extensions;
}
set extensions(exts) {
if (this._handle) {
console.warn(
"datasette-sql-editor: .extensions must be set before the element connects; ignoring",
);
return;
}
this._extensions = exts || [];
}
// ---- methods ----------------------------------------------------------
focus() {
if (this._handle) this._handle.view.focus();
}
updateSchema(conf = {}) {
if (!this._handle) return;
// Merge in default-table so a bare {schema} update doesn't drop it (the
// compartment reconfigure replaces the whole sql() extension).
this._handle.updateSchema({
defaultTable: this.getAttribute("default-table") || undefined,
...conf,
});
}
format() {
const formatter =
typeof window !== "undefined" ? window.sqlFormatter : undefined;
if (!formatter || typeof formatter.format !== "function") {
console.warn(
"datasette-sql-editor: window.sqlFormatter is not loaded; format() is a no-op",
);
return;
}
if (!this._handle) return;
const formatted = formatter.format(this.value);
this._handle.value = formatted; // hostChange-tagged full replace
this._syncFormValue();
}
}
// registerSqlEditorElement(tagName) — defines the element under tagName, guarding
// against double registration (customElements.define throws on a duplicate). A
// no-op in non-DOM contexts. Returns the tag name.
export function registerSqlEditorElement(tagName = "datasette-sql-editor") {
if (typeof customElements === "undefined") return tagName;
if (!customElements.get(tagName)) {
customElements.define(tagName, DatasetteSqlEditorElement);
}
return tagName;
}
// 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,
};
// Auto-register the default <datasette-sql-editor> tag on import. Guarded so
// importing this module in a non-DOM context (SSR/tests) or after another copy of
// the module already claimed the tag is a harmless no-op. This gives template and
// dogfood usage a zero-config element; plugins that want to compose the primitives
// without the element simply don't touch the tag. Register a differently-named tag
// with registerSqlEditorElement("my-tag").
if (
typeof customElements !== "undefined" &&
!customElements.get("datasette-sql-editor")
) {
registerSqlEditorElement("datasette-sql-editor");
}

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

@ -8,25 +8,21 @@
window.addEventListener("DOMContentLoaded", () => {
const sqlFormat = document.querySelector("button#sql-format");
const readOnly = document.querySelector("pre#sql-query");
const sqlInput = document.querySelector("textarea#sql-editor");
const editorElement = document.querySelector("datasette-sql-editor#sql-editor");
if (sqlFormat && !readOnly) {
sqlFormat.hidden = false;
}
if (sqlInput) {
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
schema,
}));
if (editorElement) {
// Rich lang-sql schema inlined server-side (see const schema above): drives
// first-paint autocomplete with no extra HTTP request and no flash of
// no-completions. The default table is carried on the element attribute.
if (Object.keys(schema).length) {
editorElement.schema = schema;
}
// Back-compat: plugins and inline scripts reach the raw EditorView here.
window.editor = editorElement.view;
if (sqlFormat) {
sqlFormat.addEventListener("click", (ev) => {
const formatted = sqlFormatter.format(editor.state.doc.toString());
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: formatted,
},
});
});
sqlFormat.addEventListener("click", () => editorElement.format());
}
}
if (sqlFormat && readOnly) {

View file

@ -70,7 +70,7 @@ form.sql .query-create-sql {
max-width: 52rem;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
form.sql .query-create-sql textarea[name="sql"] {
grid-column: 2;
width: 100%;
}
@ -127,7 +127,7 @@ form.sql .query-create-sql textarea#sql-editor {
grid-template-columns: 1fr;
}
.query-create-sql .cm-editor,
form.sql .query-create-sql textarea#sql-editor {
form.sql .query-create-sql textarea[name="sql"] {
grid-column: 1;
}
.query-create-options,

View file

@ -11,7 +11,9 @@ window.datasetteSqlParameters = (() => {
if (window.editor) {
return window.editor.state.doc.toString();
}
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
const sqlInput = form.querySelector(
"#sql-editor, textarea[name=sql], input[name=sql]"
);
return sqlInput ? sqlInput.value : "";
}
@ -201,7 +203,7 @@ window.datasetteSqlParameters = (() => {
editorElement.addEventListener("input", callback);
}
if (!window.editor) {
const sqlInput = form.querySelector("textarea#sql-editor");
const sqlInput = form.querySelector("#sql-editor");
if (sqlInput) {
sqlInput.addEventListener("input", callback);
}

View file

@ -2,7 +2,7 @@
form.sql .sql-editor {
max-width: 52rem;
}
form.sql .sql-editor textarea#sql-editor {
form.sql .sql-editor textarea[name="sql"] {
width: 100%;
}
form.sql .sql-parameters-section {

View file

@ -32,7 +32,7 @@
{% if allow_execute_sql %}
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
<h3>Custom SQL query</h3>
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
<p class="sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></datasette-sql-editor></p>
{% set parameter_names = [] %}
{% set parameter_values = {} %}
{% set sql_parameters_allow_expand = false %}

View file

@ -124,7 +124,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
{% endif %}
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
{% set sql_parameters_allow_expand = true %}
@ -175,7 +175,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
<script>
window.addEventListener("DOMContentLoaded", () => {
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
const executeWriteSqlInput = document.querySelector("#sql-editor");
const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#execute-write-analysis-section");
const submitButton = form
@ -261,7 +261,7 @@ window.addEventListener("DOMContentLoaded", () => {
window.addEventListener("DOMContentLoaded", () => {
const tableSelect = document.querySelector("#execute-write-template-table");
const templateButtons = document.querySelectorAll("[data-sql-template]");
const sqlInput = document.querySelector("textarea#sql-editor");
const sqlInput = document.querySelector("#sql-editor");
function dataKey(operation) {
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;

View file

@ -46,8 +46,8 @@
{% endif %}
{% if not hide_sql %}
{% if editable and allow_execute_sql %}
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
<p class="sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></datasette-sql-editor></p>
{% else %}
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
{% endif %}

View file

@ -28,7 +28,7 @@
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
</div>
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="query-create-sql sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
<p class="query-create-options">
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>

View file

@ -28,7 +28,7 @@
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
</div>
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
<p class="query-create-sql sql-editor"><datasette-sql-editor id="sql-editor" name="sql"{% if default_table is defined and default_table %} default-table="{{ default_table }}"{% endif %}><textarea name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></datasette-sql-editor></p>
<p class="query-create-options">
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>

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

@ -0,0 +1,157 @@
<!DOCTYPE html>
<!--
Manual-QA demo for <datasette-sql-editor> (ticket 08).
This page is NOT served by Datasette by default. Launch a Datasette instance
with this directory mounted at /demos so both the demo page and the ESM bundle
are served same-origin (the schema fetch needs same-origin):
uv run python tests/fixtures.py fixtures.db # once, to build fixtures
uv run datasette fixtures.db --static demos:demos/ -p 8001
Then open:
http://localhost:8001/demos/sql-editor-element.html
The editor imports /-/static/datasette-sql-editor.bundle.js (Datasette's built
ESM module, which auto-registers the <datasette-sql-editor> tag on import) and
fetches its schema from /fixtures/-/editor-schema.json.
QA checklist:
[ ] Editor mounts; "ready" logs in the event panel.
[ ] Typing "select st" / "facetable." offers ranked, typed column completions
(schema fetch succeeded). Ctrl-Space forces the completion popup.
[ ] Editing fires "input" events (origin: user).
[ ] Shift-Enter / Mod-Enter fires a cancelable "submit"; with the checkbox
UNchecked the form GET-submits ?sql=... (name=sql). Checked cancels it.
[ ] The plain "Run SQL" button also submits the current editor text as sql=.
[ ] Escape fires "editor-escape".
[ ] Reset button restores the initial document (form reset).
[ ] Format button reformats via window.sqlFormatter (loaded below).
[ ] Read-only toggle disables editing.
[ ] Point schema-url at a bad URL (edit source) -> editor still works,
keyword-only completion, a console.warn appears.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&lt;datasette-sql-editor&gt; demo</title>
<!-- sql-formatter global (window.sqlFormatter) so format() works. -->
<script src="/-/static/sql-formatter-2.3.3.min.js"></script>
<style>
:root {
--datasette-sql-editor-font-size: 14px;
}
body {
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 820px;
margin: 2rem auto;
padding: 0 1rem;
}
datasette-sql-editor {
display: block;
}
.cm-editor {
border: 1px solid #ddd;
border-radius: 4px;
}
.toolbar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0.75rem 0;
align-items: center;
}
#log {
background: #111;
color: #b9f6ca;
font: 12px/1.5 ui-monospace, Menlo, monospace;
padding: 0.75rem;
border-radius: 4px;
height: 200px;
overflow: auto;
white-space: pre-wrap;
}
label {
font-size: 0.9rem;
}
</style>
</head>
<body>
<h1>&lt;datasette-sql-editor&gt; demo</h1>
<p>
Form-associated custom element against the live
<code>fixtures</code> database. See the HTML comment at the top of this file
for launch and QA instructions.
</p>
<form id="f" action="/fixtures/-/query" method="get">
<datasette-sql-editor
id="ed"
name="sql"
database="fixtures"
default-table="facetable"
autofocus
>
select state, county, on_earth
from facetable
where on_earth = 1
order by pk</datasette-sql-editor
>
<div class="toolbar">
<button type="submit">Run SQL</button>
<button type="button" id="format">Format</button>
<button type="reset">Reset</button>
<label
><input type="checkbox" id="cancel" /> cancel submit event</label
>
<label
><input type="checkbox" id="ro" /> read-only</label
>
</div>
</form>
<h3>Events</h3>
<div id="log"></div>
<script type="module">
// Importing the bundle auto-registers <datasette-sql-editor>.
import "/-/static/datasette-sql-editor.bundle.js";
const ed = document.getElementById("ed");
const log = document.getElementById("log");
const line = (m) => {
log.textContent += m + "\n";
log.scrollTop = log.scrollHeight;
};
["ready", "input", "submit", "editor-escape"].forEach((type) => {
ed.addEventListener(type, (e) => {
if (type === "submit" && document.getElementById("cancel").checked) {
e.preventDefault();
line("submit (CANCELED via preventDefault)");
return;
}
line(
type +
(e.detail ? " " + JSON.stringify(e.detail) : "") +
" | value=" +
JSON.stringify(ed.value.replace(/\s+/g, " ").slice(0, 60)),
);
});
});
document.getElementById("format").addEventListener("click", () => {
ed.format();
line("format() called");
});
document.getElementById("ro").addEventListener("change", (e) => {
ed.readOnly = e.target.checked;
line("readOnly = " + e.target.checked);
});
// view escape hatch smoke test
line("view is EditorView? " + (ed.view && ed.view.constructor.name));
</script>
</body>
</html>

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

@ -280,10 +280,57 @@ async def test_query_page_with_no_sql(ds_client):
# https://github.com/simonw/datasette/issues/2743
response = await ds_client.get("/fixtures/-/query")
assert response.status_code == 200
assert '<textarea id="sql-editor" name="sql"' in response.text
assert '<datasette-sql-editor id="sql-editor" name="sql"' in response.text
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 'default-table="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
# default-table at all.
response = await ds_client.get("/fixtures/-/query?sql=select+1")
assert response.status_code == 200
assert "default-table=" 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 "default-table=" 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
@ -876,7 +923,8 @@ async def test_query_error(ds_client):
response = await ds_client.get("/fixtures/-/query?sql=select+*+from+notatable")
html = response.text
assert '<p class="message-error">no such table: notatable</p>' in html
assert '<textarea id="sql-editor" name="sql" style="height: 3em' in html
assert '<datasette-sql-editor id="sql-editor" name="sql"' in html
assert '<textarea name="sql" style="height: 3em' in html
assert ">select * from notatable</textarea>" in html
assert "0 results" not in html

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)
@ -1811,7 +1888,10 @@ async def test_execute_write_get_prepopulates_without_executing():
actor={"id": "root"},
)
assert '<p class="sql-editor sql-editor-min-lines">' in empty_response.text
assert '<textarea id="sql-editor" name="sql"></textarea>' in empty_response.text
assert (
'<datasette-sql-editor id="sql-editor" name="sql"><textarea name="sql"></textarea></datasette-sql-editor>'
in empty_response.text
)
assert "min-height: calc(5.6em + 8px);" in empty_response.text
assert 'executeWriteSqlInput.value = "\\n\\n\\n";' not in empty_response.text
assert "Enter writable SQL before executing." in empty_response.text

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