mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user