mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(editor): improve ASE SQL semantic highlighting
This commit is contained in:
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user