fix(editor): improve ASE SQL semantic highlighting

This commit is contained in:
zipg
2026-07-16 17:00:55 +08:00
committed by GitHub
parent df1d31e74f
commit 3d23a58e36
14 changed files with 360 additions and 22 deletions
@@ -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(
@@ -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<SqlFormatDialect>(() => 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"
+75 -1
View File
@@ -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" })]));
});
});
@@ -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");
+14 -1
View File
@@ -1,7 +1,7 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
type JdbcDialectConnection = Pick<ConnectionConfig, "db_type"> & Partial<Pick<ConnectionConfig, "driver_profile" | "connection_string" | "jdbc_driver_class" | "jdbc_driver_paths">>;
type JdbcDialectConnection = Pick<ConnectionConfig, "db_type"> & Partial<Pick<ConnectionConfig, "driver_profile" | "driver_label" | "connection_string" | "jdbc_driver_class" | "jdbc_driver_paths" | "database_info">>;
const DATABASE_AS_EXECUTION_SCHEMA_TYPES = new Set<DatabaseType>(["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";
}
@@ -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;
@@ -0,0 +1,39 @@
import { sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model";
import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types";
type SqlSyntaxTree = ReturnType<typeof import("@codemirror/language").syntaxTree>;
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 }));
}
+21 -4
View File
@@ -9,6 +9,7 @@ type LucideIconNode = Array<[string, Record<string, string>]>;
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,
@@ -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");
+78 -1
View File
@@ -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<string>();
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);
+2 -2
View File
@@ -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));
+12
View File
@@ -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");
+29 -1
View File
@@ -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");
});
+21
View File
@@ -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"],
);
});