diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 03838a71ca..05d32512c5 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -13,7 +13,7 @@ import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverr import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; -import { parseInsertValueHints } from "@/lib/sql/insertValueHints"; +import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; @@ -53,7 +53,7 @@ import { type QueryEditorTableReferenceDropDetail, type QueryEditorTableReferencePayload, } from "@/lib/editor/queryEditorTableDrop"; -import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, createRunStatementButtonDom, loadEditorTheme, editorFontTheme, sqlCompletionTheme } from "@/lib/editor/editorThemes"; +import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, createRunStatementButtonDom, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes"; import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; @@ -61,6 +61,7 @@ import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatch import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from "@/lib/editor/codemirrorInsertValueHints"; import { focusEditorView } from "@/lib/editor/queryEditorFocus"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; +import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; @@ -80,6 +81,7 @@ const props = defineProps<{ schema?: string; databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver"; + syntaxDialect?: "mysql" | "postgres" | "sqlserver"; formatDialect?: SqlFormatDialect; formatRequestId?: number; executionError?: string; @@ -216,6 +218,7 @@ let wordWrapComp: import("@codemirror/state").Compartment | null = null; let vimModeComp: import("@codemirror/state").Compartment | null = null; let closeBracketsComp: import("@codemirror/state").Compartment | null = null; let sqlLanguageComp: import("@codemirror/state").Compartment | null = null; +let sqlSemanticHighlightComp: import("@codemirror/state").Compartment | null = null; let codeMirrorCloseBrackets: typeof import("@codemirror/autocomplete").closeBrackets | null = null; let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null; let readOnlyComp: import("@codemirror/state").Compartment | null = null; @@ -233,6 +236,7 @@ let buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | let buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null = null; let buildSqlCompletionExtension: (() => import("@codemirror/state").Extension) | null = null; let buildSqlLanguageExtension: (() => import("@codemirror/state").Extension) | null = null; +let buildSqlSemanticHighlightExtension: (() => import("@codemirror/state").Extension) | null = null; let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion; let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null; let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null; @@ -1380,7 +1384,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) const parts = splitQualifiedIdentifier(identifier); const name = parts[parts.length - 1] ?? identifier; const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined; - const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(sql, pos, { databaseType: props.databaseType, dialect: props.dialect }) : null; + const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(sql, pos, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect }) : null; const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null; const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase()); const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name; @@ -1671,7 +1675,7 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo if (runId !== semanticDiagnosticRunId) return; const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length)); - const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(range.sql, semanticCursor, { databaseType: props.databaseType, dialect: props.dialect }) : null; + const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(range.sql, semanticCursor, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect }) : null; const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis; const { tables, missingTables } = await enrichSemanticDiagnosticTables(semanticAnalysis.tables); const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables); @@ -2034,7 +2038,7 @@ async function provideSqlCompletions(context: CompletionContext) { if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null; const legacyCompletionContext = getSqlCompletionContext(fullDoc, position); - const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.dialect }) : null; + const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect }) : null; const completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext; if (!hasDatabase) { @@ -2686,7 +2690,7 @@ onMounted(async () => { langSql, { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField }, { copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, history, defaultKeymap, historyKeymap }, - { bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap }, + { bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, ensureSyntaxTree }, { searchKeymap }, ] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("@codemirror/autocomplete"), import("@codemirror/commands"), import("@codemirror/language"), import("@codemirror/search")]); editorViewModule = { EditorView, keymap, rectangularSelection } as typeof import("@codemirror/view"); @@ -2699,6 +2703,7 @@ onMounted(async () => { vimModeComp = new Compartment(); closeBracketsComp = new Compartment(); sqlLanguageComp = new Compartment(); + sqlSemanticHighlightComp = new Compartment(); codeMirrorCloseBrackets = closeBrackets; codeMirrorCloseBracketsKeymap = closeBracketsKeymap; readOnlyComp = new Compartment(); @@ -2929,7 +2934,46 @@ onMounted(async () => { override: [async (context: CompletionContext) => provideSqlCompletions(context)], }); - buildSqlLanguageExtension = () => langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, props.dialect, props.databaseType) }); + buildSqlLanguageExtension = () => langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType) }); + buildSqlSemanticHighlightExtension = () => [ + ViewPlugin.fromClass( + class { + decorations: import("@codemirror/view").DecorationSet; + constructor(currentView: import("@codemirror/view").EditorView) { + this.decorations = this.buildDecorations(currentView); + } + update(update: import("@codemirror/view").ViewUpdate) { + if (update.docChanged || update.viewportChanged) this.decorations = this.buildDecorations(update.view); + } + buildDecorations(currentView: import("@codemirror/view").EditorView) { + const sql = currentView.state.doc.toString(); + const windows: Array<{ from: number; to: number }> = []; + for (const visibleRange of currentView.visibleRanges) { + const next = expandToSqlStatementWindow(sql, visibleRange.from, visibleRange.to); + const previous = windows[windows.length - 1]; + if (previous && next.from <= previous.to) previous.to = Math.max(previous.to, next.to); + else windows.push(next); + } + const tree = ensureSyntaxTree(currentView.state, windows[windows.length - 1]?.to ?? 0, 25); + if (!tree) return Decoration.set([]); + const ranges = windows.flatMap((window) => sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, { databaseType: props.databaseType, dialect: props.syntaxDialect ?? props.dialect })); + return Decoration.set( + ranges.map((range) => + Decoration.mark({ + class: "cm-sql-table-name", + attributes: { + "data-sql-token": "table", + }, + }).range(range.start, range.end), + ), + true, + ); + } + }, + { decorations: (value) => value.decorations }, + ), + sqlSemanticHighlightTheme(EditorView), + ]; const initialSettings = settingsStore.editorSettings; const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value); @@ -3086,6 +3130,7 @@ onMounted(async () => { vimModeComp.of(vimModeExtension(initialSettings.vimModeEnabled)), keymap.of([...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap]), sqlLanguageComp.of(buildSqlLanguageExtension()), + sqlSemanticHighlightComp.of(buildSqlSemanticHighlightExtension()), tooltips({ parent: document.body }), completionComp.of(buildSqlCompletionExtension()), sqlCompletionTheme(EditorView), @@ -3434,10 +3479,10 @@ watch( }, ); -watch([() => props.databaseType, () => props.dialect], () => { +watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect], () => { executableStatementRangeCache = null; - if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension) return; - view.value.dispatch({ effects: sqlLanguageComp.reconfigure(buildSqlLanguageExtension()) }); + if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension) return; + view.value.dispatch({ effects: [sqlLanguageComp.reconfigure(buildSqlLanguageExtension()), sqlSemanticHighlightComp.reconfigure(buildSqlSemanticHighlightExtension())] }); }); watch( diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 202746c4fe..8f021ff726 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -71,7 +71,7 @@ import { isTableDataEditable } from "@/lib/table/tableEditing"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; -import { codeMirrorSqlDialect, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { chartableColumnIndexes } from "@/lib/dataGrid/chartData"; import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse"; import * as api from "@/lib/backend/api"; @@ -270,6 +270,7 @@ const activeTabDimension = computed(() => { const activeSqlFormatDialect = computed(() => sqlFormatDialectForDbType(activeEffectiveDatabaseType.value)); const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => codeMirrorSqlDialect(activeEffectiveDatabaseType.value)); +const editorSyntaxDialect = computed<"mysql" | "postgres" | "sqlserver">(() => codeMirrorSqlDialectForConnection(props.activeConnection)); const shortcutModifier = computed(() => (navigator.platform.toLowerCase().includes("mac") ? "Cmd" : "Ctrl")); @@ -810,6 +811,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand :schema="activeTab.schema" :database-type="activeEffectiveDatabaseType" :dialect="editorDialect" + :syntax-dialect="editorSyntaxDialect" :format-dialect="activeSqlFormatDialect" :format-request-id="formatSqlRequest?.tabId === activeTab.id ? formatSqlRequest.id : undefined" :execution-error="activeQueryError" diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts index bbe381b214..fa2d5f71b6 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns } from "@/lib/sql/semantic/completion"; import { SQL_SEMANTIC_BASELINE_FIXTURES, sqlFixtureCursor } from "@/lib/sql/semantic/fixtures"; -import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; +import { buildSqlSemanticModel, sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model"; describe("sqlSemanticModel baseline fixtures", () => { for (const fixture of SQL_SEMANTIC_BASELINE_FIXTURES) { @@ -255,4 +255,78 @@ describe("sqlSemanticModel baseline fixtures", () => { expect(sqlSemanticCompletionScope(buildSqlSemanticModel(sqlite.sql, sqlite.cursor, { databaseType: "sqlite" })).useRemoteMetadata).toBe(true); expect(buildSqlSemanticModel(duckdb.sql, duckdb.cursor, { databaseType: "duckdb" }).rowSources[0]).toEqual(expect.objectContaining({ kind: "table_function", name: "csv", alias: "csv" })); }); + + it("returns only concrete table-name spans for semantic highlighting", () => { + const sql = "SELECT customer_id FROM dbo.wfAdmin AS wa WHERE wa.customer_id > 0"; + const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" }); + + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["wfAdmin"]); + }); + + it("finds table names across statements, subqueries, and comma table lists", () => { + const sql = "SELECT * FROM users u, orders o; SELECT * FROM (SELECT * FROM audit_log) a JOIN dbo.events e ON e.id = a.id"; + const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" }); + + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["users", "orders", "audit_log", "events"]); + }); + + it("highlights mutation targets but ignores strings, comments, and table functions", () => { + const sql = "UPDATE users SET name = 'FROM fake'; INSERT INTO audit_log(id) VALUES (1); -- FROM ignored\nSELECT * FROM read_csv('events.csv') e"; + const spans = sqlSemanticTableNameSpans(sql); + + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["users", "audit_log"]); + }); + + it("skips ASE maintenance keywords before update table targets", () => { + const statements = [ + { sql: "UPDATE STATISTICS wfAdmin", table: "wfAdmin" }, + { sql: "UPDATE INDEX STATISTICS wfAdmin ix_name", table: "wfAdmin" }, + { sql: "UPDATE TABLE STATISTICS dbo.wfAdmin", table: "wfAdmin" }, + { sql: "UPDATE ALL STATISTICS [dbo].[wfAdmin]", table: "wfAdmin" }, + ]; + + for (const { sql, table } of statements) { + const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" }); + const model = buildSqlSemanticModel(sql, sql.length, { dialect: "sqlserver" }); + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual([sql.includes("[wfAdmin]") ? "[wfAdmin]" : table]); + expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: table, kind: "mutation_target" })])); + expect(model.rowSources.some((source) => ["ALL", "INDEX", "TABLE", "STATISTICS"].includes(source.name.toUpperCase()))).toBe(false); + } + }); + + it("does not treat MERGE branch UPDATE as a table introducer", () => { + const sql = "MERGE INTO target_table t USING source_table s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name;"; + const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" }); + const model = buildSqlSemanticModel(sql, sql.length - 1, { dialect: "sqlserver" }); + + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["target_table", "source_table"]); + expect(model.rowSources.some((source) => source.name.toLowerCase() === "set")).toBe(false); + }); + + it("does not treat MySQL upsert UPDATE as a table introducer", () => { + const sql = "INSERT INTO users (id, name) VALUES (1, 'A') ON DUPLICATE KEY UPDATE name = VALUES(name);"; + const spans = sqlSemanticTableNameSpans(sql, { dialect: "mysql" }); + const model = buildSqlSemanticModel(sql, sql.length - 1, { dialect: "mysql" }); + + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["users"]); + expect(model.rowSources.some((source) => source.name === "name")).toBe(false); + }); + + it("keeps CTE UPDATE mutation targets", () => { + const sql = "WITH candidates AS (SELECT id FROM staging) UPDATE users SET active = 1 WHERE id IN (SELECT id FROM candidates);"; + const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" }); + const model = buildSqlSemanticModel(sql, sql.length - 1, { dialect: "sqlserver" }); + + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["staging", "users", "candidates"]); + expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "users", kind: "mutation_target" })])); + }); + + it("recognizes SQL Server local, global, and tempdb-qualified temporary tables", () => { + const sql = "SELECT * FROM #temp; SELECT * FROM ##global_temp; SELECT * FROM tempdb..#temp"; + const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" }); + const model = buildSqlSemanticModel(sql, sql.length, { dialect: "sqlserver" }); + + expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["#temp", "##global_temp", "#temp"]); + expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "#temp", kind: "table" })])); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts index 5b66d99a10..e8c0c55bd1 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts @@ -21,6 +21,15 @@ describe("sqlSemanticTokens", () => { expect(isSuppressedSqlSemanticContext(tokens, "select * from users".length)).toBe(false); }); + it("treats hash prefixes as identifiers for SQL Server but comments for MySQL", () => { + const sqlServerSql = "SELECT * FROM #temp; SELECT * FROM ##global_temp; SELECT * FROM tempdb..#temp"; + const sqlServerTokens = tokenizeSqlSemantic(sqlServerSql, "sqlserver"); + + expect(sqlServerTokens.filter((token) => token.kind === "word" && token.text.startsWith("#")).map((token) => token.text)).toEqual(["#temp", "##global_temp", "#temp"]); + expect(sqlServerTokens.some((token) => token.kind === "comment")).toBe(false); + expect(tokenizeSqlSemantic("SELECT 1 # comment", "mysql").some((token) => token.kind === "comment" && token.text === "# comment")).toBe(true); + }); + it("finds active statement spans across semicolon-separated scripts", () => { const sql = "select * from users;\nselect * from orders where id = 1;"; const cursor = sql.indexOf("orders"); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 7ac1392ab0..1a92eb2f2c 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -1,7 +1,7 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; -type JdbcDialectConnection = Pick & Partial>; +type JdbcDialectConnection = Pick & Partial>; const DATABASE_AS_EXECUTION_SCHEMA_TYPES = new Set(["hive", "spark"]); @@ -23,6 +23,11 @@ const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> = { type: "iris", patterns: [/jdbc:(?:iris|cache):/i, /com\.intersystems\.jdbc\.(?:IRIS|Cache)Driver/i, /intersystems-jdbc/i] }, ]; +// ASE uses Transact-SQL, but treating it as SQL Server globally would also +// enable SQL Server metadata, pagination, and identifier rules. Keep this +// narrower matcher exclusively for editor syntax parsing. +const JDBC_ASE_PROFILE_PATTERNS = [/(?:^|[\s_-])ase(?:$|[\s_-])/i, /\bsap[\s_-]+ase\b/i, /\badaptive server enterprise\b/i]; + export function inferJdbcDialect(connection?: JdbcDialectConnection): DatabaseType | undefined { if (!connection || connection.db_type !== "jdbc") return undefined; const haystack = [connection.driver_profile, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? [])].filter(Boolean).join("\n"); @@ -106,6 +111,14 @@ export function codeMirrorSqlDialect(dbType: DatabaseType | undefined): "mysql" return "mysql"; } +export function codeMirrorSqlDialectForConnection(connection?: JdbcDialectConnection): "mysql" | "postgres" | "sqlserver" { + if (connection?.db_type === "jdbc") { + const explicitIdentity = [connection.driver_profile, connection.driver_label, connection.database_info?.productName].filter(Boolean).join("\n"); + if (JDBC_ASE_PROFILE_PATTERNS.some((pattern) => pattern.test(explicitIdentity))) return "sqlserver"; + } + return codeMirrorSqlDialect(effectiveDatabaseTypeForConnection(connection)); +} + function isGbase8sProfile(driverProfile?: string): boolean { return driverProfile === "gbase8s"; } diff --git a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts index 19db8b898e..de1cb837d0 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts @@ -66,6 +66,7 @@ function codeMirrorBaseDialect(langSql: CodeMirrorSqlLanguageModule, dialectName if (SQLITE_CODEMIRROR_DATABASE_TYPES.has(databaseType)) return langSql.SQLite; if (databaseType === "sqlserver") return langSql.MSSQL; if (databaseType === "cassandra") return langSql.Cassandra; + if (databaseType === "jdbc" && dialectName === "sqlserver") return langSql.MSSQL; return langSql.StandardSQL; } return dialectName === "postgres" ? langSql.PostgreSQL : dialectName === "sqlserver" ? langSql.MSSQL : langSql.MySQL; diff --git a/apps/desktop/src/lib/editor/codemirrorSqlSemanticHighlight.ts b/apps/desktop/src/lib/editor/codemirrorSqlSemanticHighlight.ts new file mode 100644 index 0000000000..2d939c3261 --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorSqlSemanticHighlight.ts @@ -0,0 +1,39 @@ +import { sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model"; +import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types"; + +type SqlSyntaxTree = ReturnType; + +const SUPPRESSED_NODE_NAMES = new Set(["String", "LineComment", "BlockComment"]); + +export interface SqlSemanticHighlightWindow { + from: number; + to: number; +} + +function maskSuppressedSyntax(sql: string, window: SqlSemanticHighlightWindow, tree: SqlSyntaxTree): string { + const ranges: SqlSemanticHighlightWindow[] = []; + tree.iterate({ + from: window.from, + to: window.to, + enter(node) { + if (!SUPPRESSED_NODE_NAMES.has(node.name)) return; + ranges.push({ from: Math.max(window.from, node.from), to: Math.min(window.to, node.to) }); + return false; + }, + }); + + let cursor = window.from; + let masked = ""; + for (const range of ranges) { + if (range.from < cursor) continue; + masked += sql.slice(cursor, range.from); + masked += sql.slice(range.from, range.to).replace(/[^\r\n]/g, " "); + cursor = range.to; + } + return masked + sql.slice(cursor, window.to); +} + +export function sqlSemanticTableNameSpansForSyntaxTree(sql: string, window: SqlSemanticHighlightWindow, tree: SqlSyntaxTree, options: SqlSemanticBuildOptions = {}): SqlSemanticSpan[] { + const maskedSql = maskSuppressedSyntax(sql, window, tree); + return sqlSemanticTableNameSpans(maskedSql, options).map((span) => ({ start: span.start + window.from, end: span.end + window.from })); +} diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index 9f99bcaa30..bfd7d4b13c 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -9,6 +9,7 @@ type LucideIconNode = Array<[string, Record]>; export const EDITOR_FONT_SIZE_CSS_VAR = "--dbx-editor-font-size"; export const EDITOR_FONT_FAMILY_CSS_VAR = "--dbx-editor-font-family"; +export const SQL_TABLE_COLOR_CSS_VAR = "--dbx-sql-table-color"; const EDITOR_SELECTION_BACKGROUND_CSS_VAR = "--dbx-editor-selection-background"; export function createRunStatementButtonDom(ariaLabel = "Execute statement"): HTMLButtonElement { @@ -21,6 +22,14 @@ export function createRunStatementButtonDom(ariaLabel = "Execute statement"): HT return marker; } +export function sqlSemanticHighlightTheme(EditorView: typeof import("@codemirror/view").EditorView): Extension { + return EditorView.theme({ + ".cm-sql-table-name, .cm-sql-table-name *": { + color: `var(${SQL_TABLE_COLOR_CSS_VAR}) !important`, + }, + }); +} + const SUPPORTS_COLOR_MIX = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "color-mix(in oklch, black 50%, white)"); const SUPPORTS_OKLCH = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "oklch(0.62 0.19 255)"); @@ -72,11 +81,8 @@ function createCustomTheme(EditorView: typeof import("@codemirror/view").EditorV c.variable = colors.field; c.property = colors.field; } - if (colors.table) { - // 表名通常被识别为 propertyName,如果单独设置了表名颜色则覆盖 - c.property = colors.table; - } } + const tableColor = colors?.table || c.property; const theme = EditorView.theme( { @@ -84,6 +90,7 @@ function createCustomTheme(EditorView: typeof import("@codemirror/view").EditorV backgroundColor: c.background, color: c.foreground, [EDITOR_SELECTION_BACKGROUND_CSS_VAR]: c.selection, + [SQL_TABLE_COLOR_CSS_VAR]: tableColor, }, ".cm-content": { caretColor: c.cursor, @@ -212,6 +219,7 @@ type IdeEditorThemeColors = { operator: string; punctuation: string; property: string; + table: string; builtin: string; meta: string; invalid: string; @@ -247,6 +255,7 @@ const IDE_EDITOR_THEMES = { operator: "#080808", punctuation: "#080808", property: "#871094", + table: "#871094", builtin: "#0033b3", meta: "#9e880d", invalid: "#f50000", @@ -280,6 +289,7 @@ const IDE_EDITOR_THEMES = { operator: "#cc7832", punctuation: "#a9b7c6", property: "#9876aa", + table: "#9876aa", builtin: "#cc7832", meta: "#bbb529", invalid: "#ff0000", @@ -310,6 +320,7 @@ const IDE_EDITOR_THEMES = { operator: "#080808", punctuation: "#080808", property: "#871094", + table: "#871094", builtin: "#0033b3", meta: "#9e880d", invalid: "#f50000", @@ -343,6 +354,7 @@ const IDE_EDITOR_THEMES = { operator: "#bcbec4", punctuation: "#bcbec4", property: "#c77dbb", + table: "#c77dbb", builtin: "#cf8e6d", meta: "#b3ae60", invalid: "#f75464", @@ -373,6 +385,7 @@ const IDE_EDITOR_THEMES = { operator: "#b3003f", punctuation: "#141414eb", property: "#1f8a65", + table: "#1f8a65", builtin: "#206595", meta: "#1f8a65", invalid: "#cf2d56", @@ -403,6 +416,7 @@ const IDE_EDITOR_THEMES = { operator: "#d6d6dd", punctuation: "#d6d6dd", property: "#82d2ce", + table: "#aaa0fa", builtin: "#a8cc7c", meta: "#a8cc7c", invalid: "#e34671", @@ -433,6 +447,7 @@ const IDE_EDITOR_THEMES = { operator: "#6b5544", punctuation: "#6b5544", property: "#9a4f2e", + table: "#7a5aa8", builtin: "#4f7d5d", meta: "#8f6b2e", invalid: "#c3493d", @@ -463,6 +478,7 @@ const IDE_EDITOR_THEMES = { operator: "#e0d0c0", punctuation: "#c9b9a7", property: "#d28a5f", + table: "#a08fcd", builtin: "#74b195", meta: "#d4ae63", invalid: "#e2675f", @@ -479,6 +495,7 @@ function createIdeEditorTheme(EditorView: typeof import("@codemirror/view").Edit backgroundColor: c.background, color: c.foreground, [EDITOR_SELECTION_BACKGROUND_CSS_VAR]: c.selection, + [SQL_TABLE_COLOR_CSS_VAR]: c.table, }, ".cm-scroller": { backgroundColor: c.background, diff --git a/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts b/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts index dbcaa44b8e..60e5815e13 100644 --- a/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts +++ b/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts @@ -44,7 +44,7 @@ export function hiddenResultColumnIndexes(columns: string[], projections: Hidden function appendSelectProjections(sql: string, expressions: string[], databaseType: DatabaseType): string | undefined { if (expressions.length === 0) return sql; - const tokens = tokenizeSqlSemantic(sql); + const tokens = tokenizeSqlSemantic(sql, sqlSemanticDialectFor({ databaseType }).id); const selectIndex = tokens.findIndex((token) => token.kind === "word" && token.depth === 0 && token.normalized === "select"); if (selectIndex < 0) return undefined; const fromIndex = tokens.findIndex((token, index) => index > selectIndex && token.kind === "word" && token.depth === 0 && token.normalized === "from"); diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index 14ea65b323..636847d62f 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -22,6 +22,10 @@ const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "ful const CLAUSE_BOUNDARIES = new Set(["where", "group", "having", "order", "limit", "offset", "union", "intersect", "except", "on", "set", "values", "returning"]); const FROM_CLAUSE_BOUNDARIES = new Set([...CLAUSE_BOUNDARIES, "window", "qualify", "fetch", "for", "connect", "start", "model"].filter((item) => item !== "on")); const ALIAS_BLACKLIST = new Set([...CLAUSE_BOUNDARIES, "join", "straight_join", "left", "right", "inner", "outer", "cross", "full", "natural", "as", "select", "from", "with"]); +const TABLE_TARGET_MODIFIERS = new Set(["lateral", "only"]); +const TABLE_FUNCTION_INTRODUCERS = new Set(["from", "join", "straight_join", "apply"]); +const TOP_LEVEL_STATEMENT_WORDS = new Set(["select", "insert", "delete", "merge", "create", "alter", "drop", "truncate", "call", "exec", "execute", "grant", "revoke"]); +const SQLSERVER_UPDATE_STATISTICS_SCOPES = new Set(["all", "index", "table"]); interface ParseState { dialect: SqlSemanticDialectAdapter; @@ -81,6 +85,9 @@ function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: numb break; } index += 2; + if (dialect.id === "sqlserver") { + while (tokens[index]?.text === ".") index += 1; + } } if (parts.length === 0) return null; return { @@ -92,6 +99,74 @@ function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: numb }; } +function sqlServerMaintenanceTableTarget(tokens: readonly SqlSemanticToken[], target: number, introducer: string, dialect: SqlSemanticDialectAdapter): number { + if (dialect.id !== "sqlserver" || introducer !== "update") return target; + if (tokens[target]?.normalized === "statistics") return target + 1; + // ASE accepts UPDATE {ALL | INDEX | TABLE} STATISTICS; these scope words + // describe the maintenance operation and must never become table targets. + if (SQLSERVER_UPDATE_STATISTICS_SCOPES.has(tokens[target]?.normalized ?? "") && tokens[target + 1]?.normalized === "statistics") return target + 2; + return target; +} + +function updateIntroducesMutationTarget(tokens: readonly SqlSemanticToken[], updateIndex: number): boolean { + const update = tokens[updateIndex]; + if (update?.kind !== "word" || update.normalized !== "update") return false; + for (let index = updateIndex - 1; index >= 0; index -= 1) { + const item = tokens[index]; + if (!item || item.depth !== update.depth) continue; + if (item.text === ";") break; + if (item.kind === "word" && (item.normalized === "update" || TOP_LEVEL_STATEMENT_WORDS.has(item.normalized))) return false; + } + return true; +} + +function commaContinuesTableList(tokens: readonly SqlSemanticToken[], commaIndex: number): boolean { + const comma = tokens[commaIndex]; + if (comma?.text !== ",") return false; + for (let index = commaIndex - 1; index >= 0; index -= 1) { + const item = tokens[index]; + if (!item || item.depth !== comma.depth || item.kind !== "word") continue; + if (item.normalized === "from") return true; + if (item.normalized === "select" || item.normalized === "join" || TABLE_INTRODUCERS.has(item.normalized) || CLAUSE_BOUNDARIES.has(item.normalized)) return false; + } + return false; +} + +/** + * Finds concrete table-name tokens for visual highlighting without consulting + * metadata. Only the final identifier in a qualified name is returned, so + * schemas/catalogs and aliases keep the regular identifier color. + */ +export function sqlSemanticTableNameSpans(sql: string, options: SqlSemanticBuildOptions = {}): SqlSemanticSpan[] { + const dialect = sqlSemanticDialectFor(options); + const tokens = significantTokens(tokenizeSqlSemantic(sql, dialect.id)); + const spans: SqlSemanticSpan[] = []; + const seen = new Set(); + + for (let index = 0; index < tokens.length; index += 1) { + const item = tokens[index]; + const introduced = item?.kind === "word" && TABLE_INTRODUCERS.has(item.normalized) && (item.normalized !== "update" || updateIntroducesMutationTarget(tokens, index)); + if (!introduced && !commaContinuesTableList(tokens, index)) continue; + + let target = index + 1; + while (TABLE_TARGET_MODIFIERS.has(tokens[target]?.normalized ?? "")) target += 1; + target = sqlServerMaintenanceTableTarget(tokens, target, item?.normalized ?? "", dialect); + if (tokens[target]?.text === "(") continue; + + const qualified = readQualifiedName(tokens, target, dialect); + const followedByParenthesis = qualified && tokens[qualified.nextIndex]?.text === "("; + if (!qualified || tokens[target]?.depth !== item?.depth || (followedByParenthesis && (!introduced || TABLE_FUNCTION_INTRODUCERS.has(item.normalized)))) continue; + const tablePart = qualified.name.parts[qualified.name.parts.length - 1]; + if (!tablePart) continue; + const key = `${tablePart.span.start}:${tablePart.span.end}`; + if (seen.has(key)) continue; + seen.add(key); + spans.push(tablePart.span); + } + + return spans; +} + function sourceNameFromQualifiedName(name: SqlSemanticQualifiedName): { name: string; qualifierParts: string[] } { const parts = name.parts.map((part) => part.name); return { @@ -379,9 +454,11 @@ function parseRowSources(state: ParseState): SqlSemanticRowSource[] { if (item.kind !== "word") continue; const normalized = item.normalized; if (!TABLE_INTRODUCERS.has(normalized)) continue; + if (normalized === "update" && !updateIntroducesMutationTarget(state.tokens, index)) continue; if (JOIN_MODIFIERS.has(normalized)) continue; let target = index + 1; while (JOIN_MODIFIERS.has(state.tokens[target]?.normalized ?? "")) target += 1; + target = sqlServerMaintenanceTableTarget(state.tokens, target, normalized, state.dialect); for (;;) { const parsed = parseRowSource(state, target, normalized, sources.length); if (!parsed) break; @@ -614,7 +691,7 @@ function buildScope(statement: SqlSemanticStatement, rowSources: SqlSemanticRowS export function buildSqlSemanticModel(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): SqlSemanticModel { const safeCursor = Math.max(0, Math.min(cursor, sql.length)); const dialect = sqlSemanticDialectFor(options); - const allTokens = tokenizeSqlSemantic(sql); + const allTokens = tokenizeSqlSemantic(sql, dialect.id); const statementSpan = findActiveSqlStatementSpan(sql, allTokens, safeCursor); const tokens = significantTokens(allTokens.filter((item) => item.span.end > statementSpan.start && item.span.start < statementSpan.end)); const kind = statementKind(tokens); diff --git a/apps/desktop/src/lib/sql/semantic/tokens.ts b/apps/desktop/src/lib/sql/semantic/tokens.ts index adecd87f7f..b333e71484 100644 --- a/apps/desktop/src/lib/sql/semantic/tokens.ts +++ b/apps/desktop/src/lib/sql/semantic/tokens.ts @@ -29,7 +29,7 @@ function readQuoted(input: string, start: number, open: string, close: string): return input.length; } -export function tokenizeSqlSemantic(input: string): SqlSemanticToken[] { +export function tokenizeSqlSemantic(input: string, dialectId = "mysql"): SqlSemanticToken[] { const tokens: SqlSemanticToken[] = []; let index = 0; let depth = 0; @@ -51,7 +51,7 @@ export function tokenizeSqlSemantic(input: string): SqlSemanticToken[] { continue; } - if (ch === "#") { + if (ch === "#" && dialectId === "mysql") { index += 1; while (index < input.length && input[index] !== "\n" && input[index] !== "\r") index += 1; tokens.push(token("comment", input.slice(start, index), start, index, depth)); diff --git a/packages/app-tests/codemirrorSqlDialect.test.ts b/packages/app-tests/codemirrorSqlDialect.test.ts index 5fb224f55c..c9ef64ef9d 100644 --- a/packages/app-tests/codemirrorSqlDialect.test.ts +++ b/packages/app-tests/codemirrorSqlDialect.test.ts @@ -27,6 +27,18 @@ test("adds SQL Server READONLY for table-valued procedure parameters", () => { assert.equal(countParsedNodes(dialect, "CREATE PROCEDURE [dbo].[gylxcx](@tp2 XTableType5 readonly,@tp xtabletype2 readonly) AS SELECT 1", "Keyword", "readonly"), 2); }); +test("uses MSSQL keywords for an ASE JDBC editor override", () => { + const dialect = createDbxCodeMirrorSqlDialect(langSql, "sqlserver", "jdbc"); + + assert.equal(countParsedNodes(dialect, "SELECT top 1 * FROM wfAdmin AS wa", "Keyword", "top"), 1); +}); + +test("keeps generic JDBC on Standard SQL without the ASE editor override", () => { + const dialect = createDbxCodeMirrorSqlDialect(langSql, "mysql", "jdbc"); + + assert.equal(countParsedNodes(dialect, "SELECT top 1 * FROM wfAdmin AS wa", "Keyword", "top"), 0); +}); + test("keeps DBX PostgreSQL procedural dialect extensions", () => { const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres"); diff --git a/packages/app-tests/jdbcDialect.test.ts b/packages/app-tests/jdbcDialect.test.ts index 0f1673f160..8dfb9f1694 100644 --- a/packages/app-tests/jdbcDialect.test.ts +++ b/packages/app-tests/jdbcDialect.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { effectiveDatabaseTypeForConnection, inferJdbcDialect } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; +import { codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; test("infers GoldenDB for generic JDBC connections", () => { assert.equal( @@ -28,3 +28,31 @@ test("infers JDBC dialect from driver profile", () => { "sqlserver", ); }); + +test("uses SQL Server editor syntax for ASE without changing its effective JDBC type", () => { + const aseConnections = [ + { db_type: "jdbc" as const, driver_profile: "ase" }, + { db_type: "jdbc" as const, driver_label: "SAP ASE 15" }, + { db_type: "jdbc" as const, database_info: { productName: "Adaptive Server Enterprise" } }, + ]; + + for (const connection of aseConnections) { + assert.equal(codeMirrorSqlDialectForConnection(connection), "sqlserver"); + assert.equal(effectiveDatabaseTypeForConnection(connection), "jdbc"); + } +}); + +test("keeps non-ASE jConnect profiles on generic JDBC syntax", () => { + const iqConnection = { + db_type: "jdbc" as const, + driver_profile: "jdbc", + driver_label: "SAP IQ", + connection_string: "jdbc:sybase:Tds:127.0.0.1:2638/app", + jdbc_driver_class: "com.sybase.jdbc4.jdbc.SybDriver", + jdbc_driver_paths: ["C:\\drivers\\jconn4.jar"], + database_info: { productName: "SAP IQ" }, + }; + + assert.equal(codeMirrorSqlDialectForConnection(iqConnection), "mysql"); + assert.equal(effectiveDatabaseTypeForConnection(iqConnection), "jdbc"); +}); diff --git a/packages/app-tests/sqlSemanticHighlight.test.ts b/packages/app-tests/sqlSemanticHighlight.test.ts new file mode 100644 index 0000000000..588fde694a --- /dev/null +++ b/packages/app-tests/sqlSemanticHighlight.test.ts @@ -0,0 +1,21 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import * as langSql from "@codemirror/lang-sql"; +import { sqlSemanticTableNameSpansForSyntaxTree } from "../../apps/desktop/src/lib/editor/codemirrorSqlSemanticHighlight.ts"; +import { expandToSqlStatementWindow } from "../../apps/desktop/src/lib/sql/insertValueHints.ts"; + +test.each([ + ["string", `'${"x".repeat(40 * 1024)} FROM fake'`], + ["block comment", `/* ${"x".repeat(40 * 1024)} FROM fake */`], +])("preserves %s context when the highlight window starts inside it", (_name, suppressedSql) => { + const sql = `SELECT ${suppressedSql} AS payload;\nSELECT * FROM real_table;`; + const realTable = sql.indexOf("real_table"); + const window = expandToSqlStatementWindow(sql, realTable, realTable + "real_table".length); + const tree = langSql.StandardSQL.language.parser.parse(sql); + + assert.ok(window.from > sql.indexOf(suppressedSql)); + assert.deepEqual( + sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree).map((span) => sql.slice(span.start, span.end)), + ["real_table"], + ); +});