fix(editor): complete CTE names as table candidates

Closes #8381
This commit is contained in:
ordinary being
2026-10-08 18:49:20 +08:00
committed by GitHub
parent f37ce68a6b
commit 83e3513572
3 changed files with 91 additions and 1 deletions
@@ -1521,6 +1521,50 @@ describe("sqlCompletion scoped context classification", () => {
expect(items.filter((item) => item.type === "column").map((item) => item.label)).not.toContain("orders.name");
});
it("offers the CTE name as a table candidate above fuzzy catalog matches (#8381)", () => {
// The reported shape: no space between `as` and `(`, cursor still inside `tm`.
const sql = "with tmp as(select id, c1, c2 from test.t)\nselect * from tm";
// Same composition the editor provider uses: the semantic layer carries the
// resolved CTE reference that the legacy context alone does not keep.
const legacy = getSqlCompletionContext(sql, sql.length);
const semanticContext = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, sql.length), legacy);
const items = buildSqlCompletionItemsFromContext(semanticContext, {
tables: [{ name: "tb_market_price" }],
columnsByTable: new Map(),
});
const cteIndex = items.findIndex((item) => item.label === "tmp");
expect(cteIndex).toBeGreaterThanOrEqual(0);
expect(items[cteIndex]).toMatchObject({ type: "table" });
// The CTE is what the statement selects from, so it outranks a catalog table
// that only matched by initials.
expect(cteIndex).toBeLessThan(items.findIndex((item) => item.label === "tb_market_price"));
// The alias snippet for the relation being typed stays available, matching
// the document's existing alias-completion behaviour.
expect(items.some((item) => item.type === "snippet" && item.apply === "tm ")).toBe(true);
});
it("offers a star-projection CTE as a table candidate without a duplicate alias snippet", () => {
// `SELECT *` inside the CTE body leaves its extracted columns empty; the
// CTE itself must still complete as a table (#8381's most common shape).
const sql = "with tmp as(select * from test.t)\nselect * from tm";
const legacy = getSqlCompletionContext(sql, sql.length);
const semanticContext = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, sql.length), legacy);
const items = buildSqlCompletionItemsFromContext(semanticContext, { tables: [], columnsByTable: new Map() });
expect(items.some((item) => item.type === "table" && item.label === "tmp")).toBe(true);
expect(items.some((item) => item.type === "snippet" && item.apply === "tmp ")).toBe(false);
});
it("does not offer the half-typed relation as a table candidate", () => {
const sql = "with tmp as(select id from test.t)\nselect * from tm";
const legacy = getSqlCompletionContext(sql, sql.length);
const semanticContext = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, sql.length), legacy);
const items = buildSqlCompletionItemsFromContext(semanticContext, { tables: [], columnsByTable: new Map() });
expect(items.filter((item) => item.type === "table").map((item) => item.label)).not.toContain("tm");
});
it("keeps the CTE body's own tables while completing inside that body", () => {
const sql = "WITH cte AS (SELECT id, na FROM orders) SELECT * FROM cte";
const cursor = sql.indexOf(" FROM orders");
@@ -107,6 +107,7 @@ export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlComplet
database: source.metadataTarget?.database,
schema: source.qualifierParts[source.qualifierParts.length - 1],
schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,
kind: source.kind === "cte" ? ("cte" as const) : undefined,
alias: source.alias,
aliasSql: source.aliasSpan ? model.sql.slice(source.aliasSpan.start, source.aliasSpan.end) : source.alias,
columns: source.columns,
+46 -1
View File
@@ -1507,6 +1507,8 @@ function sqlCompletionApplyDialect(databaseType: DatabaseType | undefined, fallb
export interface SqlCompletionReferencedTable {
name: string;
/** Set when the reference is a WITH CTE rather than a physical table. */
kind?: "cte";
nameQuoted?: boolean;
database?: string;
schema?: string;
@@ -1830,6 +1832,7 @@ class SqlCompletionProvider {
const autoAliasTables = !!this.input.autoAliasTables && context.autoAliasTableCompletions && !context.tableCompletionTargetAliasUnsafe && supportsTableAliases(this.databaseType);
const schemaQualification = normalizeSqlTableCompletionSchemaQualification(this.input.tableCompletionSchemaQualification);
this.items.push(...buildForeignKeyRelatedTableItems(context, completionTables, this.input.foreignKeysByTable, this.dialect, autoAliasTables, this.databaseType, this.input.currentSchema, schemaQualification));
this.items.push(...buildReferencedTableItems(context, completionTables, this.dialect));
this.items.push(...buildTableItems(context, completionTables, this.dialect, autoAliasTables, context.referencedTables, this.databaseType, this.input.currentSchema, schemaQualification));
if (this.databaseType === "clickhouse") {
this.items.push(...buildClickHouseFunctionItems(context.prefix, context.openingParenAfterCursor, "table", this.input.functionCompletionIncludeParams));
@@ -2576,7 +2579,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq
let referencedTables = extractReferencedTables(maskResolvedCteBodies(fullStatement, cursorInStatement, cteDefs), options.databaseType);
for (const cte of cteDefs) {
if (!referencedTables.some((rt) => rt.name.toLowerCase() === cte.name.toLowerCase())) {
referencedTables.push({ name: cte.name, columns: cte.columns });
referencedTables.push({ name: cte.name, columns: cte.columns, kind: "cte" });
} else {
const existing = referencedTables.find((rt) => rt.name.toLowerCase() === cte.name.toLowerCase());
if (existing && !existing.columns) {
@@ -4207,6 +4210,43 @@ function resolveTableSchemaQualification(
return { ambiguousTableName, schemaQualification, defaultApplyName };
}
/**
* In-scope relations that are not in the catalog — `WITH` CTEs and resolved
* subquery aliases — never reached the table-name candidates, so a CTE could
* complete its columns but not its own name (#8381).
*
* The parser records columns for the references it resolved, which is what
* separates a real in-scope relation from the half-typed name the cursor
* currently sits in (that one has no columns and stays out of the list).
*
* In-scope relations outrank catalog matches: a CTE is what the statement
* actually selects from, while a catalog table is only a name that matched
* (the catalog path scores initials and subsequence matches up to 2400).
*/
function buildReferencedTableItems(context: SqlCompletionContext, completionTables: SqlCompletionTable[], dialect?: SqlCompletionApplyDialect): SqlCompletionItem[] {
const knownTables = new Set(completionTables.map((table) => normalizeIdentifierPart(table.name)));
const seen = new Set<string>();
const items: SqlCompletionItem[] = [];
for (const reference of context.referencedTables) {
// CTE definitions cannot be the half-finished identifier under the cursor,
// so they stay table candidates even when their projection is a bare `*`
// (column extraction drops `*`, leaving the columns list empty).
if ((!reference.columns || reference.columns.length === 0) && reference.kind !== "cte") continue;
if (!matchesPrefix(reference.name, context.prefix)) continue;
const key = normalizeIdentifierPart(reference.name);
if (!key || knownTables.has(key) || seen.has(key)) continue;
seen.add(key);
items.push({
label: reference.name,
type: "table",
detail: reference.schema ? `${reference.schema}.${reference.name}` : undefined,
apply: quoteCompletionApplyIdentifier(reference.name, dialect),
boost: computeBoost(reference.name, context.prefix) + 5_000,
});
}
return items;
}
function buildTableItems(
context: Pick<SqlCompletionContext, "prefix" | "qualifier" | "qualifierParts">,
tables: SqlCompletionTable[],
@@ -4840,6 +4880,11 @@ function buildAliasItems(context: SqlCompletionContext): SqlCompletionItem[] {
if (context.prefix && !matchesPrefix(ref.name, context.prefix)) continue;
const candidate = generateAlias(ref.name, seen);
if (!candidate || seen.has(candidate.toLowerCase())) continue;
// A relation that is also offered as a table candidate must not duplicate
// itself as a self-named alias snippet (CTEs and short table names);
// half-typed relations without columns keep theirs.
const offeredAsTable = (ref.columns?.length ?? 0) > 0 || ref.kind === "cte";
if (offeredAsTable && candidate.toLowerCase() === ref.name.toLowerCase()) continue;
seen.add(candidate.toLowerCase());
items.push({
label: candidate,