From 146dc672ddce6d7f4d71732999f7088115b23f7c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 27 Jul 2026 23:02:10 +0800 Subject: [PATCH] fix(sql): complete nested subquery sources --- .../__tests__/sql/semantic/completion.spec.ts | 24 ++++++ .../lib/__tests__/sql/semantic/model.spec.ts | 23 +++++ .../sql/sqlCompletion.context.spec.ts | 14 +++ apps/desktop/src/lib/sql/semantic/model.ts | 86 +++++++++++++++++-- apps/desktop/src/lib/sql/sqlCompletion.ts | 28 +++++- 5 files changed, 165 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index 0393cb2d18..92c9406dcd 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -35,6 +35,30 @@ function semanticCompletion(markedSql: string, input: Partial { + it("loads nested alias columns through the database-qualified metadata key", () => { + const { context, items } = semanticCompletion( + "SELECT * FROM aa.tb t WHERE EXISTS (SELECT 1 FROM aa.tb1 t1, aa.tb2 t2 WHERE t1.|)", + { + columnsByTable: new Map([["aa.tb1", [{ name: "id", table: "tb1", schema: "aa" }]]]), + }, + { databaseType: "mysql", dialect: "mysql" }, + ); + + expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "tb1", schema: "aa", alias: "t1" })])); + expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["id"]); + }); + + it("suggests nested tables after a qualified comma", () => { + const { context, items } = semanticCompletion( + "SELECT * FROM aa.tb t WHERE EXISTS (SELECT 1 FROM aa.tb1 t1, aa.|)", + { tables: [{ name: "tb2", schema: "aa", type: "table" }] }, + { databaseType: "mysql", dialect: "mysql" }, + ); + + expect(context.contextKind).toBe("table"); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "tb2", type: "table" })])); + }); + it.each([ ["ordinary lowercase", "SELECT * FROM orders_alias a WHERE a.|", "ORDERS_ALIAS", false], ["quoted lowercase", 'SELECT * FROM "orders_alias" a WHERE a.|', "orders_alias", true], diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts index def5467704..4f96e42bc2 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts @@ -80,6 +80,29 @@ describe("sqlSemanticModel baseline fixtures", () => { expect(model.cursorIntent).toEqual(expect.objectContaining({ kind: "alias_column", qualifierParts: ["b"] })); }); + it("keeps nested EXISTS sources and outer correlated sources visible", () => { + const { sql, cursor } = sqlFixtureCursor("SELECT * FROM aa.tb t WHERE EXISTS (SELECT 1 FROM aa.tb1 t1, aa.tb2 t2 WHERE t1.|)"); + const model = buildSqlSemanticModel(sql, cursor, { databaseType: "mysql", dialect: "mysql" }); + + expect(model.rowSources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "tb1", alias: "t1", metadataTarget: { schema: "aa", table: "tb1" } }), + expect.objectContaining({ name: "tb2", alias: "t2", metadataTarget: { schema: "aa", table: "tb2" } }), + expect.objectContaining({ name: "tb", alias: "t", metadataTarget: { schema: "aa", table: "tb" } }), + ]), + ); + expect(model.cursorIntent).toEqual(expect.objectContaining({ kind: "alias_column", qualifierParts: ["t1"] })); + expect(model.cursorIntent.targetSourceId).toBe(model.rowSources.find((source) => source.alias === "t1")?.id); + }); + + it("classifies an incomplete nested database qualifier as a table context", () => { + const { sql, cursor } = sqlFixtureCursor("SELECT * FROM aa.tb t WHERE EXISTS (SELECT 1 FROM aa.tb1 t1, aa.|)"); + const model = buildSqlSemanticModel(sql, cursor, { databaseType: "mysql", dialect: "mysql" }); + + expect(model.rowSources.some((source) => source.name === "aa")).toBe(false); + expect(model.cursorIntent).toEqual(expect.objectContaining({ kind: "table", qualifierParts: ["aa"], confidence: "high" })); + }); + it("does not treat an Oracle FOR UPDATE clause as a table alias", () => { const sql = "SELECT * FROM APP.USERS FOR UPDATE SKIP LOCKED"; const model = buildSqlSemanticModel(sql, sql.length, { databaseType: "oracle" }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index bc44ed799b..de096e8eea 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -100,6 +100,20 @@ describe("sqlCompletion quoted schema qualifiers", () => { }); describe("sqlCompletion table targets", () => { + it("suggests tables after a database qualifier in an EXISTS table list", () => { + const sql = "SELECT * FROM aa.tb t WHERE EXISTS (SELECT 1 FROM aa.tb1 t1, aa."; + const context = getSqlCompletionContext(sql, sql.length); + const items = buildSqlCompletionItems(sql, sql.length, { + databaseType: "mysql", + tables: [{ name: "tb2", schema: "aa", type: "table" }], + columnsByTable: new Map(), + }); + + expect(context.qualifier).toBe("aa"); + expect(context.suggestTables).toBe(true); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "tb2", type: "table" })])); + }); + it("does not suggest aliases while completing an empty FROM target before LIMIT", () => { const sql = "SELECT *\nFROM \nLIMIT 100;"; const cursor = "SELECT *\nFROM ".length; diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index ec697f465a..49aea166ea 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -34,6 +34,12 @@ interface ParseState { cteSources: SqlSemanticRowSource[]; } +interface QuerySourceRange { + depth: number; + startIndex: number; + endIndex: number; +} + interface TrailingIdentifier { prefix: string; replacementRange: SqlSemanticSpan; @@ -85,6 +91,7 @@ function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: numb break; } index += 2; + if (!tokenIsIdentifier(tokens[index]) && !(dialect.id === "sqlserver" && tokens[index]?.text === ".")) return null; if (dialect.id === "sqlserver") { while (tokens[index]?.text === ".") index += 1; } @@ -431,10 +438,8 @@ function parseRowSource(state: ParseState, target: number, introducer: string, s return parseTableFunctionSource(state, target, introducer, sourceIndex) ?? parseTableSource(state, target, introducer, sourceIndex); } -function parseRowSources(state: ParseState): SqlSemanticRowSource[] { - const sources: SqlSemanticRowSource[] = [...state.cteSources]; - const rootDepth = state.tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY); - const sourceDepth = Number.isFinite(rootDepth) ? rootDepth : 0; +function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIndexOffset = 0): SqlSemanticRowSource[] { + const sources: SqlSemanticRowSource[] = []; let inSelectFromClause = false; for (let index = 0; index < state.tokens.length; index += 1) { const item = state.tokens[index]; @@ -445,7 +450,7 @@ function parseRowSources(state: ParseState): SqlSemanticRowSource[] { else if (inSelectFromClause && FROM_CLAUSE_BOUNDARIES.has(item.normalized)) inSelectFromClause = false; } if (inSelectFromClause && item.text === ",") { - const parsed = parseRowSource(state, index + 1, "from", sources.length); + const parsed = parseRowSource(state, index + 1, "from", sourceIndexOffset + sources.length); if (parsed) { sources.push(parsed.source); index = parsed.nextIndex - 1; @@ -461,7 +466,7 @@ function parseRowSources(state: ParseState): SqlSemanticRowSource[] { while (JOIN_MODIFIERS.has(state.tokens[target]?.normalized ?? "")) target += 1; target = sqlServerMaintenanceTableTarget(state.tokens, target, normalized, state.dialect); for (;;) { - const parsed = parseRowSource(state, target, normalized, sources.length); + const parsed = parseRowSource(state, target, normalized, sourceIndexOffset + sources.length); if (!parsed) break; sources.push(parsed.source); index = parsed.nextIndex - 1; @@ -474,6 +479,50 @@ function parseRowSources(state: ParseState): SqlSemanticRowSource[] { return dedupeSources(sources); } +function querySourceRanges(tokens: readonly SqlSemanticToken[], cursor: number): QuerySourceRange[] { + const rootDepth = tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY); + const fallbackDepth = Number.isFinite(rootDepth) ? rootDepth : 0; + const cursorDepth = [...tokens].reverse().find((item) => item.span.start < cursor)?.depth ?? fallbackDepth; + const starts = new Map(); + + for (let index = 0; index < tokens.length; index += 1) { + const item = tokens[index]; + if (!item || item.span.start >= cursor) break; + if (item.kind === "word" && item.normalized === "select" && item.depth <= cursorDepth) { + starts.set(item.depth, index); + } + } + if (!starts.has(fallbackDepth)) starts.set(fallbackDepth, 0); + + return [...starts.entries()] + .sort(([left], [right]) => right - left) + .map(([depth, startIndex]) => { + let endIndex = tokens.length; + if (depth > fallbackDepth) { + for (let index = startIndex + 1; index < tokens.length; index += 1) { + if ((tokens[index]?.depth ?? depth) < depth) { + endIndex = index; + break; + } + } + } + return { depth, startIndex, endIndex }; + }); +} + +function parseRowSources(state: ParseState, cursor: number): SqlSemanticRowSource[] { + const ranges = querySourceRanges(state.tokens, cursor); + const sources: SqlSemanticRowSource[] = []; + for (const range of ranges) { + const scopedState = { + ...state, + tokens: state.tokens.slice(range.startIndex, range.endIndex), + }; + sources.push(...parseRowSourcesAtDepth(scopedState, range.depth, sources.length)); + } + return dedupeSources([...sources, ...state.cteSources]); +} + function dedupeSources(sources: SqlSemanticRowSource[]): SqlSemanticRowSource[] { const seen = new Set(); const result: SqlSemanticRowSource[] = []; @@ -557,9 +606,26 @@ function wordBeforePosition(tokens: readonly SqlSemanticToken[], position: numbe return before[before.length - 1]?.normalized ?? ""; } +function wordBeforeTrailingIdentifier(tokens: readonly SqlSemanticToken[], cursor: number, trailing: TrailingIdentifier): string { + const before = tokens.filter((item) => item.span.end <= cursor && item.kind !== "comment"); + let index = before.length - 1; + let identifiersToSkip = trailing.qualifierParts.length + (trailing.prefix ? 1 : 0); + while (index >= 0 && identifiersToSkip > 0) { + if (before[index]?.text === ".") { + index -= 1; + continue; + } + if (!tokenIsIdentifier(before[index])) break; + identifiersToSkip -= 1; + index -= 1; + } + return before[index]?.kind === "word" ? before[index].normalized : ""; +} + function isTableListContinuation(tokens: readonly SqlSemanticToken[], position: number): boolean { const before = tokens.filter((item) => item.span.end <= position && item.kind !== "comment"); - const commaIndex = before.length - 1; + let commaIndex = before.length - 1; + while (commaIndex >= 0 && (tokenIsIdentifier(before[commaIndex]) || before[commaIndex]?.text === ".")) commaIndex -= 1; const comma = before[commaIndex]; if (comma?.text !== ",") return false; const depth = comma.depth; @@ -611,6 +677,7 @@ function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number, const before = tokens.filter((item) => item.span.end <= cursor); const last = before[before.length - 1]; const wordBeforeReplacement = wordBeforePosition(tokens, trailing.replacementRange.start); + const wordBeforeTrailing = wordBeforeTrailingIdentifier(tokens, cursor, trailing); const tableListContinuation = isTableListContinuation(tokens, trailing.replacementRange.start); if (last?.text === "*" || trailing.prefix === "*") { @@ -645,6 +712,7 @@ function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number, previous === "join" || TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || + TABLE_INTRODUCERS.has(wordBeforeTrailing) || (!!targetSource && !targetSource.alias && trailing.replacementRange.start <= targetSource.sourceSpan.end + 1 && TABLE_INTRODUCERS.has(wordBeforePosition(tokens, targetSource.sourceSpan.start)))) ) { const role = dialect.qualifierRole(trailing.qualifierParts, "table"); @@ -655,7 +723,7 @@ function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number, return { kind: "alias_column", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, targetSourceId: targetSource.id, expectedObjectKinds: ["column"], confidence: "high" }; } - if (TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || previous === "from" || previous === "join" || wordBeforeReplacement === "from" || wordBeforeReplacement === "join" || tableListContinuation) { + if (TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || TABLE_INTRODUCERS.has(wordBeforeTrailing) || previous === "from" || previous === "join" || wordBeforeReplacement === "from" || wordBeforeReplacement === "join" || tableListContinuation) { return { kind: previous === "join" ? "table" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "high" }; } @@ -708,7 +776,7 @@ export function buildSqlSemanticModel(sql: string, cursor: number, options: SqlS const suppressed = isSuppressedSqlSemanticContext(allTokens, safeCursor); const parseState: ParseState = { dialect, tokens, statement, cteSources: [] }; parseState.cteSources = parseCteSources(parseState); - const rowSources = parseRowSources(parseState); + const rowSources = parseRowSources(parseState, safeCursor); const projections = parseSelectProjections(tokens, dialect); const cursorIntent = buildCursorIntent(tokens, safeCursor, rowSources, dialect, suppressed, kind); const scopes = [buildScope(statement, rowSources, projections, tokens)]; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 413302f091..96f9e47286 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -3501,7 +3501,7 @@ function isFollowedByJoin(beforeToken: string): boolean { function isInTableListContext(beforeToken: string): boolean { if (isInOrderOrGroupByContext(beforeToken)) return false; - const cleaned = stripSqlLiterals(beforeToken).trimEnd(); + const cleaned = activeQueryBlockSql(stripSqlLiterals(beforeToken).trimEnd()); if (!/,\s*$/.test(cleaned)) return false; // Only commas in the active top-level table segment should continue table completion. @@ -3523,6 +3523,32 @@ function isInTableListContext(beforeToken: string): boolean { return lastBoundary < lastTableIntro; } +function activeQueryBlockSql(sql: string): string { + let depth = 0; + const selectIndexes = new Map(); + const lower = sql.toLowerCase(); + + for (let index = 0; index < lower.length; index += 1) { + const ch = lower[index] ?? ""; + if (ch === "(") { + depth += 1; + continue; + } + if (ch === ")") { + depth = Math.max(0, depth - 1); + continue; + } + if (lower.startsWith("select", index) && !isIdentifierPart(lower[index - 1]) && !isIdentifierPart(lower[index + 6])) { + selectIndexes.set(depth, index); + index += 5; + } + } + + const activeDepth = Math.max(...[...selectIndexes.keys()].filter((selectDepth) => selectDepth <= depth), -1); + const activeSelectIndex = activeDepth >= 0 ? selectIndexes.get(activeDepth) : undefined; + return activeSelectIndex == null ? sql : sql.slice(activeSelectIndex); +} + function collectCompletionColumns(columnsByTable: Map): Array { const allColumns: Array = []; for (const [key, cols] of columnsByTable.entries()) {