diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index fb4b6833c..940a046b6 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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(); const cachedForeignKeysByTable = new Map(); +const loadedColumnsByTable = new Set(); 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 { + 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 }> { + if (!props.connectionId || props.database == null) return { tables, missingTables: new Set() }; const enriched: SqlTableReference[] = []; + const missingTables = new Set(); 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> { + const missingTables = new Set(); + const seen = new Set(); + 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(); } }, ); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index a6988f16b..e20e9538f 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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)" diff --git a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts index 7cee9eef4..d887a4fc6 100644 --- a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts +++ b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts @@ -11,6 +11,9 @@ export interface SqlSemanticDiagnostic { export interface SqlSemanticDiagnosticSchema { tables: SqlCompletionTable[]; columnsByTable: Map; + missingTables?: Set; + loadedColumnTables?: Set; + 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): 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): 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, 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 { + const lookup = new Map(); + 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, loadedColumnTables?: Set): 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, tableName: string): string[] { + const suffix = `.${normalizeName(tableName)}`; + return [...columnsByTable.keys()].filter((key) => normalizeName(key).endsWith(suffix)); +} + +export function tableReferenceKey(table: Pick): 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); diff --git a/crates/dbx-core/src/sql_analysis.rs b/crates/dbx-core/src/sql_analysis.rs index 335a286aa..90caf3164 100644 --- a/crates/dbx-core/src/sql_analysis.rs +++ b/crates/dbx-core/src/sql_analysis.rs @@ -415,7 +415,7 @@ impl Analyzer { fn table_reference_from_name(name: &ObjectName, alias: Option) -> Option { 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() }) } diff --git a/crates/dbx-core/tests/sql_analysis.rs b/crates/dbx-core/tests/sql_analysis.rs index dfb344f73..e6d4c263a 100644 --- a/crates/dbx-core/tests/sql_analysis.rs +++ b/crates/dbx-core/tests/sql_analysis.rs @@ -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 = diff --git a/packages/app-tests/sqlSemanticDiagnostics.test.ts b/packages/app-tests/sqlSemanticDiagnostics.test.ts index cad425cc1..9e809394b 100644 --- a/packages/app-tests/sqlSemanticDiagnostics.test.ts +++ b/packages/app-tests/sqlSemanticDiagnostics.test.ts @@ -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", () => {