Fix:修复 SQL 子查询语义诊断误报 (#2184)

* Fix:修复 SQL 子查询语义诊断误报

* Improve:添加 SQL 语义诊断开关

* Improve:优化 SQL 语义诊断开关说明

* Fix:收紧 SQL 语义诊断飘红范围

* Improve:调整 SQL 语义诊断默认策略

---------

Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
zipg 2026-06-30 20:15:59 +08:00 committed by GitHub
parent 73a996ebe6
commit c38bc05417
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 417 additions and 31 deletions

View File

@ -34,6 +34,7 @@ import {
type DesktopIconTheme,
type InterfaceLayout,
type DisconnectTabHandlingMode,
type SqlSemanticDiagnosticsMode,
type UpdateDownloadSource,
type CustomThemeColors,
type CustomTheme,
@ -190,6 +191,8 @@ const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker);
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
const editSqlSemanticDiagnosticsMode = ref<SqlSemanticDiagnosticsMode>(settingsStore.editorSettings.sqlSemanticDiagnosticsMode);
const editSqlSemanticDiagnosticsEnabled = ref(settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution);
const editAppLayout = ref(settingsStore.editorSettings.appLayout);
const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon);
@ -468,6 +471,8 @@ watch(
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
editWordWrap.value = settingsStore.editorSettings.wordWrap;
editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution;
editAppLayout.value = settingsStore.editorSettings.appLayout;
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
@ -532,6 +537,8 @@ function hasChanges(): boolean {
editShowExecutionTargetPicker.value !== settingsStore.editorSettings.showExecutionTargetPicker ||
editAutoAliasTables.value !== settingsStore.editorSettings.autoAliasTables ||
editWordWrap.value !== settingsStore.editorSettings.wordWrap ||
editSqlSemanticDiagnosticsMode.value !== settingsStore.editorSettings.sqlSemanticDiagnosticsMode ||
editSqlSemanticDiagnosticsEnabled.value !== settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled ||
editConfirmDangerousSqlExecution.value !== settingsStore.editorSettings.confirmDangerousSqlExecution ||
editAppLayout.value !== settingsStore.editorSettings.appLayout ||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
@ -582,6 +589,7 @@ async function persistSettings() {
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
autoAliasTables: editAutoAliasTables.value,
wordWrap: editWordWrap.value,
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value,
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
appLayout: editAppLayout.value,
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
@ -643,6 +651,8 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
} else if (tab === "formatter") {
editSqlFormatter.value = normalizeSqlFormatterSettings(DEFAULT_EDITOR_SETTINGS.sqlFormatter);
@ -702,6 +712,8 @@ function resetAllDefaults() {
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout;
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
@ -866,6 +878,11 @@ function onExecuteModeChange(v: any) {
if (v === "all" || v === "current") editExecuteMode.value = v;
}
function onSqlSemanticDiagnosticsEnabledChange(value: boolean) {
editSqlSemanticDiagnosticsEnabled.value = value;
editSqlSemanticDiagnosticsMode.value = value ? "enabled" : "disabled";
}
function onFontFamilyChange(v: any) {
if (typeof v === "string") editFontFamily.value = v;
}
@ -2005,14 +2022,26 @@ watch(
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-confirm-dangerous-sql">{{ t("settings.confirmDangerousSqlExecution") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.confirmDangerousSqlExecutionDescription") }}
</p>
<div class="grid gap-3 md:grid-cols-2">
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-sql-semantic-diagnostics">{{ t("settings.sqlSemanticDiagnosticsEnabled") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sqlSemanticDiagnosticsEnabledDescription") }}
</p>
</div>
<Switch id="editor-sql-semantic-diagnostics" :model-value="editSqlSemanticDiagnosticsEnabled" class="mt-0.5" @update:model-value="onSqlSemanticDiagnosticsEnabledChange" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-confirm-dangerous-sql">{{ t("settings.confirmDangerousSqlExecution") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.confirmDangerousSqlExecutionDescription") }}
</p>
</div>
<Switch id="editor-confirm-dangerous-sql" v-model="editConfirmDangerousSqlExecution" class="mt-0.5" />
</div>
<Switch id="editor-confirm-dangerous-sql" v-model="editConfirmDangerousSqlExecution" class="mt-0.5" />
</div>
<Separator />

View File

