Files
dbx/apps/desktop/src/lib/editor/sqlIntentionActions.ts
T

821 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DatabaseType } from "@/types/database";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references";
import type { SqlSemanticModel, SqlSemanticRowSource, SqlSemanticCursorIntent, SqlSemanticScope, SqlSemanticToken } from "@/lib/sql/semantic/types";
import { unquoteSqlSemanticIdentifier, tokenIsIdentifier } from "@/lib/sql/semantic/tokens";
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { supportsColumnNameQuoting } from "@/lib/dataGrid/dataGridColumnNameCopy";
// ==================== Types ====================
export interface IntentionAction {
kind: "expand_wildcard" | "qualify_identifier" | "unqualify_identifier" | "batch_qualify_identifiers";
span: { start: number; end: number };
replacement: string;
/** batch_qualify_identifiers 专用:每个标识符的独立替换 */
replacements?: Array<{ span: { start: number; end: number }; replacement: string }>;
/** 自定义标签(多表限定场景显示 "Qualify with su" 等) */
label?: string;
}
export interface IntentionActionContext {
sql: string;
cursor: number;
databaseType?: DatabaseType;
dialect?: "mysql" | "postgres" | "sqlserver";
/** 选择区间,非空时优先走批量限定逻辑 */
selection?: { from: number; to: number };
}
// SQL 关键字集合(小写),用于批量限定时跳过
const SQL_KEYWORDS = new Set([
"select",
"from",
"where",
"and",
"or",
"not",
"in",
"exists",
"between",
"like",
"is",
"null",
"as",
"on",
"join",
"inner",
"left",
"right",
"full",
"outer",
"cross",
"group",
"by",
"order",
"having",
"limit",
"offset",
"union",
"all",
"distinct",
"case",
"when",
"then",
"else",
"end",
"asc",
"desc",
"insert",
"into",
"values",
"update",
"set",
"delete",
"create",
"table",
"drop",
"alter",
"add",
"column",
"primary",
"key",
"foreign",
"references",
"default",
"constraint",
"unique",
"index",
"with",
"recursive",
"returning",
"intersect",
"except",
"using",
"natural",
"lateral",
"window",
"over",
"partition",
]);
// 热循环中使用的正则常量,避免重复编译
const WHITESPACE_RE = /\s/;
const IDENT_START_RE = /[a-zA-Z_]/;
const IDENT_PART_RE = /[a-zA-Z0-9_$]/;
// ==================== Entry Point ====================
export function analyzeIntentionActions(ctx: IntentionActionContext): IntentionAction[] {
// P0: 选中区间优先 → 批量限定
if (ctx.selection && ctx.selection.from < ctx.selection.to) {
return buildBatchQualifyActions(ctx);
}
const model = buildSqlSemanticModel(ctx.sql, ctx.cursor, {
databaseType: ctx.databaseType,
dialect: ctx.dialect,
});
const { cursorIntent } = model;
switch (cursorIntent.kind) {
case "star":
return buildExpandWildcardActions(model, cursorIntent);
case "column":
case "alias_column":
case "update_column":
case "insert_column":
case "join_condition":
return buildQualifierActions(model, cursorIntent, ctx.sql, ctx.databaseType);
case "keyword":
if (cursorIntent.qualifierParts.length > 0 || cursorIntent.replacementRange.start < cursorIntent.replacementRange.end) {
return buildQualifierActions(model, cursorIntent, ctx.sql, ctx.databaseType);
}
return [];
default:
return [];
}
}
// ==================== Expand Wildcard ====================
/**
* 查找光标所在的 SELECT scope(用于通配符展开和上下文准备)。
*/
function findSelectScopeForCursor(model: SqlSemanticModel, cursorIntent: SqlSemanticCursorIntent) {
return model.scopes.find((s) => {
if (!s.clauseSpans.select) return false;
const selectStart = s.clauseSpans.select.start;
const nextClause = s.clauseSpans.from ?? s.clauseSpans.where ?? s.clauseSpans.having ?? s.clauseSpans.groupBy ?? s.clauseSpans.orderBy ?? s.clauseSpans.limit;
const selectEnd = nextClause?.start ?? Infinity;
return cursorIntent.replacementRange.start >= selectStart && cursorIntent.replacementRange.start < selectEnd;
});
}
function buildExpandWildcardActions(model: SqlSemanticModel, cursorIntent: SqlSemanticCursorIntent): IntentionAction[] {
const scope = findSelectScopeForCursor(model, cursorIntent);
if (!scope || scope.rowSources.length === 0) return [];
const rowSources = scope.rowSources.filter((rs) => rs.kind === "table" || rs.kind === "cte");
if (rowSources.length === 0) return [];
return [
{
kind: "expand_wildcard" as const,
span: cursorIntent.replacementRange,
replacement: "",
},
];
}
export async function buildExpandWildcardReplacement(databaseType: DatabaseType | undefined, rowSources: SqlSemanticRowSource[], fetchColumns: (source: SqlSemanticRowSource) => Promise<string[]>): Promise<string> {
const quote = supportsColumnNameQuoting(databaseType);
const allColumns: string[] = [];
for (const source of rowSources) {
const columns = await fetchColumns(source);
const rawName = source.alias ?? source.name;
const qualifier = quote ? quoteTableIdentifier(databaseType, rawName) : rawName;
for (const col of columns) {
const quoted = quote ? quoteTableIdentifier(databaseType, col) : col;
allColumns.push(rowSources.length > 1 ? `${qualifier}.${quoted}` : quoted);
}
}
return allColumns.join(", ");
}
// ==================== Batch Qualify (P0) ====================
/**
* 在选中区间内扫描独立标识符,为未限定的标识符批量添加表前缀。
*/
function buildBatchQualifyActions(ctx: IntentionActionContext): IntentionAction[] {
const { sql, selection, databaseType, dialect } = ctx;
if (!selection) return [];
// 用选中区间起点构建语义模型
const model = buildSqlSemanticModel(sql, selection.from, {
databaseType,
dialect,
});
// 检查是否为多表场景
const scope = model.scopes.find((s) => s.rowSources.length > 0);
const tableSources = scope?.rowSources.filter((rs) => rs.kind === "table" || rs.kind === "cte") ?? [];
const isMultiTable = tableSources.length > 1;
// 单表场景:解析统一表源
let singleQualifier: string | null = null;
if (!isMultiTable) {
const tableTarget = resolveNavigationTarget(model, model.cursorIntent);
if (!tableTarget) return [];
const quote = supportsColumnNameQuoting(databaseType);
singleQualifier = quote ? quoteTableIdentifier(databaseType, tableTarget.alias ?? tableTarget.name) : (tableTarget.alias ?? tableTarget.name);
}
// 扫描选中区间内的标识符
const selectedText = sql.slice(selection.from, selection.to);
const tokens = tokenizeSelectionIdentifiers(selectedText, databaseType);
const replacements: NonNullable<IntentionAction["replacements"]> = [];
const offset = selection.from;
// 收集当前 scope 的 projection 别名,用于跳过
const aliasNames = collectProjectionAliasNames(model);
const quote = supportsColumnNameQuoting(databaseType);
// 多表场景:一次性构建 column → qualifiers 映射,避免 N 次重复扫描全量 token
const qualifierMap = isMultiTable ? buildColumnQualifierMap(model) : undefined;
for (const token of tokens) {
// 跳过已限定标识符
if (token.qualified) continue;
// 跳过 SQL 关键字
if (SQL_KEYWORDS.has(token.text.toLowerCase())) continue;
// 跳过 projection 别名(P1-2)
if (aliasNames.has(token.text.toLowerCase())) continue;
let qualifier: string;
if (isMultiTable) {
// 多表场景:为每个 token 独立解析表源(使用缓存的 qualifierMap)
const tokenModel = buildSqlSemanticModel(sql, offset + token.start, {
databaseType,
dialect,
});
const tokenTarget = resolveNavigationTarget(tokenModel, tokenModel.cursorIntent, qualifierMap);
if (!tokenTarget) continue; // 无法确定表源,跳过
qualifier = quote ? quoteTableIdentifier(databaseType, tokenTarget.alias ?? tokenTarget.name) : (tokenTarget.alias ?? tokenTarget.name);
} else {
qualifier = singleQualifier!;
}
const quotedColumn = quote ? quoteTableIdentifier(databaseType, token.text) : token.text;
replacements.push({
span: { start: offset + token.start, end: offset + token.end },
replacement: `${qualifier}.${quotedColumn}`,
});
}
if (replacements.length === 0) return [];
return [
{
kind: "batch_qualify_identifiers" as const,
span: { start: selection.from, end: selection.to },
replacement: "",
replacements,
},
];
}
interface SelectionToken {
/** 在选中区间内的起始偏移 */
start: number;
/** 在选中区间内的结束偏移 */
end: number;
/** 剥离引号后的标识符文本 */
text: string;
/** 是否已限定(含 "." 分隔符) */
qualified: boolean;
}
/**
* 在选中文本中扫描独立标识符,处理各种引号包裹方式。
*/
function tokenizeSelectionIdentifiers(text: string, databaseType?: DatabaseType): SelectionToken[] {
const tokens: SelectionToken[] = [];
const doubleQuoteIsIdentifier = usesDoubleQuoteIdentifier(databaseType);
let i = 0;
while (i < text.length) {
const ch = text[i];
// 跳过单行注释 --
if (ch === "-" && text[i + 1] === "-") {
const nl = text.indexOf("\n", i);
i = nl === -1 ? text.length : nl + 1;
continue;
}
// 跳过多行注释 /* */
if (ch === "/" && text[i + 1] === "*") {
const end = text.indexOf("*/", i + 2);
i = end === -1 ? text.length : end + 2;
continue;
}
// 跳过字符串字面量(单引号包裹,支持 '' 转义)
if (ch === "'") {
i++;
while (i < text.length) {
if (text[i] === "'" && text[i + 1] === "'") {
i += 2;
continue;
}
if (text[i] === "'") {
i++;
break;
}
i++;
}
continue;
}
// 跳过双引号字符串字面量(当数据库不使用双引号作为标识符引用时)
if (ch === '"' && !doubleQuoteIsIdentifier) {
const end = text.indexOf('"', i + 1);
i = end === -1 ? text.length : end + 1;
continue;
}
// 跳过空白和标点(逗号、括号等)
if (WHITESPACE_RE.test(ch) || ch === "," || ch === "(" || ch === ")" || ch === ";" || ch === "*" || ch === "+" || ch === "-" || ch === "/" || ch === "=") {
i++;
continue;
}
// 标识符起始:字母、下划线、反引号、双引号、方括号
const identStart = i;
let parts: string[] = [];
let qualified = false;
// 循环读取 qualified.identifier 形式
while (i < text.length) {
const part = readIdentifierPart(text, i, databaseType, doubleQuoteIsIdentifier);
if (!part) break;
parts.push(part.text);
i = part.end;
// 检查后面是否紧跟 "."
if (text[i] === ".") {
qualified = true;
i++;
continue;
}
break;
}
if (parts.length > 0) {
// 取最后一个 part 作为列名(去掉引号后的文本)
const lastPart = parts[parts.length - 1];
if (lastPart.length > 0) {
tokens.push({
start: identStart,
end: i,
text: lastPart,
qualified,
});
}
} else {
i++;
}
}
return tokens;
}
/**
* 判断当前数据库是否使用双引号作为标识符引用。
* PostgreSQL/Oracle/DB2 等使用双引号,MySQL/SQL Server 中双引号是字符串字面量。
*/
function usesDoubleQuoteIdentifier(databaseType?: DatabaseType): boolean {
if (!databaseType) return true;
return quoteTableIdentifier(databaseType, "x").startsWith('"');
}
/**
* 读取一个标识符分段(可能被引号包裹),返回剥离引号后的文本和结束位置。
* `doubleQuoteIsIdentifier` 参数用于避免在热循环中重复调用 `usesDoubleQuoteIdentifier`。
*/
function readIdentifierPart(text: string, start: number, databaseType?: DatabaseType, doubleQuoteIsIdentifier?: boolean): { text: string; end: number } | null {
const ch = text[start];
// MySQL 反引号
if (ch === "`") {
const end = text.indexOf("`", start + 1);
if (end === -1) return null;
return { text: text.slice(start + 1, end), end: end + 1 };
}
// 双引号:仅在数据库使用双引号作为标识符引用时处理
const dqIsIdent = doubleQuoteIsIdentifier ?? usesDoubleQuoteIdentifier(databaseType);
if (ch === '"' && dqIsIdent) {
const end = text.indexOf('"', start + 1);
if (end === -1) return null;
return { text: text.slice(start + 1, end), end: end + 1 };
}
// SQL Server 方括号
if (ch === "[") {
const end = text.indexOf("]", start + 1);
if (end === -1) return null;
return { text: text.slice(start + 1, end), end: end + 1 };
}
// 普通标识符:字母、数字、下划线
if (IDENT_START_RE.test(ch)) {
let i = start;
while (i < text.length && IDENT_PART_RE.test(text[i])) i++;
return { text: text.slice(start, i), end: i };
}
return null;
}
/**
* 收集当前模型所有 scope 中的 projection 别名(小写),用于跳过别名列。
*/
function collectProjectionAliasNames(model: SqlSemanticModel): Set<string> {
const names = new Set<string>();
for (const scope of model.scopes) {
for (const proj of scope.projections) {
if (proj.alias) {
names.add(proj.alias.toLowerCase());
}
}
}
return names;
}
/**
* 收集所有行源的表名和别名(小写),用于跳过表别名。
* 表别名不是列名,不应提供限定操作。
*/
function collectTableAliasNames(model: SqlSemanticModel): Set<string> {
const names = new Set<string>();
for (const rs of model.rowSources) {
if (rs.alias) names.add(rs.alias.toLowerCase());
names.add(rs.name.toLowerCase());
}
return names;
}
/**
* 将 token 标准化为小写无引号文本,用于跨引号风格的统一比较。
*/
function normalizeIdentifierToken(token: SqlSemanticToken): string {
return unquoteSqlSemanticIdentifier(token).toLowerCase();
}
/**
* 提取 qualifier 的完整文本(处理 schema.table.column 三段式标识符)。
* 当 tokens[i] 前面还有 `schema.` 时,返回 `schema.table`,否则返回 `table`。
*/
function extractQualifier(tokens: readonly SqlSemanticToken[], i: number): string {
// 检查是否为 3-part: schema.table.column → qualifier = schema.table
if (i >= 2 && tokens[i - 1]?.text === "." && tokenIsIdentifier(tokens[i - 2])) {
return `${normalizeIdentifierToken(tokens[i - 2])}.${normalizeIdentifierToken(tokens[i])}`;
}
return normalizeIdentifierToken(tokens[i]);
}
/**
* 扫描 SQL 中的 `qualifier.column` 模式,收集引用指定列名的不同限定符。
* 支持 2-part (table.column) 和 3-part (schema.table.column) 标识符。
*/
function collectColumnQualifiers(model: SqlSemanticModel, columnName: string): Set<string> {
const target = columnName.toLowerCase();
const qualifiers = new Set<string>();
const tokens = model.tokens;
for (let i = 0; i < tokens.length - 2; i++) {
if (tokenIsIdentifier(tokens[i]) && tokens[i + 1]?.text === "." && tokenIsIdentifier(tokens[i + 2])) {
if (normalizeIdentifierToken(tokens[i + 2]) === target) {
qualifiers.add(extractQualifier(tokens, i));
}
}
}
return qualifiers;
}
/**
* 一次性构建 `column → qualifiers` 映射,避免批量场景中 N 次重复扫描全量 token。
* 支持 2-part (table.column) 和 3-part (schema.table.column) 标识符。
*/
function buildColumnQualifierMap(model: SqlSemanticModel): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
const tokens = model.tokens;
for (let i = 0; i < tokens.length - 2; i++) {
if (tokenIsIdentifier(tokens[i]) && tokens[i + 1]?.text === "." && tokenIsIdentifier(tokens[i + 2])) {
const col = normalizeIdentifierToken(tokens[i + 2]);
const qual = extractQualifier(tokens, i);
let set = map.get(col);
if (!set) {
set = new Set();
map.set(col, set);
}
set.add(qual);
}
}
return map;
}
/**
* 检查取消限定后列名是否会产生歧义。
* 1. 扫描 SQL 中的 qualifier.column 模式,检查同一列名是否被 2+ 个不同限定符引用
* 2. 检查 rowSources 的 columns(CTE/子查询场景),列名是否出现在 2+ 个行源中
*/
function isColumnAmbiguous(model: SqlSemanticModel, columnName: string): boolean {
const target = columnName.toLowerCase();
// 1. 检查 rowSources 的 columns(CTE/子查询有列信息时)
let sourcesWithColumn = 0;
for (const rs of model.rowSources) {
if (rs.columns?.some((c) => c.toLowerCase() === target)) {
sourcesWithColumn++;
}
}
if (sourcesWithColumn >= 2) return true;
// 2. 扫描 token 中的 qualifier.column 模式,收集不同限定符
const qualifiers = collectColumnQualifiers(model, columnName);
// 2+ 个不同限定符引用同一列名 → 取消限定后会产生歧义
return qualifiers.size >= 2;
}
// ==================== Qualify / Unqualify Identifier ====================
/**
* 查找光标所在位置对应的完整标识符 token。
* trailingIdentifier 为自动补全设计,replacementRange 只到光标位置。
* 意图操作需要完整 token 的范围和文本。
*/
function findFullIdentifierToken(model: SqlSemanticModel, range: { start: number; end: number }): { prefix: string; replacementRange: { start: number; end: number } } | null {
for (const token of model.tokens) {
if (tokenIsIdentifier(token) && token.span.start === range.start && token.span.end > range.end) {
return {
prefix: unquoteSqlSemanticIdentifier(token),
replacementRange: { start: token.span.start, end: token.span.end },
};
}
}
return null;
}
function buildQualifierActions(model: SqlSemanticModel, cursorIntentIn: SqlSemanticCursorIntent, sql: string, databaseType?: DatabaseType): IntentionAction[] {
let cursorIntent = cursorIntentIn;
// 修复:当光标在标识符中间时,trailingIdentifier 返回的 replacementRange 只到光标位置。
// 扩展到完整 token 的范围和文本。
const fullToken = findFullIdentifierToken(model, cursorIntent.replacementRange);
if (fullToken) {
cursorIntent = {
...cursorIntent,
prefix: fullToken.prefix,
replacementRange: fullToken.replacementRange,
};
}
// 组合 qualifierParts + prefix 形成完整标识符分段
// qualifierParts 仅含限定符(表别名),prefix 含列名
let parts = cursorIntent.qualifierParts;
if (cursorIntent.prefix) {
parts = [...parts, cursorIntent.prefix];
}
// fallback:无 prefix 且无 qualifierParts 时从 SQL 文本提取
if (parts.length === 0 && sql && cursorIntent.replacementRange.start < cursorIntent.replacementRange.end) {
const text = sql.slice(cursorIntent.replacementRange.start, cursorIntent.replacementRange.end);
if (text.length > 0) {
parts = splitQualifiedIdentifier(text, databaseType);
}
}
if (parts.length === 0) return [];
const actions: IntentionAction[] = [];
const lastPart = parts[parts.length - 1] ?? "";
// P1-2: 跳过 projection 别名
const aliasNames = collectProjectionAliasNames(model);
if (aliasNames.has(lastPart.toLowerCase())) return [];
// 跳过表别名(su 是别名不是列名,不应提供限定)
const tableAliasNames = collectTableAliasNames(model);
if (tableAliasNames.has(lastPart.toLowerCase())) return [];
// 判断是否已限定:qualifierParts 非空,或 fallback 提取出多段
const isQualified = cursorIntent.qualifierParts.length > 0 || parts.length > 1;
if (isQualified) {
// 检查取消限定后是否会产生歧义(多表 JOIN 同名列场景)
if (isColumnAmbiguous(model, lastPart)) {
return [];
}
// 已限定 → 提供取消限定
// 扩展 span 覆盖完整限定标识符(如 `su`.`user_id` 而非仅 `user_id`)
let span = cursorIntent.replacementRange;
if (cursorIntent.qualifierParts.length > 0) {
// 反向迭代查找 replacementRange 之前的 token(避免 filter 创建新数组)
let ti = model.tokens.length - 1;
while (ti >= 0) {
const t = model.tokens[ti];
if (t.span.end <= cursorIntent.replacementRange.start && t.kind !== "comment" && t.kind !== "string") break;
ti--;
}
// 检查 replacementRange 前是否紧跟 "."
if (ti >= 0 && model.tokens[ti].text === ".") {
ti--;
// 向前查找限定符标识符
while (ti >= 0) {
const token = model.tokens[ti];
// 跳过 comment/string token
if (token.kind === "comment" || token.kind === "string") {
ti--;
continue;
}
if (token.kind === "word" || token.kind === "quoted_identifier") {
span = { start: token.span.start, end: cursorIntent.replacementRange.end };
ti--;
// 继续向前查找 schema.table.column 模式
if (ti >= 0 && model.tokens[ti].text === ".") {
ti--;
continue;
}
}
break;
}
}
}
const quote = supportsColumnNameQuoting(databaseType);
const quotedColumn = quote ? quoteTableIdentifier(databaseType, lastPart) : lastPart;
actions.push({
kind: "unqualify_identifier",
span,
replacement: quotedColumn,
});
return actions;
}
// 未限定 → 提供限定
const target = resolveNavigationTarget(model, cursorIntent);
if (target) {
const quote = supportsColumnNameQuoting(databaseType);
const qualifier = quote ? quoteTableIdentifier(databaseType, target.alias ?? target.name) : (target.alias ?? target.name);
const quotedColumn = quote ? quoteTableIdentifier(databaseType, lastPart) : lastPart;
actions.push({
kind: "qualify_identifier" as const,
span: cursorIntent.replacementRange,
replacement: `${qualifier}.${quotedColumn}`,
});
return actions;
}
// 多表场景:无法自动推断所属表时,提供所有候选表的限定选项
const scope = findScopeContainingCursor(model, cursorIntent.replacementRange.start);
if (scope) {
const tableSources = scope.rowSources.filter((rs) => rs.kind === "table" || rs.kind === "cte");
if (tableSources.length > 1) {
const quote = supportsColumnNameQuoting(databaseType);
const quotedColumn = quote ? quoteTableIdentifier(databaseType, lastPart) : lastPart;
for (const source of tableSources) {
const qualifierName = source.alias ?? source.name;
const qualifier = quote ? quoteTableIdentifier(databaseType, qualifierName) : qualifierName;
actions.push({
kind: "qualify_identifier" as const,
span: cursorIntent.replacementRange,
replacement: `${qualifier}.${quotedColumn}`,
label: `${qualifierName}.${lastPart}`,
});
}
}
}
return actions;
}
/**
* 将 qualified identifier 文本拆分为分段,正确处理各种引号包裹方式。
* 例如: `su`.`user_id` → ["su", "user_id"]
* su.user_id → ["su", "user_id"]
* `user_id` → ["user_id"]
*/
function splitQualifiedIdentifier(text: string, databaseType?: DatabaseType): string[] {
const parts: string[] = [];
let i = 0;
while (i < text.length) {
const part = readIdentifierPart(text, i, databaseType);
if (!part) {
i++;
continue;
}
parts.push(part.text);
i = part.end;
// 跳过 "." 分隔符
if (text[i] === ".") {
i++;
continue;
}
break;
}
return parts.filter(Boolean);
}
/**
* 查找包含光标位置的最内层 scope。
*
* 注意:clauseSpans 中每个子句的 end 只到下一个 token 的 start(非常窄),
* 无法覆盖整个子句范围。因此优先使用 scope 的 statement span 判断光标归属,
* 仅在 statement span 不可用时回退到 clauseSpans。
*/
function findScopeContainingCursor(model: SqlSemanticModel, cursor: number) {
let best: SqlSemanticScope | undefined;
for (const s of model.scopes) {
if (s.rowSources.length === 0) continue;
// 优先使用 statement span(覆盖整条语句,cursor 必在其中)
if (cursor >= s.span.start && cursor <= s.span.end) {
best = s; // 取最后一个匹配的(最内层)
continue;
}
// 回退:检查 clauseSpans
const spans = [s.clauseSpans.select, s.clauseSpans.from, s.clauseSpans.where, s.clauseSpans.having, s.clauseSpans.groupBy, s.clauseSpans.orderBy, s.clauseSpans.limit].filter(Boolean) as { start: number; end: number }[];
if (spans.some((sp) => cursor >= sp.start && cursor <= sp.end)) {
best = s;
}
}
return best;
}
function resolveNavigationTarget(model: SqlSemanticModel, cursorIntent: SqlSemanticCursorIntent, qualifierMap?: Map<string, Set<string>>): { name: string; alias?: string } | null {
// 1. 优先使用 semantic 的 targetSourceId
if (cursorIntent.targetSourceId) {
const source = model.rowSources.find((rs) => rs.id === cursorIntent.targetSourceId);
if (source) return { name: source.name, alias: source.alias };
}
// 2. 尝试使用 resolveSqlSemanticNavigationTarget(适用于已限定标识符)
try {
const target = resolveSqlSemanticNavigationTarget(model, cursorIntent.qualifierParts);
if (target) return { name: target.name, alias: target.alias };
} catch {
// ignore
}
// 3. 降级策略 — 查找包含光标的 scope
const scope = findScopeContainingCursor(model, cursorIntent.replacementRange.start);
if (scope) {
const tableSources = scope.rowSources.filter((rs) => rs.kind === "table" || rs.kind === "cte");
// 3a. 单表场景 → 直接返回
if (tableSources.length === 1) {
return { name: tableSources[0].name, alias: tableSources[0].alias };
}
// 3b. 多表场景 → 扫描 SQL 中同一列名的限定符引用
// 如果列名在 SQL 中被恰好 1 个限定符引用,则可推断其所属表
if (cursorIntent.prefix && tableSources.length > 1) {
const qualifiers = qualifierMap?.get(cursorIntent.prefix.toLowerCase()) ?? collectColumnQualifiers(model, cursorIntent.prefix);
if (qualifiers.size === 1) {
const qualifier = [...qualifiers][0];
// 支持 schema.table 形式的限定符匹配
const source = tableSources.find((rs) => {
const alias = rs.alias?.toLowerCase();
const name = rs.name.toLowerCase();
if (alias === qualifier || name === qualifier) return true;
// 3-part: qualifier = "schema.table", match against metadataTarget
const dotIdx = qualifier.indexOf(".");
if (dotIdx > 0) {
const qualTable = qualifier.slice(dotIdx + 1);
return alias === qualTable || name === qualTable;
}
return false;
});
if (source) return { name: source.name, alias: source.alias };
}
}
}
return null;
}
// ==================== Expand Wildcard Context ====================
export function prepareExpandWildcardContext(sql: string, cursor: number, databaseType?: DatabaseType, dialect?: "mysql" | "postgres" | "sqlserver"): { rowSources: SqlSemanticRowSource[]; starSpan: { start: number; end: number } } | null {
const model = buildSqlSemanticModel(sql, cursor, { databaseType, dialect });
if (model.cursorIntent.kind !== "star") return null;
const { cursorIntent } = model;
const scope = findSelectScopeForCursor(model, cursorIntent);
if (!scope) return null;
const rowSources = scope.rowSources.filter((rs) => rs.kind === "table" || rs.kind === "cte");
if (rowSources.length === 0) return null;
const starSpan = cursorIntent.replacementRange.start === cursorIntent.replacementRange.end ? { start: cursorIntent.replacementRange.start, end: cursorIntent.replacementRange.end + 1 } : cursorIntent.replacementRange;
return { rowSources, starSpan };
}