diff --git a/apps/desktop/src/components/diff/SchemaDiffDeployStep.vue b/apps/desktop/src/components/diff/SchemaDiffDeployStep.vue index 0c6b1a5337..06d275cc09 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDeployStep.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDeployStep.vue @@ -7,6 +7,7 @@ import { useToast } from "@/composables/useToast"; import { useSettingsStore } from "@/stores/settingsStore"; import { useTheme } from "@/composables/useTheme"; import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; +import { createDbxCodeMirrorSqlDialect } from "@/lib/codemirrorSqlDialect"; import { Splitpanes, Pane } from "splitpanes"; import type { SchemaDiffObject, DiffOperationType, DiffObjectKind } from "@/lib/schemaDiff"; import { ArrowLeft, Copy, Download, Play, Loader2, PlusCircle, XCircle, ArrowRightLeft, Table, Eye, FunctionSquare, ListOrdered, ScrollText, UserCog, ListTree, Link2, Zap } from "@lucide/vue"; @@ -109,7 +110,7 @@ const operationColors: Record = { async function initEditor() { if (!editorContainer.value) return; - const [{ EditorView }, { EditorState, Compartment }, { sql, PostgreSQL, SQLDialect }, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]); + const [{ EditorView }, { EditorState, Compartment }, langSql, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]); const themeComp = new Compartment(); const fontComp = new Compartment(); @@ -122,25 +123,13 @@ async function initEditor() { const themeExt = await loadEditorTheme(editorTheme, appAppearance); const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true }); - // Custom PostgreSQL dialect with PL/pgSQL support (same as QueryEditor.vue) - const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT"; - const plpgsqlKeywords = "PERFORM"; - const plpgsqlTypes = " RECORD JSON JSONB"; - const plpgsqlBuiltin = "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV"; - - const dialect = SQLDialect.define({ - ...PostgreSQL.spec, - keywords: [PostgreSQL.spec.keywords || "", extraKeywords, plpgsqlKeywords].filter(Boolean).join(" "), - types: [PostgreSQL.spec.types || "", plpgsqlTypes].filter(Boolean).join(" ") || undefined, - builtin: [PostgreSQL.spec.builtin || "", plpgsqlBuiltin].filter(Boolean).join(" ") || undefined, - doubleDollarQuotedStrings: false, - }); + const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres"); const state = EditorState.create({ doc: props.deploySql, extensions: [ basicSetup, - sql({ dialect }), + langSql.sql({ dialect }), themeComp.of(themeExt), fontComp.of(fontExt), EditorView.updateListener.of((update: any) => { diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 5daf84208e..0e1c1b4cb4 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -38,6 +38,7 @@ import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestu import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/codemirrorTrimmedSelectionLayer"; import { selectionMatchOccurrences } from "@/lib/codemirrorSelectionMatches"; +import { createDbxCodeMirrorSqlDialect } from "@/lib/codemirrorSqlDialect"; import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport"; import { qualifiedTableNameAtSqlPosition } from "@/lib/queryCursorTableTarget"; import * as api from "@/lib/api"; @@ -1675,7 +1676,7 @@ onMounted(async () => { const [ { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, ViewPlugin }, { EditorState, Compartment, Prec, StateEffect, StateField }, - { sql, MSSQL, MySQL, PostgreSQL, SQLDialect }, + langSql, { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap }, { copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, history, defaultKeymap, historyKeymap }, { bracketMatching, foldGutter, indentOnInput, syntaxHighlighting, defaultHighlightStyle, foldKeymap }, @@ -1791,22 +1792,7 @@ onMounted(async () => { override: [async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos, context.explicit)], }); - const baseDialect = props.dialect === "postgres" ? PostgreSQL : props.dialect === "sqlserver" ? MSSQL : MySQL; - const extraKeywords = "PIVOT UNPIVOT EXCLUDE REPLACE QUALIFY ASOF POSITIONAL ANTI SEMI SAMPLE TABLESAMPLE STRUCT MAP LIST ARRAY LAMBDA UNNEST LATERAL FILTER RECURSIVE SUMMARIZE PRAGMA READ_CSV READ_PARQUET READ_JSON DESCRIBE SHOW COPY EXPORT IMPORT"; - - // PL/pgSQL extension: add procedural language keywords and built-in variables for PostgreSQL function/procedure bodies - const isPostgres = props.dialect === "postgres"; - const plpgsqlKeywords = isPostgres ? "PERFORM" : ""; - const plpgsqlTypes = isPostgres ? " RECORD JSON JSONB" : ""; - const plpgsqlBuiltin = isPostgres ? "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV" : ""; - - const dialect = SQLDialect.define({ - ...baseDialect.spec, - keywords: [baseDialect.spec.keywords || "", extraKeywords, plpgsqlKeywords].filter(Boolean).join(" "), - types: [baseDialect.spec.types || "", plpgsqlTypes].filter(Boolean).join(" ") || undefined, - builtin: [baseDialect.spec.builtin || "", plpgsqlBuiltin].filter(Boolean).join(" ") || undefined, - doubleDollarQuotedStrings: false, - }); + const dialect = createDbxCodeMirrorSqlDialect(langSql, props.dialect); const initialSettings = settingsStore.editorSettings; const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors()); @@ -1878,7 +1864,7 @@ onMounted(async () => { crosshairCursor(), activeLineHighlighter, keymap.of([...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap]), - sql({ dialect }), + langSql.sql({ dialect }), tooltips({ parent: document.body }), completionComp.of(buildSqlCompletionExtension()), sqlCompletionTheme(EditorView), diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index 0ae73bf142..b1427ca949 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -6,6 +6,7 @@ import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useSettingsStore } from "@/stores/settingsStore"; import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; +import { createDbxCodeMirrorSqlDialect } from "@/lib/codemirrorSqlDialect"; import { copyToClipboard } from "@/lib/clipboard"; import * as api from "@/lib/api"; import { Button } from "@/components/ui/button"; @@ -82,8 +83,7 @@ async function initDdlEditor(content: string) { const fontFamily = settingsStore.editorSettings.fontFamily; const themeExt = await loadEditorTheme(editorTheme, appAppearance); const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true }); - const baseDialect = props.dialect === "postgres" ? langSql.PostgreSQL : props.dialect === "sqlserver" ? langSql.MSSQL : langSql.MySQL; - const dialect = langSql.SQLDialect.define({ ...baseDialect.spec }); + const dialect = createDbxCodeMirrorSqlDialect(langSql, props.dialect); const state = EditorState.create({ doc: content, extensions: [ diff --git a/apps/desktop/src/lib/codemirrorSqlDialect.ts b/apps/desktop/src/lib/codemirrorSqlDialect.ts new file mode 100644 index 0000000000..08fb55f843 --- /dev/null +++ b/apps/desktop/src/lib/codemirrorSqlDialect.ts @@ -0,0 +1,59 @@ +import type { SQLDialect } from "@codemirror/lang-sql"; + +export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver"; + +type CodeMirrorSqlLanguageModule = Pick; + +const DBX_COMMON_SQL_KEYWORDS = [ + "PIVOT", + "UNPIVOT", + "EXCLUDE", + "REPLACE", + "QUALIFY", + "ASOF", + "POSITIONAL", + "ANTI", + "SEMI", + "SAMPLE", + "TABLESAMPLE", + "STRUCT", + "MAP", + "LIST", + "ARRAY", + "LAMBDA", + "UNNEST", + "LATERAL", + "FILTER", + "RECURSIVE", + "SUMMARIZE", + "PRAGMA", + "READ_CSV", + "READ_PARQUET", + "READ_JSON", + "DESCRIBE", + "SHOW", + "COPY", + "EXPORT", + "IMPORT", +].join(" "); + +const POSTGRES_PLPGSQL_KEYWORDS = "PERFORM"; +const POSTGRES_PLPGSQL_TYPES = "RECORD JSON JSONB"; +const POSTGRES_PLPGSQL_BUILTIN = "SQLERRM TG_NAME TG_WHEN TG_LEVEL TG_OP TG_RELID TG_RELNAME TG_TABLE_NAME TG_TABLE_SCHEMA TG_NARGS TG_ARGV"; + +// SQL Server table-valued parameters require READONLY in procedure/function declarations. +const SQLSERVER_KEYWORDS = "readonly"; + +export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModule, dialectName: CodeMirrorSqlDialectName = "mysql"): SQLDialect { + const baseDialect = dialectName === "postgres" ? langSql.PostgreSQL : dialectName === "sqlserver" ? langSql.MSSQL : langSql.MySQL; + const isPostgres = dialectName === "postgres"; + const isSqlServer = dialectName === "sqlserver"; + + return langSql.SQLDialect.define({ + ...baseDialect.spec, + keywords: [baseDialect.spec.keywords || "", DBX_COMMON_SQL_KEYWORDS, isPostgres ? POSTGRES_PLPGSQL_KEYWORDS : "", isSqlServer ? SQLSERVER_KEYWORDS : ""].filter(Boolean).join(" "), + types: [baseDialect.spec.types || "", isPostgres ? POSTGRES_PLPGSQL_TYPES : ""].filter(Boolean).join(" ") || undefined, + builtin: [baseDialect.spec.builtin || "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : ""].filter(Boolean).join(" ") || undefined, + doubleDollarQuotedStrings: false, + }); +} diff --git a/packages/app-tests/codemirrorSqlDialect.test.ts b/packages/app-tests/codemirrorSqlDialect.test.ts new file mode 100644 index 0000000000..d89bf62243 --- /dev/null +++ b/packages/app-tests/codemirrorSqlDialect.test.ts @@ -0,0 +1,33 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import * as langSql from "@codemirror/lang-sql"; +import { createDbxCodeMirrorSqlDialect } from "../../apps/desktop/src/lib/codemirrorSqlDialect.ts"; + +function hasKeyword(keywords: string | undefined, keyword: string): boolean { + return new RegExp(`(?:^|\\s)${keyword}(?:\\s|$)`, "i").test(keywords || ""); +} + +function countParsedNodes(dialect: langSql.SQLDialect, sql: string, nodeName: string, text: string): number { + const tree = dialect.language.parser.parse(sql); + const cursor = tree.cursor(); + let count = 0; + do { + if (cursor.name === nodeName && sql.slice(cursor.from, cursor.to).toLowerCase() === text.toLowerCase()) count++; + } while (cursor.next()); + return count; +} + +test("adds SQL Server READONLY for table-valued procedure parameters", () => { + const dialect = createDbxCodeMirrorSqlDialect(langSql, "sqlserver"); + + assert.equal(hasKeyword(dialect.spec.keywords, "READONLY"), true); + assert.equal(countParsedNodes(dialect, "CREATE PROCEDURE [dbo].[gylxcx](@tp2 XTableType5 readonly,@tp xtabletype2 readonly) AS SELECT 1", "Keyword", "readonly"), 2); +}); + +test("keeps DBX PostgreSQL procedural dialect extensions", () => { + const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres"); + + assert.equal(hasKeyword(dialect.spec.keywords, "PERFORM"), true); + assert.equal(hasKeyword(dialect.spec.types, "JSONB"), true); + assert.equal(hasKeyword(dialect.spec.builtin, "TG_NAME"), true); +});