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 2538c11eb5..3e0bb0ff1a 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index 7c988cc51a..24846a5542 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -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, diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index f305fc10ac..9d502b7bf8 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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(); + 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, 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,