fix: 完善 SQL 表名和字段语义诊断 (#1961)
Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
parent
2b99cd3b62
commit
90bed417e9
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue