mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:14:01 +00:00
fix(sql): complete nested subquery sources
This commit is contained in:
@@ -35,6 +35,30 @@ function semanticCompletion(markedSql: string, input: Partial<SqlCompletionProvi
|
||||
}
|
||||
|
||||
describe("semantic SQL completion candidates", () => {
|
||||
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],
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number, number>();
|
||||
|
||||
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<string>();
|
||||
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)];
|
||||
|
||||
@@ -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<number, number>();
|
||||
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<string, SqlCompletionColumn[]>): Array<SqlCompletionColumn & { key: string }> {
|
||||
const allColumns: Array<SqlCompletionColumn & { key: string }> = [];
|
||||
for (const [key, cols] of columnsByTable.entries()) {
|
||||
|
||||
Reference in New Issue
Block a user