@ -926,6 +926,17 @@ function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) {
reconfigureDiagnostics();
}
function clearScheduledSemanticDiagnostics() {
semanticDiagnosticRunId++;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = null;
pendingSemanticDiagnosticPreserveOutsideRanges = false;
}
function shouldSkipSqlSemanticDiagnostics() {
return props.databaseType !== "redis" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
}
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
return left.from < right.to && right.from < left.to;
}
@ -1046,6 +1057,10 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
return;
}
if (shouldSkipSqlSemanticDiagnostics()) {
setSemanticDiagnostics([]);
return;
}
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
scheduleSemanticDiagnostics(1200, { preserveOutsideRanges: options.preserveOutsideRanges });
return;
@ -1102,6 +1117,11 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
function scheduleSemanticDiagnostics(delay = 500, options: { preserveOutsideRanges?: boolean } = {}) {
if (!editorIsActive) return;
if (shouldSkipSqlSemanticDiagnostics()) {
clearScheduledSemanticDiagnostics();
setSemanticDiagnostics([]);
return;
}
pendingSemanticDiagnosticPreserveOutsideRanges = !!options.preserveOutsideRanges;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = setTimeout(() => {
@ -2512,14 +2532,25 @@ watch(
{ deep: true },
);
watch(
() => settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled,
(enabled) => {
if (props.databaseType === "redis") return;
if (!shouldSkipSqlSemanticDiagnostics() && enabled) {
scheduleSemanticDiagnostics(0);
return;
}
clearScheduledSemanticDiagnostics();
setSemanticDiagnostics([]);
},
);
function pauseQueryEditorBackgroundWork() {
flushEditorViewport();
flushEditorSelection();
clearTableNavigationHover();
editorIsActive = false;
semanticDiagnosticRunId++;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = null;
clearScheduledSemanticDiagnostics();
completionEpoch++;
unregisterTableReferenceDropListener();
}

View File

@ -2671,6 +2671,8 @@ export default {
showExecutionTargetPickerDescription: "When enabled, running without a selection lets you choose between the current statement and all SQL.",
wordWrap: "Word wrap",
wordWrapDescription: "Wrap long SQL lines within the editor width",
sqlSemanticDiagnosticsEnabled: "SQL semantic diagnostics",
sqlSemanticDiagnosticsEnabledDescription: "When enabled, the editor reports semantic issues such as unknown tables and columns. Disable it to reduce parsing and metadata checks for large SQL.",
confirmDangerousSqlExecution: "Confirm before dangerous SQL",
confirmDangerousSqlExecutionDescription: "When disabled, ALTER, DROP, DELETE, TRUNCATE, and other dangerous SQL run without the warning dialog.",
autoAliasTables: "Automatically add table aliases",

View File

@ -2644,6 +2644,8 @@ export default withEnglishFallback({
showExecutionTargetPickerDescription: "有効にすると、選択なしで実行するときに現在の文とすべてのSQLを一時的に選べます。",
wordWrap: "折り返し",
wordWrapDescription: "長いSQL行をエディタ幅内で折り返します",
sqlSemanticDiagnosticsEnabled: "SQLセマンティック診断",
sqlSemanticDiagnosticsEnabledDescription: "有効時、エディタは不明なテーブルや列などの意味的な問題を表示します。大きなSQLの解析とメタデータ確認の負荷を減らすには無効にします。",
confirmDangerousSqlExecution: "危険なSQLの前に確認",
confirmDangerousSqlExecutionDescription: "無効時、ALTER、DROP、DELETE、TRUNCATEなどの危険なSQLが警告ダイアログなしで実行されます。",
autoAliasTables: "テーブル別名を自動追加",

View File

@ -2678,6 +2678,8 @@ export default withEnglishFallback({
showExecutionTargetPickerDescription: "开启后,无选区执行时可在当前语句和全部 SQL 之间临时选择。",
wordWrap: "自动换行",
wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示",
sqlSemanticDiagnosticsEnabled: "SQL 语义诊断",
sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。",
confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认",
confirmDangerousSqlExecutionDescription: "关闭后ALTER、DROP、DELETE、TRUNCATE 等危险 SQL 将直接执行。",
autoAliasTables: "自动添加表别名",

View File

@ -1,7 +1,7 @@
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
import { getSqlCompletionContext } from "@/lib/sqlCompletion";
import { executableStatementRanges, type SqlTextRange } from "@/lib/sqlStatementRanges";
import type { DatabaseType, SqlColumnReference, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
import type { DatabaseType, SqlColumnReference, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database";
export interface SqlSemanticDiagnostic {
span: SqlTextSpan;
@ -42,6 +42,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
const diagnostics: SqlSemanticDiagnostic[] = [];
const tables = analysis.tables.filter((table) => table.name.trim());
const knownTables = new Map<string, SqlTableReference>();
const scopesById = scopesByIdMap(analysis.scopes);
for (const table of tables) {
knownTables.set(normalizeName(table.name), table);
@ -52,14 +53,14 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
for (const table of tables) {
if (!schema.missingTables?.has(tableReferenceKey(table))) continue;
diagnostics.push({
span: table.span,
span: trimSqlTextSpanWhitespace(schema.sql, table.span),
message: `Unknown table ${displayTableName(table)}`,
severity: "error",
});
}
for (const column of analysis.columns) {
const table = resolveColumnTable(column, tables, knownTables, schema.sql);
const table = resolveColumnTable(column, tables, knownTables, schema.sql, scopesById);
if (!table) continue;
if (schema.missingTables?.has(tableReferenceKey(table))) continue;
@ -71,7 +72,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
const displayName = column.qualifier ? `${column.qualifier}.${column.name}` : column.name;
diagnostics.push({
span: column.span,
span: trimSqlTextSpanWhitespace(schema.sql, column.span),
message: `Unknown column ${displayName}`,
severity: "error",
});
@ -80,6 +81,70 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
return diagnostics;
}
function trimSqlTextSpanWhitespace(sql: string | undefined, span: SqlTextSpan): SqlTextSpan {
if (!sql) return span;
const range = sqlTextSpanToOffsetRange(sql, span);
if (!range) return span;
let from = range.from;
let to = range.to;
while (from < to && /\s/.test(sql[from] ?? "")) from += 1;
while (to > from && /\s/.test(sql[to - 1] ?? "")) to -= 1;
if (from === range.from && to === range.to) return span;
const start = offsetToSqlTextStartPosition(sql, from);
const end = offsetToSqlTextEndPosition(sql, to);
if (!start || !end) return span;
return {
start_line: start.line,
start_column: start.column,
end_line: end.line,
end_column: Math.max(end.column, start.column),
};
}
function sqlTextSpanToOffsetRange(sql: string, span: SqlTextSpan): { from: number; to: number } | null {
if (!span.start_line || !span.start_column) return null;
const from = sqlTextPositionToOffset(sql, span.start_line, span.start_column - 1);
const to = sqlTextPositionToOffset(sql, Math.max(span.end_line, span.start_line), Math.max(span.end_column, span.start_column));
if (from == null || to == null || to <= from) return null;
return { from, to };
}
function sqlTextPositionToOffset(sql: string, line: number, column: number): number | null {
const lines = sql.split(/\r?\n/);
if (line < 1 || line > lines.length) return null;
let offset = 0;
for (let index = 0; index < line - 1; index += 1) {
offset += lines[index].length + 1;
}
return Math.min(offset + Math.max(column, 0), offset + lines[line - 1].length);
}
function offsetToSqlTextStartPosition(sql: string, offset: number): { line: number; column: number } | null {
const position = offsetToLineColumn(sql, offset);
return position ? { line: position.line, column: position.column + 1 } : null;
}
function offsetToSqlTextEndPosition(sql: string, offset: number): { line: number; column: number } | null {
return offsetToLineColumn(sql, offset);
}
function offsetToLineColumn(sql: string, offset: number): { line: number; column: number } | null {
if (offset < 0 || offset > sql.length) return null;
const lines = sql.split(/\r?\n/);
let remaining = offset;
for (let index = 0; index < lines.length; index += 1) {
const lineLength = lines[index].length;
if (remaining <= lineLength) {
return { line: index + 1, column: remaining };
}
remaining -= lineLength + 1;
}
const lastLine = lines[lines.length - 1] ?? "";
return { line: lines.length, column: lastLine.length };
}
export function buildSqlParserErrorDiagnostic(error: unknown, sql: string): SqlSemanticDiagnostic | null {
const message = errorMessage(error);
const location = /\bat Line:\s*(\d+),\s*Column:\s*(\d+)\b/i.exec(message);
@ -129,8 +194,8 @@ export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number,
return context.exclusiveColumnSuggestions || !!context.qualifier || ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor));
}
function resolveColumnTable(column: SqlColumnReference, tables: SqlTableReference[], knownTables: Map<string, SqlTableReference>, sql?: string): SqlTableReference | null {
const candidateTables = sql ? tablesInSameStatement(tables, column, sql) : tables;
function resolveColumnTable(column: SqlColumnReference, tables: SqlTableReference[], knownTables: Map<string, SqlTableReference>, sql?: string, scopesById?: Map<number, SqlReferenceScope>): SqlTableReference | null {
const candidateTables = candidateTablesForColumn(tables, column, sql, scopesById);
if (column.qualifier) {
return tableLookupFor(candidateTables).get(normalizeName(column.qualifier)) ?? (sql ? null : (knownTables.get(normalizeName(column.qualifier)) ?? null));
}
@ -138,6 +203,34 @@ function resolveColumnTable(column: SqlColumnReference, tables: SqlTableReferenc
return candidateTables[0];
}
function candidateTablesForColumn(tables: SqlTableReference[], column: SqlColumnReference, sql?: string, scopesById?: Map<number, SqlReferenceScope>): SqlTableReference[] {
const scoped = tablesInVisibleScopes(tables, column, scopesById);
if (scoped) return scoped;
return sql ? tablesInSameStatement(tables, column, sql) : tables;
}
function tablesInVisibleScopes(tables: SqlTableReference[], column: SqlColumnReference, scopesById?: Map<number, SqlReferenceScope>): SqlTableReference[] | null {
if (column.scope_id == null || !scopesById || scopesById.size === 0) return null;
if (!column.qualifier) {
const currentScopeTables = tables.filter((table) => table.scope_id === column.scope_id);
if (currentScopeTables.length > 0) return currentScopeTables;
}
const visibleScopeIds = scopeAndParents(column.scope_id, scopesById);
if (visibleScopeIds.size === 0) return null;
return tables.filter((table) => table.scope_id != null && visibleScopeIds.has(table.scope_id));
}
function scopeAndParents(scopeId: number, scopesById: Map<number, SqlReferenceScope>): Set<number> {
const ids = new Set<number>();
let current: number | undefined = scopeId;
while (current != null && !ids.has(current)) {
ids.add(current);
const scope = scopesById.get(current);
current = scope?.parent_id ?? undefined;
}
return ids;
}
function tableLookupFor(tables: SqlTableReference[]): Map<string, SqlTableReference> {
const lookup = new Map<string, SqlTableReference>();
for (const table of tables) {
@ -157,6 +250,12 @@ function tablesInSameStatement(tables: SqlTableReference[], column: SqlColumnRef
});
}
function scopesByIdMap(scopes: readonly SqlReferenceScope[] | undefined): Map<number, SqlReferenceScope> {
const map = new Map<number, SqlReferenceScope>();
for (const scope of scopes ?? []) map.set(scope.id, scope);
return map;
}
function spanStartOffset(sql: string, span: SqlTextSpan): number | null {
if (!span.start_line || !span.start_column) return null;
const lines = sql.split(/\r?\n/);

View File

@ -10,6 +10,22 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({ autoAliasTables: false }).autoAliasTables).toBe(false);
});
it("keeps SQL semantic diagnostics in auto mode and disabled by default", () => {
const settings = normalizeEditorSettings({});
expect(settings.sqlSemanticDiagnosticsMode).toBe("auto");
expect(settings.sqlSemanticDiagnosticsEnabled).toBe(false);
});
it("preserves explicit SQL semantic diagnostics modes", () => {
expect(normalizeEditorSettings({ sqlSemanticDiagnosticsMode: "enabled" }).sqlSemanticDiagnosticsEnabled).toBe(true);
expect(normalizeEditorSettings({ sqlSemanticDiagnosticsMode: "disabled" }).sqlSemanticDiagnosticsEnabled).toBe(false);
});
it("migrates legacy SQL semantic diagnostics booleans to explicit modes", () => {
expect(normalizeEditorSettings({ sqlSemanticDiagnosticsEnabled: true } as any).sqlSemanticDiagnosticsMode).toBe("enabled");
expect(normalizeEditorSettings({ sqlSemanticDiagnosticsEnabled: false } as any).sqlSemanticDiagnosticsMode).toBe("disabled");
});
it("defaults update downloads to the official source", () => {
expect(normalizeEditorSettings({}).updateDownloadSource).toBe("official");
});

View File

@ -59,8 +59,10 @@ export type DesktopIconTheme = "default" | "black";
export type InterfaceLayout = "separated" | "classic";
export type UpdateDownloadSource = "official" | "cnb";
export type SqlSemanticDiagnosticsMode = "auto" | "enabled" | "disabled";
export const DEFAULT_SIDEBAR_TABLE_PAGE_SIZE = 1000;
const SQL_SEMANTIC_DIAGNOSTICS_AUTO_ENABLED = false;
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
show_tray_icon: true,
@ -330,6 +332,8 @@ export interface EditorSettings {
showExecutionTargetPicker: boolean;
autoAliasTables: boolean;
wordWrap: boolean;
sqlSemanticDiagnosticsMode: SqlSemanticDiagnosticsMode;
sqlSemanticDiagnosticsEnabled: boolean;
confirmDangerousSqlExecution: boolean;
compactTabTitle: boolean;
appLayout: "separated" | "classic";
@ -436,6 +440,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
showExecutionTargetPicker: false,
autoAliasTables: true,
wordWrap: false,
sqlSemanticDiagnosticsMode: "auto",
sqlSemanticDiagnosticsEnabled: SQL_SEMANTIC_DIAGNOSTICS_AUTO_ENABLED,
confirmDangerousSqlExecution: true,
compactTabTitle: false,
appLayout: "classic",
@ -514,6 +520,18 @@ function normalizeUpdateDownloadSource(value: unknown): UpdateDownloadSource {
return value === "cnb" ? "cnb" : DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
}
function normalizeSqlSemanticDiagnosticsMode(value: unknown, legacyEnabled?: unknown): SqlSemanticDiagnosticsMode {
if (value === "auto" || value === "enabled" || value === "disabled") return value;
if (typeof legacyEnabled === "boolean") return legacyEnabled ? "enabled" : "disabled";
return DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
}
function sqlSemanticDiagnosticsEnabledForMode(mode: SqlSemanticDiagnosticsMode): boolean {
if (mode === "enabled") return true;
if (mode === "disabled") return false;
return SQL_SEMANTIC_DIAGNOSTICS_AUTO_ENABLED;
}
function normalizeDisconnectTabHandlingMode(value: unknown, legacyCloseTabsOnDisconnect?: unknown): DisconnectTabHandlingMode {
if (DISCONNECT_TAB_HANDLING_MODES.includes(value as DisconnectTabHandlingMode)) {
return value as DisconnectTabHandlingMode;
@ -582,6 +600,7 @@ function normalizeToolbarItems(items: Partial<ToolbarItems> | undefined): Toolba
}
export function normalizeEditorSettings(settings: Partial<EditorSettings>, existing?: EditorSettings): EditorSettings {
const sqlSemanticDiagnosticsMode = normalizeSqlSemanticDiagnosticsMode(settings.sqlSemanticDiagnosticsMode, settings.sqlSemanticDiagnosticsEnabled);
return {
fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
fontSize: settings.fontSize ?? DEFAULT_EDITOR_SETTINGS.fontSize,
@ -618,6 +637,8 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
showExecutionTargetPicker: settings.showExecutionTargetPicker ?? DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker,
autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables,
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
sqlSemanticDiagnosticsMode,
sqlSemanticDiagnosticsEnabled: sqlSemanticDiagnosticsEnabledForMode(sqlSemanticDiagnosticsMode),
confirmDangerousSqlExecution: settings.confirmDangerousSqlExecution ?? DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution,
compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle,
appLayout: settings.appLayout ?? DEFAULT_EDITOR_SETTINGS.appLayout,
@ -791,6 +812,11 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.showExecutionTargetPicker !== undefined) editorSettings.value.showExecutionTargetPicker = partial.showExecutionTargetPicker;
if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables;
if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap;
if (partial.sqlSemanticDiagnosticsMode !== undefined || partial.sqlSemanticDiagnosticsEnabled !== undefined) {
const nextMode = normalizeSqlSemanticDiagnosticsMode(partial.sqlSemanticDiagnosticsMode, partial.sqlSemanticDiagnosticsEnabled);
editorSettings.value.sqlSemanticDiagnosticsMode = nextMode;
editorSettings.value.sqlSemanticDiagnosticsEnabled = sqlSemanticDiagnosticsEnabledForMode(nextMode);
}
if (partial.confirmDangerousSqlExecution !== undefined) editorSettings.value.confirmDangerousSqlExecution = partial.confirmDangerousSqlExecution;
if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle;
if (partial.appLayout !== undefined) editorSettings.value.appLayout = partial.appLayout;

View File

@ -448,17 +448,25 @@ export interface SqlTableReference {
schema?: string | null;
alias?: string | null;
span: SqlTextSpan;
scope_id?: number;
}
export interface SqlColumnReference {
name: string;
qualifier?: string | null;
span: SqlTextSpan;
scope_id?: number;
}
export interface SqlReferenceScope {
id: number;
parent_id?: number | null;
}
export interface SqlReferenceAnalysis {
tables: SqlTableReference[];
columns: SqlColumnReference[];
scopes?: SqlReferenceScope[];
}
export type TreeNodeType =

View File

@ -27,6 +27,7 @@ static POSTGRES_DEFAULT_PRIVILEGES_RE: LazyLock<Regex> = LazyLock::new(|| {
pub struct SqlReferenceAnalysis {
pub tables: Vec<SqlTableReference>,
pub columns: Vec<SqlColumnReference>,
pub scopes: Vec<SqlReferenceScope>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -35,6 +36,7 @@ pub struct SqlTableReference {
pub schema: Option<String>,
pub alias: Option<String>,
pub span: SqlTextSpan,
pub scope_id: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -42,6 +44,13 @@ pub struct SqlColumnReference {
pub name: String,
pub qualifier: Option<String>,
pub span: SqlTextSpan,
pub scope_id: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SqlReferenceScope {
pub id: usize,
pub parent_id: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -67,15 +76,18 @@ impl From<Span> for SqlTextSpan {
struct Analyzer {
tables: Vec<SqlTableReference>,
columns: Vec<SqlColumnReference>,
scopes: Vec<SqlReferenceScope>,
scope_stack: Vec<usize>,
next_scope_id: usize,
}
pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result<SqlReferenceAnalysis, String> {
let normalized_dialect = normalize_dialect(dialect);
if normalized_dialect == "duckdb" && starts_with_duckdb_parser_gap_sql(sql) {
return Ok(SqlReferenceAnalysis { tables: vec![], columns: vec![] });
return Ok(SqlReferenceAnalysis { tables: vec![], columns: vec![], scopes: vec![] });
}
if normalized_dialect == "postgres" && starts_with_postgres_parser_gap_sql(sql) {
return Ok(SqlReferenceAnalysis { tables: vec![], columns: vec![] });
return Ok(SqlReferenceAnalysis { tables: vec![], columns: vec![], scopes: vec![] });
}
let parser_sql = if normalized_dialect == "clickhouse" {
normalize_clickhouse_join_order_for_parser(sql)
@ -99,7 +111,7 @@ pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result<SqlRef
analyzer.visit_statement(&statement);
}
Ok(SqlReferenceAnalysis { tables: analyzer.tables, columns: analyzer.columns })
Ok(SqlReferenceAnalysis { tables: analyzer.tables, columns: analyzer.columns, scopes: analyzer.scopes })
}
fn starts_with_duckdb_parser_gap_sql(sql: &str) -> bool {
@ -147,14 +159,31 @@ fn normalize_dialect(dialect: Option<&str>) -> String {
impl Analyzer {
fn visit_statement(&mut self, statement: &Statement) {
if let Statement::Query(query) = statement {
self.visit_query(query);
self.visit_query_in_new_scope(query, None);
}
}
fn visit_query_in_new_scope(&mut self, query: &Query, parent_id: Option<usize>) {
let scope_id = self.next_scope_id;
self.next_scope_id += 1;
self.scopes.push(SqlReferenceScope { id: scope_id, parent_id });
self.scope_stack.push(scope_id);
self.visit_query(query);
self.scope_stack.pop();
}
fn visit_child_query(&mut self, query: &Query) {
self.visit_query_in_new_scope(query, self.current_scope_id());
}
fn current_scope_id(&self) -> Option<usize> {
self.scope_stack.last().copied()
}
fn visit_query(&mut self, query: &Query) {
if let Some(with) = &query.with {
for cte in &with.cte_tables {
self.visit_query(&cte.query);
self.visit_child_query(&cte.query);
}
}
self.visit_set_expr(&query.body);
@ -170,15 +199,24 @@ impl Analyzer {
fn visit_set_expr(&mut self, set_expr: &SetExpr) {
match set_expr {
SetExpr::Select(select) => self.visit_select(select),
SetExpr::Query(query) => self.visit_query(query),
SetExpr::Query(query) => self.visit_child_query(query),
SetExpr::SetOperation { left, right, .. } => {
self.visit_set_expr(left);
self.visit_set_expr(right);
self.visit_set_expr_in_child_scope(left);
self.visit_set_expr_in_child_scope(right);
}
_ => {}
}
}
fn visit_set_expr_in_child_scope(&mut self, set_expr: &SetExpr) {
let scope_id = self.next_scope_id;
self.next_scope_id += 1;
self.scopes.push(SqlReferenceScope { id: scope_id, parent_id: self.current_scope_id() });
self.scope_stack.push(scope_id);
self.visit_set_expr(set_expr);
self.scope_stack.pop();
}
fn visit_select(&mut self, select: &Select) {
for table in &select.from {
self.visit_table_with_joins(table);
@ -272,12 +310,16 @@ impl Analyzer {
match factor {
TableFactor::Table { name, alias, args, .. } => {
if args.is_none() {
if let Some(table) = table_reference_from_name(name, alias.as_ref().map(|a| a.name.value.clone())) {
if let Some(table) = table_reference_from_name(
name,
alias.as_ref().map(|a| a.name.value.clone()),
self.current_scope_id(),
) {
self.tables.push(table);
}
}
}
TableFactor::Derived { subquery, .. } => self.visit_query(subquery),
TableFactor::Derived { subquery, .. } => self.visit_child_query(subquery),
TableFactor::NestedJoin { table_with_joins, .. } => self.visit_table_with_joins(table_with_joins),
TableFactor::TableFunction { expr, .. } => self.visit_expr(expr),
TableFactor::Function { args, .. } => {
@ -383,14 +425,14 @@ impl Analyzer {
self.visit_expr(else_result);
}
}
Expr::Subquery(query) | Expr::Exists { subquery: query, .. } => self.visit_query(query),
Expr::Subquery(query) | Expr::Exists { subquery: query, .. } => self.visit_child_query(query),
_ => {}
}
}
fn visit_function_args(&mut self, args: &FunctionArguments) {
match args {
FunctionArguments::Subquery(query) => self.visit_query(query),
FunctionArguments::Subquery(query) => self.visit_child_query(query),
FunctionArguments::List(list) => {
for arg in &list.args {
self.visit_function_arg(arg);
@ -418,16 +460,27 @@ impl Analyzer {
}
fn push_column(&mut self, qualifier: Option<String>, ident: &Ident) {
self.columns.push(SqlColumnReference { name: ident.value.clone(), qualifier, span: ident.span.into() });
if let Some(scope_id) = self.current_scope_id() {
self.columns.push(SqlColumnReference {
name: ident.value.clone(),
qualifier,
span: ident.span.into(),
scope_id,
});
}
}
}
fn table_reference_from_name(name: &ObjectName, alias: Option<String>) -> Option<SqlTableReference> {
fn table_reference_from_name(
name: &ObjectName,
alias: Option<String>,
scope_id: Option<usize>,
) -> Option<SqlTableReference> {
let parts: Vec<&Ident> = name.0.iter().filter_map(ObjectNamePart::as_ident).collect();
let table = parts.last()?;
let schema = if parts.len() >= 2 { parts.get(parts.len() - 2).map(|ident| ident.value.clone()) } else { None };
Some(SqlTableReference { name: table.value.clone(), schema, alias, span: table.span.into() })
Some(SqlTableReference { name: table.value.clone(), schema, alias, span: table.span.into(), scope_id: scope_id? })
}
fn object_name_last_ident(name: &ObjectName) -> Option<&Ident> {

View File

@ -13,6 +13,26 @@ fn extracts_tables_aliases_and_qualified_columns() {
assert_eq!(columns, vec![(Some("u"), "missing"), (Some("u"), "id")]);
}
#[test]
fn extracts_nested_query_scopes_for_correlated_subqueries() {
let sql = "select aa.house_id from mds_base_house aa where exists (select 1 from mds_base_owner where HOUSE_ID = aa.HOUSE_ID)";
let analysis = analyze_sql_references(sql, Some("mysql")).unwrap();
let tables: Vec<_> =
analysis.tables.iter().map(|table| (table.name.as_str(), table.alias.as_deref(), table.scope_id)).collect();
assert_eq!(tables, vec![("mds_base_house", Some("aa"), 0), ("mds_base_owner", None, 1)]);
let scopes: Vec<_> = analysis.scopes.iter().map(|scope| (scope.id, scope.parent_id)).collect();
assert_eq!(scopes, vec![(0, None), (1, Some(0))]);
let columns: Vec<_> = analysis
.columns
.iter()
.map(|column| (column.qualifier.as_deref(), column.name.as_str(), column.scope_id))
.collect();
assert_eq!(columns, vec![(Some("aa"), "house_id", 0), (None, "HOUSE_ID", 1), (Some("aa"), "HOUSE_ID", 1)]);
}
#[test]
fn extracts_unqualified_columns_from_single_table_select() {
let analysis = analyze_sql_references("select missing, id from users", Some("postgres")).unwrap();

View File

@ -50,6 +50,22 @@ test("flags confirmed missing tables", () => {
assert.equal(diagnostics[0]?.severity, "error");
});
test("trims whitespace from missing table diagnostic spans", () => {
const analysis: SqlReferenceAnalysis = {
tables: [{ name: "t_00011", span: span(32, 39) }],
columns: [],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [],
columnsByTable: new Map(),
missingTables: new Set(["t_00011"]),
sql: "SELECT * FROM demo_2000_tables.t_00011 AS t0",
});
assert.deepEqual(diagnostics[0]?.span, span(32, 38));
});
test("flags missing columns when column metadata is cached with a schema key", () => {
const analysis: SqlReferenceAnalysis = {
tables: [{ name: "t_10001", span: span(24, 32) }],
@ -165,6 +181,88 @@ test("resolves qualified aliases against the table in the same statement", () =>
);
});
test("resolves correlated subquery columns against the visible query scopes", () => {
const analysis: SqlReferenceAnalysis = {
tables: [
{ name: "mds_base_house", alias: "aa", span: span(1, 14), scope_id: 0 },
{ name: "mds_base_owner", span: span(1, 14), scope_id: 1 },
{ name: "FDS_PAY_ORDER", span: span(1, 13), scope_id: 2 },
{ name: "ac_fund_acct", span: span(1, 12), scope_id: 3 },
],
columns: [
{ name: "house_id", qualifier: "aa", span: span(8, 15), scope_id: 0 },
{ name: "hou_add", qualifier: "aa", span: span(18, 24), scope_id: 0 },
{ name: "contract_code", qualifier: "aa", span: span(27, 39), scope_id: 0 },
{ name: "pay_type", qualifier: "aa", span: span(42, 49), scope_id: 0 },
{ name: "HOU_PAY_AMT", qualifier: "aa", span: span(52, 62), scope_id: 0 },
{ name: "OWNER_NAME", span: span(8, 17), scope_id: 1 },
{ name: "HOUSE_ID", span: span(8, 15), scope_id: 1 },
{ name: "HOUSE_ID", qualifier: "aa", span: span(8, 15), scope_id: 1 },
{ name: "PAY_DATA", span: span(8, 15), scope_id: 2 },
{ name: "contract_code", qualifier: "aa", span: span(8, 20), scope_id: 2 },
{ name: "owner_id", span: span(8, 15), scope_id: 3 },
{ name: "house_id", qualifier: "aa", span: span(8, 15), scope_id: 3 },
],
scopes: [
{ id: 0, parent_id: null },
{ id: 1, parent_id: 0 },
{ id: 2, parent_id: 0 },
{ id: 3, parent_id: 0 },
],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [
{ name: "mds_base_house", type: "table" },
{ name: "mds_base_owner", type: "table" },
{ name: "FDS_PAY_ORDER", type: "table" },
{ name: "ac_fund_acct", type: "table" },
],
columnsByTable: new Map([
["mds_base_house", ["house_id", "hou_add", "contract_code", "pay_type", "HOU_PAY_AMT"].map((name) => ({ name, table: "mds_base_house" }))],
["mds_base_owner", ["OWNER_NAME", "HOUSE_ID"].map((name) => ({ name, table: "mds_base_owner" }))],
["FDS_PAY_ORDER", [{ name: "PAY_DATA", table: "FDS_PAY_ORDER" }]],
["ac_fund_acct", [{ name: "owner_id", table: "ac_fund_acct" }]],
]),
});
assert.deepEqual(diagnostics, []);
});
test("keeps missing-column diagnostics inside nested query scopes", () => {
const analysis: SqlReferenceAnalysis = {
tables: [
{ name: "parent_table", alias: "p", span: span(1, 12), scope_id: 0 },
{ name: "child_table", alias: "c", span: span(1, 11), scope_id: 1 },
],
columns: [
{ name: "id", qualifier: "p", span: span(8, 9), scope_id: 0 },
{ name: "missing", qualifier: "c", span: span(8, 15), scope_id: 1 },
{ name: "id", qualifier: "p", span: span(8, 9), scope_id: 1 },
],
scopes: [
{ id: 0, parent_id: null },
{ id: 1, parent_id: 0 },
],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [
{ name: "parent_table", type: "table" },
{ name: "child_table", type: "table" },
],
columnsByTable: new Map([
["parent_table", [{ name: "id", table: "parent_table" }]],
["child_table", [{ name: "id", table: "child_table" }]],
]),
});
assert.deepEqual(
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown column c.missing"],
);
});
test("does not flag unqualified columns when multiple tables make ownership ambiguous", () => {
const analysis: SqlReferenceAnalysis = {
tables: [