fix: 完善 SQL 表名和字段语义诊断 (#1961)

Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
zipg 2026-06-26 18:47:10 +08:00 committed by GitHub
parent 2b99cd3b62
commit 90bed417e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 333 additions and 53 deletions

View File

@ -43,7 +43,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/completionMetadataPolicy";
import { qualifiedTableNameAtSqlPosition } from "@/lib/queryCursorTableTarget";
import * as api from "@/lib/api";
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, shouldRunSqlSemanticDiagnostics, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics";
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics, tableReferenceKey, type SqlSemanticDiagnostic } from "@/lib/sqlSemanticDiagnostics";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redisSyntaxDiagnostics";
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redisCompletion";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionTable } from "@/lib/sqlCompletion";
@ -59,6 +59,7 @@ const props = defineProps<{
formatDialect?: SqlFormatDialect;
formatRequestId?: number;
executionError?: string;
executionErrorSql?: string;
readOnly?: boolean;
forceWordWrap?: boolean;
initialViewport?: { scrollTop: number; scrollLeft: number };
@ -145,6 +146,7 @@ const completionTranslations = computed(() => ({
}));
const MAX_COMPLETION_TABLES = 200;
const MAX_JOIN_FK_PREFETCH_TABLES = 24;
const MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES = 4;
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
const isGestureZooming = ref(false);
@ -222,6 +224,7 @@ let cachedCompletionObjects: SqlCompletionObject[] = [];
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
const zoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => {
if (settingsStore.editorSettings.fontSize === fontSize) return;
@ -667,14 +670,34 @@ function completionTablesMatch(left: { name: string; schema?: string | null }, r
return left.schema.toLowerCase() === right.schema.toLowerCase();
}
async function ensureColumnsForTable(table: { name: string; schema?: string | null }) {
const cacheKey = completionCacheKey(table);
if (cachedColumnsByTable.has(cacheKey) || !props.connectionId || props.database == null) return;
async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promise<{ name: string; schema?: string; type?: "table" | "view" } | null> {
if (!props.connectionId || props.database == null) return null;
const target = completionMetadataTarget(table);
if (!target) return;
if (!target) return null;
const localMatches = connectionStore.lookupLocalCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema);
const localExact = localMatches.find((item) => completionTablesMatch(item, table));
if (localExact) return localExact;
const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema);
cachedTables = mergeCompletionTables(cachedTables, remoteMatches);
return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null;
}
async function ensureColumnsForTable(table: { name: string; schema?: string | null }): Promise<boolean> {
const cacheKey = completionCacheKey(table);
if (cachedColumnsByTable.has(cacheKey)) return true;
if (!props.connectionId || props.database == null) return false;
const target = completionMetadataTarget(table);
if (!target) return false;
const columns = await connectionStore.listCompletionColumns(props.connectionId, target.database, table.name, target.schema);
if (columns.length === 0) return;
cachedColumnsByTable.set(cacheKey, columns);
loadedColumnsByTable.add(cacheKey.toLowerCase());
return true;
}
function isMissingTableMetadataError(error: unknown) {
const message = String(error instanceof Error ? error.message : error).toLowerCase();
return message.includes("42s02") || message.includes("1146") || message.includes("doesn't exist") || message.includes("does not exist") || message.includes("unknown table");
}
async function ensureForeignKeysForTable(table: { name: string; schema?: string | null }) {
@ -821,6 +844,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
function sqlErrorDecorationRange(currentState: import("@codemirror/state").EditorState) {
if (!props.executionError) return [];
if (!props.executionErrorSql || props.executionErrorSql !== currentState.doc.toString()) return [];
const location = parseSqlErrorLocation(props.executionError);
if (!location) return [];
const offset = lineColumnToOffset(currentState.doc.toString(), location);
@ -875,36 +899,50 @@ function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) {
reconfigureDiagnostics();
}
async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]) {
if (!props.connectionId || props.database == null) return tables;
async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]): Promise<{ tables: SqlTableReference[]; missingTables: Set<string> }> {
if (!props.connectionId || props.database == null) return { tables, missingTables: new Set() };
const enriched: SqlTableReference[] = [];
const missingTables = new Set<string>();
for (const table of tables) {
if (table.schema) {
enriched.push(table);
continue;
}
const cached = cachedTables.find((item) => item.name.toLowerCase() === table.name.toLowerCase());
if (cached?.schema) {
enriched.push({ ...table, schema: cached.schema });
continue;
}
try {
if (usesLocalOnlyCompletionMetadata()) {
const matches = connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, table.name, MAX_COMPLETION_TABLES, props.schema);
const match = matches.find((item) => item.name.toLowerCase() === table.name.toLowerCase());
enriched.push(match?.schema ? { ...table, schema: match.schema } : table);
continue;
}
const matches = await connectionStore.listCompletionTables(props.connectionId, props.database, table.name, MAX_COMPLETION_TABLES, props.schema);
cachedTables = [...cachedTables, ...matches];
const match = matches.find((item) => item.name.toLowerCase() === table.name.toLowerCase());
const match = await findExactSemanticDiagnosticTable(table);
if (!match) missingTables.add(tableReferenceKey(table));
enriched.push(match?.schema ? { ...table, schema: match.schema } : table);
} catch {
enriched.push(table);
}
}
return enriched;
return { tables: enriched, missingTables };
}
async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[]): Promise<Set<string>> {
const missingTables = new Set<string>();
const seen = new Set<string>();
const targets: SqlTableReference[] = [];
for (const table of tables) {
const tableWithInlineColumns = table as SqlTableReference & { columns?: string[] };
if (tableWithInlineColumns.columns && tableWithInlineColumns.columns.length > 0) continue;
const cacheKey = completionCacheKey(table);
if (cachedColumnsByTable.has(cacheKey)) continue;
const normalizedKey = cacheKey.toLowerCase();
if (seen.has(normalizedKey)) continue;
seen.add(normalizedKey);
targets.push(table);
if (targets.length >= MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES) break;
}
await Promise.all(
targets.map(async (table) => {
try {
await ensureColumnsForTable(table);
} catch (error) {
if (isMissingTableMetadataError(error)) {
missingTables.add(tableReferenceKey(table));
}
}
}),
);
return missingTables;
}
async function refreshSemanticDiagnostics() {
@ -937,7 +975,7 @@ async function refreshSemanticDiagnostics() {
scheduleSemanticDiagnostics(1200);
return;
}
if (codeMirrorCompletionStatus?.(currentView.state)) {
if (codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
scheduleSemanticDiagnostics(900);
return;
}
@ -946,10 +984,9 @@ async function refreshSemanticDiagnostics() {
const analysis = await api.analyzeSqlReferences(sql, props.formatDialect ?? props.dialect ?? "generic");
if (runId !== semanticDiagnosticRunId) return;
const tables = await enrichSemanticDiagnosticTables(analysis.tables);
if (!usesOnDemandOnlyCompletionColumns()) {
await Promise.all(tables.map((table) => ensureColumnsForTable(table)));
}
const { tables, missingTables } = await enrichSemanticDiagnosticTables(analysis.tables);
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables);
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
if (runId !== semanticDiagnosticRunId) return;
const enrichedAnalysis: SqlReferenceAnalysis = { ...analysis, tables };
@ -957,6 +994,9 @@ async function refreshSemanticDiagnostics() {
buildSqlSemanticDiagnostics(enrichedAnalysis, {
tables: cachedTables,
columnsByTable: cachedColumnsByTable,
missingTables,
loadedColumnTables: loadedColumnsByTable,
sql,
}),
);
} catch (error) {
@ -1782,6 +1822,7 @@ async function refreshCompletionCache() {
cachedTables = [];
cachedCompletionObjects = [];
cachedColumnsByTable.clear();
loadedColumnsByTable.clear();
cachedForeignKeysByTable.clear();
}
@ -2233,6 +2274,7 @@ watch(
view.value.dispatch({
changes: { from: 0, to: view.value.state.doc.length, insert: val },
});
scheduleSemanticDiagnostics();
}
},
);

