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:
1 parent
73a996ebe6
commit
c38bc05417
12 files changed
+417
-31
No files matched your search
@@ -34,6 +34,7 @@ import {
|
|||||||
type DesktopIconTheme,
|
type DesktopIconTheme,
|
||||||
type InterfaceLayout,
|
type InterfaceLayout,
|
||||||
type DisconnectTabHandlingMode,
|
type DisconnectTabHandlingMode,
|
||||||
|
type SqlSemanticDiagnosticsMode,
|
||||||
type UpdateDownloadSource,
|
type UpdateDownloadSource,
|
||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
type CustomTheme,
|
type CustomTheme,
|
||||||
@@ -190,6 +191,8 @@ const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
|
|||||||
const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker);
|
const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker);
|
||||||
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
|
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
|
||||||
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
|
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 editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution);
|
||||||
const editAppLayout = ref(settingsStore.editorSettings.appLayout);
|
const editAppLayout = ref(settingsStore.editorSettings.appLayout);
|
||||||
const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon);
|
const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon);
|
||||||
@@ -468,6 +471,8 @@ watch(
|
|||||||
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
|
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
|
||||||
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
|
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
|
||||||
editWordWrap.value = settingsStore.editorSettings.wordWrap;
|
editWordWrap.value = settingsStore.editorSettings.wordWrap;
|
||||||
|
editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode;
|
||||||
|
editSqlSemanticDiagnosticsEnabled.value = settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
|
||||||
editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution;
|
editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution;
|
||||||
editAppLayout.value = settingsStore.editorSettings.appLayout;
|
editAppLayout.value = settingsStore.editorSettings.appLayout;
|
||||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||||
@@ -532,6 +537,8 @@ function hasChanges(): boolean {
|
|||||||
editShowExecutionTargetPicker.value !== settingsStore.editorSettings.showExecutionTargetPicker ||
|
editShowExecutionTargetPicker.value !== settingsStore.editorSettings.showExecutionTargetPicker ||
|
||||||
editAutoAliasTables.value !== settingsStore.editorSettings.autoAliasTables ||
|
editAutoAliasTables.value !== settingsStore.editorSettings.autoAliasTables ||
|
||||||
editWordWrap.value !== settingsStore.editorSettings.wordWrap ||
|
editWordWrap.value !== settingsStore.editorSettings.wordWrap ||
|
||||||
|
editSqlSemanticDiagnosticsMode.value !== settingsStore.editorSettings.sqlSemanticDiagnosticsMode ||
|
||||||
|
editSqlSemanticDiagnosticsEnabled.value !== settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled ||
|
||||||
editConfirmDangerousSqlExecution.value !== settingsStore.editorSettings.confirmDangerousSqlExecution ||
|
editConfirmDangerousSqlExecution.value !== settingsStore.editorSettings.confirmDangerousSqlExecution ||
|
||||||
editAppLayout.value !== settingsStore.editorSettings.appLayout ||
|
editAppLayout.value !== settingsStore.editorSettings.appLayout ||
|
||||||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
|
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
|
||||||
@@ -582,6 +589,7 @@ async function persistSettings() {
|
|||||||
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
|
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
|
||||||
autoAliasTables: editAutoAliasTables.value,
|
autoAliasTables: editAutoAliasTables.value,
|
||||||
wordWrap: editWordWrap.value,
|
wordWrap: editWordWrap.value,
|
||||||
|
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value,
|
||||||
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
|
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
|
||||||
appLayout: editAppLayout.value,
|
appLayout: editAppLayout.value,
|
||||||
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
|
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
|
||||||
@@ -643,6 +651,8 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
|||||||
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
|
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
|
||||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||||
|
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
|
||||||
|
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
|
||||||
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
|
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
|
||||||
} else if (tab === "formatter") {
|
} else if (tab === "formatter") {
|
||||||
editSqlFormatter.value = normalizeSqlFormatterSettings(DEFAULT_EDITOR_SETTINGS.sqlFormatter);
|
editSqlFormatter.value = normalizeSqlFormatterSettings(DEFAULT_EDITOR_SETTINGS.sqlFormatter);
|
||||||
@@ -702,6 +712,8 @@ function resetAllDefaults() {
|
|||||||
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
|
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
|
||||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||||
|
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
|
||||||
|
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
|
||||||
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
|
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
|
||||||
editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout;
|
editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout;
|
||||||
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
|
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
|
||||||
@@ -866,6 +878,11 @@ function onExecuteModeChange(v: any) {
|
|||||||
if (v === "all" || v === "current") editExecuteMode.value = v;
|
if (v === "all" || v === "current") editExecuteMode.value = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSqlSemanticDiagnosticsEnabledChange(value: boolean) {
|
||||||
|
editSqlSemanticDiagnosticsEnabled.value = value;
|
||||||
|
editSqlSemanticDiagnosticsMode.value = value ? "enabled" : "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
function onFontFamilyChange(v: any) {
|
function onFontFamilyChange(v: any) {
|
||||||
if (typeof v === "string") editFontFamily.value = v;
|
if (typeof v === "string") editFontFamily.value = v;
|
||||||
}
|
}
|
||||||
@@ -2005,14 +2022,26 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div class="space-y-1">
|
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||||
<Label for="editor-confirm-dangerous-sql">{{ t("settings.confirmDangerousSqlExecution") }}</Label>
|
<div class="space-y-1">
|
||||||
<p class="text-xs text-muted-foreground">
|
<Label for="editor-sql-semantic-diagnostics">{{ t("settings.sqlSemanticDiagnosticsEnabled") }}</Label>
|
||||||
{{ t("settings.confirmDangerousSqlExecutionDescription") }}
|
<p class="text-xs text-muted-foreground">
|
||||||
</p>
|
{{ 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>
|
</div>
|
||||||
<Switch id="editor-confirm-dangerous-sql" v-model="editConfirmDangerousSqlExecution" class="mt-0.5" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -926,6 +926,17 @@ function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) {
|
|||||||
reconfigureDiagnostics();
|
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 {
|
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
|
||||||
return left.from < right.to && right.from < left.to;
|
return left.from < right.to && right.from < left.to;
|
||||||
}
|
}
|
||||||
@@ -1046,6 +1057,10 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
|||||||
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
|
setSemanticDiagnostics(buildRedisSyntaxDiagnostics(sql));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (shouldSkipSqlSemanticDiagnostics()) {
|
||||||
|
setSemanticDiagnostics([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
|
||||||
scheduleSemanticDiagnostics(1200, { preserveOutsideRanges: options.preserveOutsideRanges });
|
scheduleSemanticDiagnostics(1200, { preserveOutsideRanges: options.preserveOutsideRanges });
|
||||||
return;
|
return;
|
||||||
@@ -1102,6 +1117,11 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
|||||||
|
|
||||||
function scheduleSemanticDiagnostics(delay = 500, options: { preserveOutsideRanges?: boolean } = {}) {
|
function scheduleSemanticDiagnostics(delay = 500, options: { preserveOutsideRanges?: boolean } = {}) {
|
||||||
if (!editorIsActive) return;
|
if (!editorIsActive) return;
|
||||||
|
if (shouldSkipSqlSemanticDiagnostics()) {
|
||||||
|
clearScheduledSemanticDiagnostics();
|
||||||
|
setSemanticDiagnostics([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
pendingSemanticDiagnosticPreserveOutsideRanges = !!options.preserveOutsideRanges;
|
pendingSemanticDiagnosticPreserveOutsideRanges = !!options.preserveOutsideRanges;
|
||||||
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
|
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
|
||||||
semanticDiagnosticTimer = setTimeout(() => {
|
semanticDiagnosticTimer = setTimeout(() => {
|
||||||
@@ -2512,14 +2532,25 @@ watch(
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled,
|
||||||
|
(enabled) => {
|
||||||
|
if (props.databaseType === "redis") return;
|
||||||
|
if (!shouldSkipSqlSemanticDiagnostics() && enabled) {
|
||||||
|
scheduleSemanticDiagnostics(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearScheduledSemanticDiagnostics();
|
||||||
|
setSemanticDiagnostics([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
function pauseQueryEditorBackgroundWork() {
|
function pauseQueryEditorBackgroundWork() {
|
||||||
flushEditorViewport();
|
flushEditorViewport();
|
||||||
flushEditorSelection();
|
flushEditorSelection();
|
||||||
clearTableNavigationHover();
|
clearTableNavigationHover();
|
||||||
editorIsActive = false;
|
editorIsActive = false;
|
||||||
semanticDiagnosticRunId++;
|
clearScheduledSemanticDiagnostics();
|
||||||
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
|
|
||||||
semanticDiagnosticTimer = null;
|
|
||||||
completionEpoch++;
|
completionEpoch++;
|
||||||
unregisterTableReferenceDropListener();
|
unregisterTableReferenceDropListener();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2671,6 +2671,8 @@ export default {
|
|||||||
showExecutionTargetPickerDescription: "When enabled, running without a selection lets you choose between the current statement and all SQL.",
|
showExecutionTargetPickerDescription: "When enabled, running without a selection lets you choose between the current statement and all SQL.",
|
||||||
wordWrap: "Word wrap",
|
wordWrap: "Word wrap",
|
||||||
wordWrapDescription: "Wrap long SQL lines within the editor width",
|
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",
|
confirmDangerousSqlExecution: "Confirm before dangerous SQL",
|
||||||
confirmDangerousSqlExecutionDescription: "When disabled, ALTER, DROP, DELETE, TRUNCATE, and other dangerous SQL run without the warning dialog.",
|
confirmDangerousSqlExecutionDescription: "When disabled, ALTER, DROP, DELETE, TRUNCATE, and other dangerous SQL run without the warning dialog.",
|
||||||
autoAliasTables: "Automatically add table aliases",
|
autoAliasTables: "Automatically add table aliases",
|
||||||
|
|||||||
@@ -2644,6 +2644,8 @@ export default withEnglishFallback({
|
|||||||
showExecutionTargetPickerDescription: "有効にすると、選択なしで実行するときに現在の文とすべてのSQLを一時的に選べます。",
|
showExecutionTargetPickerDescription: "有効にすると、選択なしで実行するときに現在の文とすべてのSQLを一時的に選べます。",
|
||||||
wordWrap: "折り返し",
|
wordWrap: "折り返し",
|
||||||
wordWrapDescription: "長いSQL行をエディタ幅内で折り返します",
|
wordWrapDescription: "長いSQL行をエディタ幅内で折り返します",
|
||||||
|
sqlSemanticDiagnosticsEnabled: "SQLセマンティック診断",
|
||||||
|
sqlSemanticDiagnosticsEnabledDescription: "有効時、エディタは不明なテーブルや列などの意味的な問題を表示します。大きなSQLの解析とメタデータ確認の負荷を減らすには無効にします。",
|
||||||
confirmDangerousSqlExecution: "危険なSQLの前に確認",
|
confirmDangerousSqlExecution: "危険なSQLの前に確認",
|
||||||
confirmDangerousSqlExecutionDescription: "無効時、ALTER、DROP、DELETE、TRUNCATEなどの危険なSQLが警告ダイアログなしで実行されます。",
|
confirmDangerousSqlExecutionDescription: "無効時、ALTER、DROP、DELETE、TRUNCATEなどの危険なSQLが警告ダイアログなしで実行されます。",
|
||||||
autoAliasTables: "テーブル別名を自動追加",
|
autoAliasTables: "テーブル別名を自動追加",
|
||||||
|
|||||||
@@ -2678,6 +2678,8 @@ export default withEnglishFallback({
|
|||||||
showExecutionTargetPickerDescription: "开启后,无选区执行时可在当前语句和全部 SQL 之间临时选择。",
|
showExecutionTargetPickerDescription: "开启后,无选区执行时可在当前语句和全部 SQL 之间临时选择。",
|
||||||
wordWrap: "自动换行",
|
wordWrap: "自动换行",
|
||||||
wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示",
|
wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示",
|
||||||
|
sqlSemanticDiagnosticsEnabled: "SQL 语义诊断",
|
||||||
|
sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。",
|
||||||
confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认",
|
confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认",
|
||||||
confirmDangerousSqlExecutionDescription: "关闭后,ALTER、DROP、DELETE、TRUNCATE 等危险 SQL 将直接执行。",
|
confirmDangerousSqlExecutionDescription: "关闭后,ALTER、DROP、DELETE、TRUNCATE 等危险 SQL 将直接执行。",
|
||||||
autoAliasTables: "自动添加表别名",
|
autoAliasTables: "自动添加表别名",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
|
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
|
||||||
import { getSqlCompletionContext } from "@/lib/sqlCompletion";
|
import { getSqlCompletionContext } from "@/lib/sqlCompletion";
|
||||||
import { executableStatementRanges, type SqlTextRange } from "@/lib/sqlStatementRanges";
|
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 {
|
export interface SqlSemanticDiagnostic {
|
||||||
span: SqlTextSpan;
|
span: SqlTextSpan;
|
||||||
@@ -42,6 +42,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
|
|||||||
const diagnostics: SqlSemanticDiagnostic[] = [];
|
const diagnostics: SqlSemanticDiagnostic[] = [];
|
||||||
const tables = analysis.tables.filter((table) => table.name.trim());
|
const tables = analysis.tables.filter((table) => table.name.trim());
|
||||||
const knownTables = new Map<string, SqlTableReference>();
|
const knownTables = new Map<string, SqlTableReference>();
|
||||||
|
const scopesById = scopesByIdMap(analysis.scopes);
|
||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
knownTables.set(normalizeName(table.name), table);
|
knownTables.set(normalizeName(table.name), table);
|
||||||
@@ -52,14 +53,14 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
|
|||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
if (!schema.missingTables?.has(tableReferenceKey(table))) continue;
|
if (!schema.missingTables?.has(tableReferenceKey(table))) continue;
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
span: table.span,
|
span: trimSqlTextSpanWhitespace(schema.sql, table.span),
|
||||||
message: `Unknown table ${displayTableName(table)}`,
|
message: `Unknown table ${displayTableName(table)}`,
|
||||||
severity: "error",
|
severity: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const column of analysis.columns) {
|
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 (!table) continue;
|
||||||
if (schema.missingTables?.has(tableReferenceKey(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;
|
const displayName = column.qualifier ? `${column.qualifier}.${column.name}` : column.name;
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
span: column.span,
|
span: trimSqlTextSpanWhitespace(schema.sql, column.span),
|
||||||
message: `Unknown column ${displayName}`,
|
message: `Unknown column ${displayName}`,
|
||||||
severity: "error",
|
severity: "error",
|
||||||
});
|
});
|
||||||
@@ -80,6 +81,70 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
|
|||||||
return diagnostics;
|
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 {
|
export function buildSqlParserErrorDiagnostic(error: unknown, sql: string): SqlSemanticDiagnostic | null {
|
||||||
const message = errorMessage(error);
|
const message = errorMessage(error);
|
||||||
const location = /\bat Line:\s*(\d+),\s*Column:\s*(\d+)\b/i.exec(message);
|
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));
|
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 {
|
function resolveColumnTable(column: SqlColumnReference, tables: SqlTableReference[], knownTables: Map<string, SqlTableReference>, sql?: string, scopesById?: Map<number, SqlReferenceScope>): SqlTableReference | null {
|
||||||
const candidateTables = sql ? tablesInSameStatement(tables, column, sql) : tables;
|
const candidateTables = candidateTablesForColumn(tables, column, sql, scopesById);
|
||||||
if (column.qualifier) {
|
if (column.qualifier) {
|
||||||
return tableLookupFor(candidateTables).get(normalizeName(column.qualifier)) ?? (sql ? null : (knownTables.get(normalizeName(column.qualifier)) ?? null));
|
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];
|
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> {
|
function tableLookupFor(tables: SqlTableReference[]): Map<string, SqlTableReference> {
|
||||||
const lookup = new Map<string, SqlTableReference>();
|
const lookup = new Map<string, SqlTableReference>();
|
||||||
for (const table of tables) {
|
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 {
|
function spanStartOffset(sql: string, span: SqlTextSpan): number | null {
|
||||||
if (!span.start_line || !span.start_column) return null;
|
if (!span.start_line || !span.start_column) return null;
|
||||||
const lines = sql.split(/\r?\n/);
|
const lines = sql.split(/\r?\n/);
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ describe("normalizeEditorSettings", () => {
|
|||||||
expect(normalizeEditorSettings({ autoAliasTables: false }).autoAliasTables).toBe(false);
|
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", () => {
|
it("defaults update downloads to the official source", () => {
|
||||||
expect(normalizeEditorSettings({}).updateDownloadSource).toBe("official");
|
expect(normalizeEditorSettings({}).updateDownloadSource).toBe("official");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ export type DesktopIconTheme = "default" | "black";
|
|||||||
export type InterfaceLayout = "separated" | "classic";
|
export type InterfaceLayout = "separated" | "classic";
|
||||||
|
|
||||||
export type UpdateDownloadSource = "official" | "cnb";
|
export type UpdateDownloadSource = "official" | "cnb";
|
||||||
|
export type SqlSemanticDiagnosticsMode = "auto" | "enabled" | "disabled";
|
||||||
|
|
||||||
export const DEFAULT_SIDEBAR_TABLE_PAGE_SIZE = 1000;
|
export const DEFAULT_SIDEBAR_TABLE_PAGE_SIZE = 1000;
|
||||||
|
const SQL_SEMANTIC_DIAGNOSTICS_AUTO_ENABLED = false;
|
||||||
|
|
||||||
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
show_tray_icon: true,
|
show_tray_icon: true,
|
||||||
@@ -330,6 +332,8 @@ export interface EditorSettings {
|
|||||||
showExecutionTargetPicker: boolean;
|
showExecutionTargetPicker: boolean;
|
||||||
autoAliasTables: boolean;
|
autoAliasTables: boolean;
|
||||||
wordWrap: boolean;
|
wordWrap: boolean;
|
||||||
|
sqlSemanticDiagnosticsMode: SqlSemanticDiagnosticsMode;
|
||||||
|
sqlSemanticDiagnosticsEnabled: boolean;
|
||||||
confirmDangerousSqlExecution: boolean;
|
confirmDangerousSqlExecution: boolean;
|
||||||
compactTabTitle: boolean;
|
compactTabTitle: boolean;
|
||||||
appLayout: "separated" | "classic";
|
appLayout: "separated" | "classic";
|
||||||
@@ -436,6 +440,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
|||||||
showExecutionTargetPicker: false,
|
showExecutionTargetPicker: false,
|
||||||
autoAliasTables: true,
|
autoAliasTables: true,
|
||||||
wordWrap: false,
|
wordWrap: false,
|
||||||
|
sqlSemanticDiagnosticsMode: "auto",
|
||||||
|
sqlSemanticDiagnosticsEnabled: SQL_SEMANTIC_DIAGNOSTICS_AUTO_ENABLED,
|
||||||
confirmDangerousSqlExecution: true,
|
confirmDangerousSqlExecution: true,
|
||||||
compactTabTitle: false,
|
compactTabTitle: false,
|
||||||
appLayout: "classic",
|
appLayout: "classic",
|
||||||
@@ -514,6 +520,18 @@ function normalizeUpdateDownloadSource(value: unknown): UpdateDownloadSource {
|
|||||||
return value === "cnb" ? "cnb" : DEFAULT_EDITOR_SETTINGS.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 {
|
function normalizeDisconnectTabHandlingMode(value: unknown, legacyCloseTabsOnDisconnect?: unknown): DisconnectTabHandlingMode {
|
||||||
if (DISCONNECT_TAB_HANDLING_MODES.includes(value as DisconnectTabHandlingMode)) {
|
if (DISCONNECT_TAB_HANDLING_MODES.includes(value as DisconnectTabHandlingMode)) {
|
||||||
return 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 {
|
export function normalizeEditorSettings(settings: Partial<EditorSettings>, existing?: EditorSettings): EditorSettings {
|
||||||
|
const sqlSemanticDiagnosticsMode = normalizeSqlSemanticDiagnosticsMode(settings.sqlSemanticDiagnosticsMode, settings.sqlSemanticDiagnosticsEnabled);
|
||||||
return {
|
return {
|
||||||
fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
|
fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
|
||||||
fontSize: settings.fontSize ?? DEFAULT_EDITOR_SETTINGS.fontSize,
|
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,
|
showExecutionTargetPicker: settings.showExecutionTargetPicker ?? DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker,
|
||||||
autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables,
|
autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables,
|
||||||
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
|
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
|
||||||
|
sqlSemanticDiagnosticsMode,
|
||||||
|
sqlSemanticDiagnosticsEnabled: sqlSemanticDiagnosticsEnabledForMode(sqlSemanticDiagnosticsMode),
|
||||||
confirmDangerousSqlExecution: settings.confirmDangerousSqlExecution ?? DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution,
|
confirmDangerousSqlExecution: settings.confirmDangerousSqlExecution ?? DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution,
|
||||||
compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle,
|
compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle,
|
||||||
appLayout: settings.appLayout ?? DEFAULT_EDITOR_SETTINGS.appLayout,
|
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.showExecutionTargetPicker !== undefined) editorSettings.value.showExecutionTargetPicker = partial.showExecutionTargetPicker;
|
||||||
if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables;
|
if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables;
|
||||||
if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap;
|
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.confirmDangerousSqlExecution !== undefined) editorSettings.value.confirmDangerousSqlExecution = partial.confirmDangerousSqlExecution;
|
||||||
if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle;
|
if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle;
|
||||||
if (partial.appLayout !== undefined) editorSettings.value.appLayout = partial.appLayout;
|
if (partial.appLayout !== undefined) editorSettings.value.appLayout = partial.appLayout;
|
||||||
|
|||||||
@@ -448,17 +448,25 @@ export interface SqlTableReference {
|
|||||||
schema?: string | null;
|
schema?: string | null;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
span: SqlTextSpan;
|
span: SqlTextSpan;
|
||||||
|
scope_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SqlColumnReference {
|
export interface SqlColumnReference {
|
||||||
name: string;
|
name: string;
|
||||||
qualifier?: string | null;
|
qualifier?: string | null;
|
||||||
span: SqlTextSpan;
|
span: SqlTextSpan;
|
||||||
|
scope_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqlReferenceScope {
|
||||||
|
id: number;
|
||||||
|
parent_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SqlReferenceAnalysis {
|
export interface SqlReferenceAnalysis {
|
||||||
tables: SqlTableReference[];
|
tables: SqlTableReference[];
|
||||||
columns: SqlColumnReference[];
|
columns: SqlColumnReference[];
|
||||||
|
scopes?: SqlReferenceScope[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TreeNodeType =
|
export type TreeNodeType =
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ static POSTGRES_DEFAULT_PRIVILEGES_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
pub struct SqlReferenceAnalysis {
|
pub struct SqlReferenceAnalysis {
|
||||||
pub tables: Vec<SqlTableReference>,
|
pub tables: Vec<SqlTableReference>,
|
||||||
pub columns: Vec<SqlColumnReference>,
|
pub columns: Vec<SqlColumnReference>,
|
||||||
|
pub scopes: Vec<SqlReferenceScope>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -35,6 +36,7 @@ pub struct SqlTableReference {
|
|||||||
pub schema: Option<String>,
|
pub schema: Option<String>,
|
||||||
pub alias: Option<String>,
|
pub alias: Option<String>,
|
||||||
pub span: SqlTextSpan,
|
pub span: SqlTextSpan,
|
||||||
|
pub scope_id: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -42,6 +44,13 @@ pub struct SqlColumnReference {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub qualifier: Option<String>,
|
pub qualifier: Option<String>,
|
||||||
pub span: SqlTextSpan,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -67,15 +76,18 @@ impl From<Span> for SqlTextSpan {
|
|||||||
struct Analyzer {
|
struct Analyzer {
|
||||||
tables: Vec<SqlTableReference>,
|
tables: Vec<SqlTableReference>,
|
||||||
columns: Vec<SqlColumnReference>,
|
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> {
|
pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result<SqlReferenceAnalysis, String> {
|
||||||
let normalized_dialect = normalize_dialect(dialect);
|
let normalized_dialect = normalize_dialect(dialect);
|
||||||
if normalized_dialect == "duckdb" && starts_with_duckdb_parser_gap_sql(sql) {
|
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) {
|
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" {
|
let parser_sql = if normalized_dialect == "clickhouse" {
|
||||||
normalize_clickhouse_join_order_for_parser(sql)
|
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);
|
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 {
|
fn starts_with_duckdb_parser_gap_sql(sql: &str) -> bool {
|
||||||
@@ -147,14 +159,31 @@ fn normalize_dialect(dialect: Option<&str>) -> String {
|
|||||||
impl Analyzer {
|
impl Analyzer {
|
||||||
fn visit_statement(&mut self, statement: &Statement) {
|
fn visit_statement(&mut self, statement: &Statement) {
|
||||||
if let Statement::Query(query) = 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) {
|
fn visit_query(&mut self, query: &Query) {
|
||||||
if let Some(with) = &query.with {
|
if let Some(with) = &query.with {
|
||||||
for cte in &with.cte_tables {
|
for cte in &with.cte_tables {
|
||||||
self.visit_query(&cte.query);
|
self.visit_child_query(&cte.query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.visit_set_expr(&query.body);
|
self.visit_set_expr(&query.body);
|
||||||
@@ -170,15 +199,24 @@ impl Analyzer {
|
|||||||
fn visit_set_expr(&mut self, set_expr: &SetExpr) {
|
fn visit_set_expr(&mut self, set_expr: &SetExpr) {
|
||||||
match set_expr {
|
match set_expr {
|
||||||
SetExpr::Select(select) => self.visit_select(select),
|
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, .. } => {
|
SetExpr::SetOperation { left, right, .. } => {
|
||||||
self.visit_set_expr(left);
|
self.visit_set_expr_in_child_scope(left);
|
||||||
self.visit_set_expr(right);
|
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) {
|
fn visit_select(&mut self, select: &Select) {
|
||||||
for table in &select.from {
|
for table in &select.from {
|
||||||
self.visit_table_with_joins(table);
|
self.visit_table_with_joins(table);
|
||||||
@@ -272,12 +310,16 @@ impl Analyzer {
|
|||||||
match factor {
|
match factor {
|
||||||
TableFactor::Table { name, alias, args, .. } => {
|
TableFactor::Table { name, alias, args, .. } => {
|
||||||
if args.is_none() {
|
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);
|
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::NestedJoin { table_with_joins, .. } => self.visit_table_with_joins(table_with_joins),
|
||||||
TableFactor::TableFunction { expr, .. } => self.visit_expr(expr),
|
TableFactor::TableFunction { expr, .. } => self.visit_expr(expr),
|
||||||
TableFactor::Function { args, .. } => {
|
TableFactor::Function { args, .. } => {
|
||||||
@@ -383,14 +425,14 @@ impl Analyzer {
|
|||||||
self.visit_expr(else_result);
|
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) {
|
fn visit_function_args(&mut self, args: &FunctionArguments) {
|
||||||
match args {
|
match args {
|
||||||
FunctionArguments::Subquery(query) => self.visit_query(query),
|
FunctionArguments::Subquery(query) => self.visit_child_query(query),
|
||||||
FunctionArguments::List(list) => {
|
FunctionArguments::List(list) => {
|
||||||
for arg in &list.args {
|
for arg in &list.args {
|
||||||
self.visit_function_arg(arg);
|
self.visit_function_arg(arg);
|
||||||
@@ -418,16 +460,27 @@ impl Analyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn push_column(&mut self, qualifier: Option<String>, ident: &Ident) {
|
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 parts: Vec<&Ident> = name.0.iter().filter_map(ObjectNamePart::as_ident).collect();
|
||||||
let table = parts.last()?;
|
let table = parts.last()?;
|
||||||
let schema = if parts.len() >= 2 { parts.get(parts.len() - 2).map(|ident| ident.value.clone()) } else { None };
|
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> {
|
fn object_name_last_ident(name: &ObjectName) -> Option<&Ident> {
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ fn extracts_tables_aliases_and_qualified_columns() {
|
|||||||
assert_eq!(columns, vec![(Some("u"), "missing"), (Some("u"), "id")]);
|
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]
|
#[test]
|
||||||
fn extracts_unqualified_columns_from_single_table_select() {
|
fn extracts_unqualified_columns_from_single_table_select() {
|
||||||
let analysis = analyze_sql_references("select missing, id from users", Some("postgres")).unwrap();
|
let analysis = analyze_sql_references("select missing, id from users", Some("postgres")).unwrap();
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ test("flags confirmed missing tables", () => {
|
|||||||
assert.equal(diagnostics[0]?.severity, "error");
|
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", () => {
|
test("flags missing columns when column metadata is cached with a schema key", () => {
|
||||||
const analysis: SqlReferenceAnalysis = {
|
const analysis: SqlReferenceAnalysis = {
|
||||||
tables: [{ name: "t_10001", span: span(24, 32) }],
|
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", () => {
|
test("does not flag unqualified columns when multiple tables make ownership ambiguous", () => {
|
||||||
const analysis: SqlReferenceAnalysis = {
|
const analysis: SqlReferenceAnalysis = {
|
||||||
tables: [
|
tables: [
|
||||||
|
|||||||
Reference in new issue
Block a user