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