View File

@ -599,6 +599,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:format-dialect="activeSqlFormatDialect"
:format-request-id="formatSqlRequest?.tabId === activeTab.id ? formatSqlRequest.id : undefined"
:execution-error="activeQueryError"
:execution-error-sql="activeTab.lastExecutedSql"
:initial-viewport="activeTab.editorViewport"
:initial-selection="activeTab.editorSelection"
@update:model-value="emit('editorUpdate', activeTab.id, $event)"

View File

@ -11,6 +11,9 @@ export interface SqlSemanticDiagnostic {
export interface SqlSemanticDiagnosticSchema {
tables: SqlCompletionTable[];
columnsByTable: Map<string, SqlCompletionColumn[]>;
missingTables?: Set<string>;
loadedColumnTables?: Set<string>;
sql?: string;
}
export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, schema: SqlSemanticDiagnosticSchema): SqlSemanticDiagnostic[] {
@ -24,11 +27,21 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
if (table.schema) knownTables.set(normalizeName(`${table.schema}.${table.name}`), table);
}
for (const column of analysis.columns) {
const table = resolveColumnTable(column, tables, knownTables);
if (!table) continue;
for (const table of tables) {
if (!schema.missingTables?.has(tableReferenceKey(table))) continue;
diagnostics.push({
span: table.span,
message: `Unknown table ${displayTableName(table)}`,
severity: "error",
});
}
const columns = columnsForTable(table, schema.columnsByTable);
for (const column of analysis.columns) {
const table = resolveColumnTable(column, tables, knownTables, schema.sql);
if (!table) continue;
if (schema.missingTables?.has(tableReferenceKey(table))) continue;
const columns = columnsForTable(table, schema.columnsByTable, schema.loadedColumnTables);
if (!columns) continue;
const columnNames = new Set(columns.map((item) => normalizeName(item.name)));
@ -38,7 +51,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche
diagnostics.push({
span: column.span,
message: `Unknown column ${displayName}`,
severity: "warning",
severity: "error",
});
}
@ -82,30 +95,94 @@ export function areSqlSemanticDiagnosticsEqual(left: readonly SqlSemanticDiagnos
export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
const context = getSqlCompletionContext(sql, cursor);
if (context.suggestTables || context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions) return false;
if (context.exclusiveColumnSuggestions) return false;
if (context.qualifier) return false;
if ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor)) return false;
return true;
}
function resolveColumnTable(column: SqlColumnReference, tables: SqlTableReference[], knownTables: Map<string, SqlTableReference>): SqlTableReference | null {
if (column.qualifier) {
return knownTables.get(normalizeName(column.qualifier)) ?? null;
}
if (tables.length !== 1) return null;
return tables[0];
export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number, options: { databaseType?: DatabaseType } = {}): boolean {
if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch" || options.databaseType === "qdrant" || options.databaseType === "milvus" || options.databaseType === "weaviate" || options.databaseType === "chromadb" || options.databaseType === "redis") return false;
const context = getSqlCompletionContext(sql, cursor);
return context.exclusiveColumnSuggestions || !!context.qualifier || ((context.suggestTables || context.exclusiveTableSuggestions) && isCursorAfterTableTrigger(sql, cursor));
}
function columnsForTable(table: SqlTableReference, columnsByTable: Map<string, SqlCompletionColumn[]>): SqlCompletionColumn[] | null {
const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name];
for (const key of keys) {
const columns = columnsByTable.get(key) ?? columnsByTable.get(normalizeName(key));
// Empty metadata usually means the upstream schema lookup was inconclusive,
// so avoid surfacing a false "unknown column" warning.
if (columns && columns.length > 0) return columns;
function resolveColumnTable(column: SqlColumnReference, tables: SqlTableReference[], knownTables: Map<string, SqlTableReference>, sql?: string): SqlTableReference | null {
const candidateTables = sql ? tablesInSameStatement(tables, column, sql) : tables;
if (column.qualifier) {
return tableLookupFor(candidateTables).get(normalizeName(column.qualifier)) ?? (sql ? null : (knownTables.get(normalizeName(column.qualifier)) ?? null));
}
if (candidateTables.length !== 1) return null;
return candidateTables[0];
}
function tableLookupFor(tables: SqlTableReference[]): Map<string, SqlTableReference> {
const lookup = new Map<string, SqlTableReference>();
for (const table of tables) {
lookup.set(normalizeName(table.name), table);
if (table.alias) lookup.set(normalizeName(table.alias), table);
if (table.schema) lookup.set(normalizeName(`${table.schema}.${table.name}`), table);
}
return lookup;
}
function tablesInSameStatement(tables: SqlTableReference[], column: SqlColumnReference, sql: string): SqlTableReference[] {
const columnOffset = spanStartOffset(sql, column.span);
if (columnOffset == null) return tables;
return tables.filter((table) => {
const tableOffset = spanStartOffset(sql, table.span);
return tableOffset != null && statementIndexAt(sql, tableOffset) === statementIndexAt(sql, columnOffset);
});
}
function spanStartOffset(sql: string, span: SqlTextSpan): number | null {
if (!span.start_line || !span.start_column) return null;
const lines = sql.split(/\r?\n/);
const lineIndex = span.start_line - 1;
if (lineIndex < 0 || lineIndex >= lines.length) return null;
let offset = 0;
for (let index = 0; index < lineIndex; index++) offset += lines[index].length + 1;
return Math.min(offset + span.start_column - 1, offset + lines[lineIndex].length);
}
function statementIndexAt(sql: string, offset: number): number {
let statementIndex = 0;
for (let index = 0; index < Math.min(offset, sql.length); index++) {
if (sql[index] === ";") statementIndex++;
}
return statementIndex;
}
function columnsForTable(table: SqlTableReference, columnsByTable: Map<string, SqlCompletionColumn[]>, loadedColumnTables?: Set<string>): SqlCompletionColumn[] | null {
const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name, ...keysWithTableName(columnsByTable, table.name)];
for (const key of keys) {
const normalizedKey = normalizeName(key);
const columns = columnsByTable.get(key) ?? columnsByTable.get(normalizedKey);
if (!columns) continue;
if (columns.length > 0 || loadedColumnTables?.has(normalizedKey)) return columns;
}
if (loadedColumnTables?.has(tableReferenceKey(table))) return [];
return null;
}
function keysWithTableName(columnsByTable: Map<string, SqlCompletionColumn[]>, tableName: string): string[] {
const suffix = `.${normalizeName(tableName)}`;
return [...columnsByTable.keys()].filter((key) => normalizeName(key).endsWith(suffix));
}
export function tableReferenceKey(table: Pick<SqlTableReference, "name" | "schema">): string {
return normalizeName(table.schema ? `${table.schema}.${table.name}` : table.name);
}
function displayTableName(table: SqlTableReference): string {
return table.schema ? `${table.schema}.${table.name}` : table.name;
}
function isCursorAfterTableTrigger(sql: string, cursor: number): boolean {
const beforeCursor = sql.slice(0, cursor).trimEnd();
return /\b(from|join|update|into|table)(?:\s+[\w$`"'\[\].]*)?$/i.test(beforeCursor);
}
function normalizeName(value: string): string {
let normalized = value;
while (normalized && `"'\`[]`.includes(normalized[0])) normalized = normalized.slice(1);

View File

@ -415,7 +415,7 @@ impl Analyzer {
fn table_reference_from_name(name: &ObjectName, alias: Option<String>) -> Option<SqlTableReference> {
let parts: Vec<&Ident> = name.0.iter().filter_map(ObjectNamePart::as_ident).collect();
let table = parts.last()?;
let schema = parts.get(parts.len().saturating_sub(2)).map(|ident| ident.value.clone());
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() })
}

View File

@ -22,6 +22,28 @@ fn extracts_unqualified_columns_from_single_table_select() {
assert_eq!(columns, vec![(None, "missing"), (None, "id")]);
}
#[test]
fn extracts_mysql_quoted_table_references() {
let analysis = analyze_sql_references("SELECT * FROM `t_19991` LIMIT 100", Some("mysql")).unwrap();
assert_eq!(analysis.tables.len(), 1);
assert_eq!(analysis.tables[0].name, "t_19991");
assert_eq!(analysis.tables[0].schema, None);
assert_eq!(analysis.tables[0].span.start_line, 1);
assert_eq!(analysis.tables[0].span.start_column, 15);
assert_eq!(analysis.tables[0].span.end_line, 1);
assert_eq!(analysis.tables[0].span.end_column, 24);
}
#[test]
fn extracts_mysql_single_quoted_table_references() {
let analysis = analyze_sql_references("SELECT * FROM 't_10001' LIMIT 100", Some("mysql")).unwrap();
assert_eq!(analysis.tables.len(), 1);
assert_eq!(analysis.tables[0].name, "t_10001");
assert_eq!(analysis.tables[0].schema, None);
}
#[test]
fn extracts_unqualified_order_by_columns_for_sqlserver_queries() {
let analysis =

View File

@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, areSqlSemanticDiagnosticsEqual, shouldRunSqlSemanticDiagnostics } from "../../apps/desktop/src/lib/sqlSemanticDiagnostics.ts";
import { buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, areSqlSemanticDiagnosticsEqual, isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics } from "../../apps/desktop/src/lib/sqlSemanticDiagnostics.ts";
import type { SqlReferenceAnalysis } from "../../apps/desktop/src/types/database.ts";
const span = (startColumn: number, endColumn: number) => ({
@ -28,6 +28,141 @@ test("flags missing qualified columns against the referenced table", () => {
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown column u.missing"],
);
assert.equal(diagnostics[0]?.severity, "error");
});
test("flags confirmed missing tables", () => {
const analysis: SqlReferenceAnalysis = {
tables: [{ name: "t_19991", span: span(15, 23) }],
columns: [],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [],
columnsByTable: new Map(),
missingTables: new Set(["t_19991"]),
});
assert.deepEqual(
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown table t_19991"],
);
assert.equal(diagnostics[0]?.severity, "error");
});
test("flags missing columns when column metadata is cached with a schema key", () => {
const analysis: SqlReferenceAnalysis = {
tables: [{ name: "t_10001", span: span(24, 32) }],
columns: [{ name: "bad_field", span: span(8, 17) }],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [{ name: "t_10001", type: "table" }],
columnsByTable: new Map([["demo_2000_tables.t_10001", [{ name: "id", table: "t_10001" }]]]),
});
assert.deepEqual(
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown column bad_field"],
);
assert.equal(diagnostics[0]?.severity, "error");
});
test("flags missing columns when loaded column metadata is empty", () => {
const analysis: SqlReferenceAnalysis = {
tables: [{ name: "t_0001", span: span(15, 22) }],
columns: [{ name: "ids", span: span(30, 33) }],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [{ name: "t_0001", type: "table" }],
columnsByTable: new Map([["t_0001", []]]),
loadedColumnTables: new Set(["t_0001"]),
});
assert.deepEqual(
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown column ids"],
);
});
test("flags where-clause columns missing from a single referenced table", () => {
const analysis: SqlReferenceAnalysis = {
tables: [{ name: "t_0001", span: span(15, 22) }],
columns: [{ name: "ids", span: span(30, 33) }],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [{ name: "t_0001", type: "table" }],
columnsByTable: new Map([["t_0001", ["id", "image_name", "image_mime", "image_data", "image_url"].map((name) => ({ name, table: "t_0001" }))]]),
});
assert.deepEqual(
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown column ids"],
);
});
test("resolves unqualified columns against the table in the same statement", () => {
const sql = "SELECT * FROM `t_00011` WHERE id > 1; SELECT * FROM `t_0001` where ids > 1 LIMIT 50;";
const analysis: SqlReferenceAnalysis = {
tables: [
{ name: "t_00011", span: { start_line: 1, start_column: 15, end_line: 1, end_column: 24 } },
{ name: "t_0001", span: { start_line: 1, start_column: 54, end_line: 1, end_column: 62 } },
],
columns: [
{ name: "id", span: { start_line: 1, start_column: 31, end_line: 1, end_column: 33 } },
{ name: "ids", span: { start_line: 1, start_column: 69, end_line: 1, end_column: 72 } },
],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [
{ name: "t_00011", type: "table" },
{ name: "t_0001", type: "table" },
],
columnsByTable: new Map([
["t_00011", [{ name: "id", table: "t_00011" }]],
["t_0001", [{ name: "id", table: "t_0001" }]],
]),
sql,
});
assert.deepEqual(
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown column ids"],
);
});
test("resolves qualified aliases against the table in the same statement", () => {
const sql = "SELECT x.id FROM t_one x; SELECT x.bad FROM t_two x;";
const analysis: SqlReferenceAnalysis = {
tables: [
{ name: "t_one", alias: "x", span: { start_line: 1, start_column: 18, end_line: 1, end_column: 23 } },
{ name: "t_two", alias: "x", span: { start_line: 1, start_column: 45, end_line: 1, end_column: 50 } },
],
columns: [
{ name: "id", qualifier: "x", span: { start_line: 1, start_column: 10, end_line: 1, end_column: 12 } },
{ name: "bad", qualifier: "x", span: { start_line: 1, start_column: 36, end_line: 1, end_column: 39 } },
],
};
const diagnostics = buildSqlSemanticDiagnostics(analysis, {
tables: [
{ name: "t_one", type: "table" },
{ name: "t_two", type: "table" },
],
columnsByTable: new Map([
["t_one", [{ name: "id", table: "t_one" }]],
["t_two", [{ name: "other", table: "t_two" }]],
]),
sql,
});
assert.deepEqual(
diagnostics.map((diagnostic) => diagnostic.message),
["Unknown column x.bad"],
);
});
test("does not flag unqualified columns when multiple tables make ownership ambiguous", () => {
@ -81,6 +216,9 @@ test("defers diagnostics while the cursor is in table completion context", () =>
assert.equal(shouldRunSqlSemanticDiagnostics("select * from us", "select * from us".length), false);
assert.equal(shouldRunSqlSemanticDiagnostics("select u.", "select u.".length), false);
assert.equal(shouldRunSqlSemanticDiagnostics("select * from users where missing = 1", 42), true);
assert.equal(shouldRunSqlSemanticDiagnostics("SELECT * FROM `t_19991` LIMIT 100", "SELECT * FROM `t_19991` LIMIT 100".length, { databaseType: "mysql" }), true);
assert.equal(shouldRunSqlSemanticDiagnostics("SELECT * FROM `t_0001` where ids > 1 LIMIT 50;", "SELECT * FROM `t_0001` where ids > 1 LIMIT 50;".length, { databaseType: "mysql" }), true);
assert.equal(isSqlSemanticDiagnosticInputContext("SELECT * FROM `t_0001` where ids > 1 LIMIT 50;", "SELECT * FROM `t_0001` where ids > 1 LIMIT 50;".length, { databaseType: "mysql" }), false);
});
test("skips diagnostics for MongoDB connections", () => {