feat(editor): semantic column navigation for CTE columns

This commit is contained in:
大峰 authored and GitHub committed 2026-09-18 15:06:37 +08:00
1 parent 7c51454bfd
commit b44e1080a8
11 files changed
+1456 -6

No files matched your search

@@ -87,6 +87,8 @@ import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shoul
import { driverProfileHasCompletionCandidates } from "@/lib/database/driverProfileExtensions";
import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSources } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { findCteColumnResolution, findCteReferenceAt, resolveCteColumnOrigins, type CteColumnOrigin } from "@/lib/sql/semantic/cteNavigation";
import type { SqlSemanticModel, SqlSemanticRowSource } from "@/lib/sql/semantic/types";
import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references";
import {
buildElasticsearchCompletionItemsFromContext,
@@ -920,6 +922,43 @@ function getEditorSqlCompletionContext(sql: string, position: number, editorStat
return context;
}
// CTE definition metadata is cursor-independent, but hover/ctrl-click models still carry the
// position; memoize per (Text node, position, dialect, state) so hover and click at the same
// position share one parse. Any edit swaps the Text node, invalidating the cache for free.
let editorSemanticModelCache: {
doc: Text;
editorState: EditorState | undefined;
position: number;
databaseType: DatabaseType | undefined;
dialect: string | undefined;
model: SqlSemanticModel;
} | null = null;
function getEditorSemanticModel(sql: string, position: number, editorState = view.value?.state): SqlSemanticModel | null {
if (!SEMANTIC_SQL_COMPLETION_ENABLED) return null;
const dialect = sqlBehaviorDialect();
const doc = editorState?.doc;
if (doc && editorSemanticModelCache?.doc === doc && editorSemanticModelCache.editorState === editorState && editorSemanticModelCache.position === position && editorSemanticModelCache.databaseType === props.databaseType && editorSemanticModelCache.dialect === dialect) {
return editorSemanticModelCache.model;
}
const model = buildSqlSemanticModel(sql, position, {
databaseType: props.databaseType,
dialect,
editorState,
});
if (doc) {
editorSemanticModelCache = {
doc,
editorState,
position,
databaseType: props.databaseType,
dialect,
model,
};
}
return model;
}
function usesOracleSessionCompletionColumns(schema?: string | null): boolean {
return shouldUseOracleSessionCompletionColumns({
databaseType: props.databaseType,
@@ -1301,6 +1340,23 @@ function focusErrorPosition(offset: number) {
currentView.focus();
}
/** CTE Ctrl/Cmd+click: select a small target (name/column/star token) while highlighting the whole CTE definition. */
function jumpToCteRange(target: { from: number; to: number }, highlight: { from: number; to: number }) {
const currentView = view.value;
if (!currentView || !editorViewModule || !setResultSourceRangeEffect) return;
const docLength = currentView.state.doc.length;
const targetFrom = Math.max(0, Math.min(target.from, docLength));
const targetTo = Math.max(targetFrom, Math.min(target.to, docLength));
const highlightFrom = Math.max(0, Math.min(highlight.from, docLength));
const highlightTo = Math.max(highlightFrom, Math.min(highlight.to, docLength));
if (targetFrom >= targetTo) return;
currentView.dispatch({
selection: { anchor: targetFrom, head: targetTo },
effects: [setResultSourceRangeEffect.of({ from: highlightFrom, to: highlightTo }), editorViewModule.EditorView.scrollIntoView(targetFrom, { y: "center" })],
});
currentView.focus();
}
function onPickerActiveIndexChange(index: number) {
pickerActiveIndex.value = index;
const candidate = pickerCandidates.value[index];
@@ -3264,6 +3320,45 @@ function createHoverDom(title: string, detail: string, sqlContent?: string, rows
};
}
/** Maps a semantic row source (from a CTE body) to the shape ensureColumnsForTable accepts. */
function referencedTableLikeFromSemanticSource(source: SqlSemanticRowSource) {
const identifierParts = source.qualifiedName?.parts ?? [];
return {
name: source.name,
nameQuoted: !!identifierParts[identifierParts.length - 1]?.quote,
database: source.metadataTarget?.database,
schema: source.qualifierParts[source.qualifierParts.length - 1],
schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,
};
}
/**
* Hovers a column that the outer query receives from a CTE: trace the CTE lineage to its
* physical table column and reuse the shared column cache (and comment/type metadata).
* Returns null for expressions, untraceable bodies or metadata misses so callers fall back.
*/
async function resolveCteColumnHoverColumn(semanticModel: SqlSemanticModel, columnName: string, qualifier?: string): Promise<SqlCompletionColumn | null> {
try {
const hit = findCteColumnResolution(semanticModel, columnName, qualifier);
if (!hit) return null;
const origins: CteColumnOrigin[] = resolveCteColumnOrigins(semanticModel, hit, columnName);
if (origins.length === 0) return null;
// Fetch all candidate origins concurrently (same pattern as expandSelectStar);
// origin order still decides which match wins.
const matched = await Promise.all(
origins.map(async (origin) => {
const reference = referencedTableLikeFromSemanticSource(origin.source);
await ensureColumnsForTable(reference, reference);
const columns = cachedColumnsByTable.get(completionCacheKey(reference));
return columns?.find((candidate) => candidate.name.toLowerCase() === origin.column.toLowerCase()) ?? null;
}),
);
return matched.find((column) => column != null) ?? null;
} catch {
return null;
}
}
async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) {
if (!props.connectionId || props.database == null || contextMenuOpen.value) return null;
@@ -3278,7 +3373,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
let semanticModel: ReturnType<typeof buildSqlSemanticModel> | null = null;
if (SEMANTIC_SQL_COMPLETION_ENABLED) {
try {
semanticModel = buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions());
semanticModel = getEditorSemanticModel(sql, pos, currentView.state);
} catch (error) {
semanticModel = null;
console.warn(`[DBX] Failed to build semantic model for hover tooltip:`, error);
@@ -3289,6 +3384,19 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name;
const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier;
// CTE-derived columns: trace to the physical column and show its type/source/comment before
// the generic table/column fallback (which cannot see inside a CTE body).
if (semanticModel) {
const cteColumn = await resolveCteColumnHoverColumn(semanticModel, name, qualifier);
if (cteColumn) {
return {
pos: range.from,
end: range.to,
create: () => createHoverDom(cteColumn.name, cteColumn.dataType || "column", undefined, [cteColumn.schema ? `${cteColumn.schema}.${cteColumn.table}` : cteColumn.table, ...(cteColumn.comment?.trim() ? [cteColumn.comment.trim()] : [])]),
};
}
}
const lookup = resolveHoverTableLookupTarget({
database: props.database,
schema: props.schema,
@@ -7052,6 +7160,30 @@ onMounted(async () => {
target,
);
// 0. CTE (WITH ... AS) in-editor navigation — jump within the editor instead of
// opening metadata for a name that does not exist as a physical table.
if (SEMANTIC_SQL_COMPLETION_ENABLED) {
try {
const cteModel = getEditorSemanticModel(doc, pos, currentView.state);
if (cteModel) {
const referenceHit = findCteReferenceAt(cteModel, pos);
if (referenceHit?.definition.nameSpan) {
jumpToCteRange({ from: referenceHit.definition.nameSpan.start, to: referenceHit.definition.nameSpan.end }, { from: referenceHit.definition.sourceSpan.start, to: referenceHit.definition.sourceSpan.end });
return;
}
const clickQualifier = identity.parts.length >= 2 ? identity.parts[identity.parts.length - 2]?.value : undefined;
const columnHit = findCteColumnResolution(cteModel, identity.name, clickQualifier);
const columnTargetSpan = columnHit?.output?.jumpSpan ?? columnHit?.stars?.[0]?.starSpan;
if (columnHit && columnTargetSpan) {
jumpToCteRange({ from: columnTargetSpan.start, to: columnTargetSpan.end }, { from: columnHit.definition.sourceSpan.start, to: columnHit.definition.sourceSpan.end });
return;
}
}
} catch (error) {
console.warn("[DBX] CTE ctrl+click resolution failed:", error);
}
}
// 1. Local table lookup with the resolved scope (do not trust stale editor cache alone —
// completion/hover may have filled cachedTables with the current schema only).
const localScopedTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema, props.catalog);
@@ -0,0 +1,179 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
/**
* QueryEditor.vue 的 CTE 接线(hover 溯源、Ctrl+点击跳转、语义模型缓存)都写在
* `<script setup>` 内部,无法直接 import,因此沿用仓库既有做法
* (queryEditorExtendSelection.spec.ts 对 queryEditorSource 的结构断言)。
*
* 这里锁定回归成本最高的两类契约:分支先后顺序、关键调用参数。算法本身由
* cteNavigation.spec.ts / model.spec.ts / sqlNavigation.spec.ts 覆盖。
*/
const source = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
/** 截取 `<script setup>` 顶层函数体,避免跨函数误匹配。 */
function functionBody(name: string): string {
const start = source.indexOf(`function ${name}(`);
expect(start, `QueryEditor.vue 缺少函数 ${name}`).toBeGreaterThanOrEqual(0);
const end = source.indexOf("\n}", start);
expect(end, `${name} 函数体未闭合`).toBeGreaterThan(start);
return source.slice(start, end);
}
function positionOf(fragment: string): number {
const index = source.indexOf(fragment);
expect(index, `QueryEditor.vue 缺少片段: ${fragment}`).toBeGreaterThanOrEqual(0);
return index;
}
/**
* 结构片段的归一化:去掉全部空白与"闭合前的尾逗号"。
* oxfmt(lint-staged 钩子会执行)会在单行与多行之间重排数组/对象字面量,
* 归一化后断言只对代码结构敏感,不会因为纯格式变动而误报。
*/
function normalizeCode(text: string): string {
return text.replace(/\s+/g, "").replace(/,(?=[)\]}])/g, "");
}
const normalizedSource = normalizeCode(source);
/** 期望片段照常写成可读形式,比较时两侧一起归一化。 */
function expectSourceToContain(fragment: string): void {
expect(normalizedSource).toContain(normalizeCode(fragment));
}
describe("QueryEditor hover 的语义模型缓存接线", () => {
it("hover 复用 getEditorSemanticModel,而不是每次重新解析", () => {
expect(source).toContain("semanticModel = getEditorSemanticModel(sql, pos, currentView.state);");
// 换回逐次解析会让 hover 在每次移动时重复建模型(原实现即为此形态)。
expect(source).not.toContain("buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions())");
});
it("缓存键覆盖文档/编辑器状态/位置/数据库类型/方言,且只在拿到 doc 时写缓存", () => {
const body = functionBody("getEditorSemanticModel");
expect(body).toContain("if (!SEMANTIC_SQL_COMPLETION_ENABLED) return null;");
for (const key of ["editorSemanticModelCache?.doc === doc", "editorSemanticModelCache.editorState === editorState", "editorSemanticModelCache.position === position", "editorSemanticModelCache.databaseType === props.databaseType", "editorSemanticModelCache.dialect === dialect"]) {
expect(body, `缓存键缺少 ${key}`).toContain(key);
}
// 没有 doc(手动构造的 state)时不能留下无法复用的缓存项。
expect(body).toMatch(/if \(doc\) \{\s*editorSemanticModelCache = \{/);
});
});
describe("QueryEditor hover 的 CTE 溯源接线", () => {
it("CTE 分支排在通用表/列回退之前", () => {
const cteBranch = positionOf("const cteColumn = await resolveCteColumnHoverColumn(semanticModel, name, qualifier);");
const genericFallback = positionOf("const hoverTarget = completionMetadataTarget({");
expect(cteBranch).toBeLessThan(genericFallback);
});
it("命中物理列后按 类型/来源/注释 生成悬浮内容,未命中则回退", () => {
expectSourceToContain('createHoverDom(cteColumn.name, cteColumn.dataType || "column", undefined, [cteColumn.schema ? `${cteColumn.schema}.${cteColumn.table}` : cteColumn.table, ...(cteColumn.comment?.trim() ? [cteColumn.comment.trim()] : [])])');
// 只有拿到列才提前返回,否则继续走原有表/列逻辑。
expect(source).toContain("if (cteColumn) {");
});
});
describe("resolveCteColumnHoverColumn 的溯源接线", () => {
const body = functionBody("resolveCteColumnHoverColumn");
it("未命中或不可溯源时返回 null,交由上层回退", () => {
expect(body).toContain("const hit = findCteColumnResolution(semanticModel, columnName, qualifier);");
expect(body).toContain("if (!hit) return null;");
expect(body).toContain("const origins: CteColumnOrigin[] = resolveCteColumnOrigins(semanticModel, hit, columnName);");
expect(body).toContain("if (origins.length === 0) return null;");
// 元数据抓取异常同样只降级为回退,不能把 hover 打崩。
expect(body).toMatch(/catch \{\s*return null;\s*\}/);
});
it("复用共享列缓存并做大小写不敏感匹配,取第一个命中", () => {
expect(body).toContain("const reference = referencedTableLikeFromSemanticSource(origin.source);");
expect(body).toContain("await ensureColumnsForTable(reference, reference);");
expect(body).toContain("const columns = cachedColumnsByTable.get(completionCacheKey(reference));");
expect(body).toContain("candidate.name.toLowerCase() === origin.column.toLowerCase()");
expect(body).toContain("return matched.find((column) => column != null) ?? null;");
});
it("多星号产生的多个来源并发取元数据,顺序即优先级,第一个命中者胜出", () => {
const body = functionBody("resolveCteColumnHoverColumn");
// 顺序即优先级:谁先取到真实列元数据就用谁(歧义 SQL 的裁决点就在这里)。
expect(body).toContain("origins.map(async (origin) => {");
expect(body).toContain("?? null;");
expect(body.indexOf("origins.map(async (origin) => {")).toBeLessThan(body.indexOf("return matched.find((column) => column != null) ?? null;"));
// 第二个来源直接复用第一个来源已建好的缓存,不会重复发起元数据查询。
expect(body).toContain("cachedColumnsByTable.get(completionCacheKey(reference))");
});
it("把语义来源映射成列缓存所需的表标识(含引号与 schema)", () => {
const mapBody = functionBody("referencedTableLikeFromSemanticSource");
expect(mapBody).toContain("const identifierParts = source.qualifiedName?.parts ?? [];");
expect(mapBody).toContain("name: source.name,");
expect(mapBody).toContain("nameQuoted: !!identifierParts[identifierParts.length - 1]?.quote,");
expect(mapBody).toContain("database: source.metadataTarget?.database,");
expect(mapBody).toContain("schema: source.qualifierParts[source.qualifierParts.length - 1],");
expect(mapBody).toContain("schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined,");
});
});
describe("QueryEditor Ctrl+点击的 CTE 跳转接线", () => {
it("CTE 分支排在本地表缓存之前", () => {
const cteBranch = positionOf("const cteModel = getEditorSemanticModel(doc, pos, currentView.state);");
const localTableCache = positionOf("// 1. Local table lookup with the resolved scope");
expect(source).toContain("// 0. CTE (WITH ... AS) in-editor navigation");
expect(cteBranch).toBeLessThan(localTableCache);
});
it("CTE 引用名命中时跳到定义名 token,并高亮整个 CTE 定义", () => {
expect(source).toContain("const referenceHit = findCteReferenceAt(cteModel, pos);");
expect(source).toContain("if (referenceHit?.definition.nameSpan) {");
expectSourceToContain("{ from: referenceHit.definition.nameSpan.start, to: referenceHit.definition.nameSpan.end }");
expectSourceToContain("{ from: referenceHit.definition.sourceSpan.start, to: referenceHit.definition.sourceSpan.end }");
});
it("CTE 列命中时跳到 output 的 jumpSpan 或 body 星号,限定符取倒数列段", () => {
expect(source).toContain("const clickQualifier = identity.parts.length >= 2 ? identity.parts[identity.parts.length - 2]?.value : undefined;");
expect(source).toContain("const columnHit = findCteColumnResolution(cteModel, identity.name, clickQualifier);");
expect(source).toContain("const columnTargetSpan = columnHit?.output?.jumpSpan ?? columnHit?.stars?.[0]?.starSpan;");
expect(source).toContain("if (columnHit && columnTargetSpan) {");
expectSourceToContain("{ from: columnTargetSpan.start, to: columnTargetSpan.end }");
});
it("解析失败只告警,不阻断后续表/列跳转", () => {
expect(source).toContain('console.warn("[DBX] CTE ctrl+click resolution failed:", error);');
const cteBlock = source.slice(positionOf("const cteModel = getEditorSemanticModel(doc, pos, currentView.state);"), positionOf("// 1. Local table lookup with the resolved scope"));
// 两个成功分支各自提前 return;catch 分支只能 warn,否则会吞掉原有的表/列跳转。
expect(cteBlock.match(/return;/g) ?? []).toHaveLength(2);
expect(cteBlock).not.toMatch(/catch \(error\) \{[\s\S]*?return/);
});
});
describe("jumpToCteRange 的跳转范围处理", () => {
const body = functionBody("jumpToCteRange");
it("缺少视图或 CodeMirror 模块时直接返回", () => {
expect(body).toContain("if (!currentView || !editorViewModule || !setResultSourceRangeEffect) return;");
});
it("把目标与高亮范围都收敛到文档长度内", () => {
expect(body).toContain("const docLength = currentView.state.doc.length;");
expect(body).toContain("const targetFrom = Math.max(0, Math.min(target.from, docLength));");
expect(body).toContain("const targetTo = Math.max(targetFrom, Math.min(target.to, docLength));");
expect(body).toContain("const highlightFrom = Math.max(0, Math.min(highlight.from, docLength));");
expect(body).toContain("const highlightTo = Math.max(highlightFrom, Math.min(highlight.to, docLength));");
});
it("空目标不动光标,命中时选中目标 token 并高亮整个定义块", () => {
expect(body).toContain("if (targetFrom >= targetTo) return;");
expect(normalizeCode(body)).toContain(normalizeCode("selection: { anchor: targetFrom, head: targetTo }"));
expectSourceToContain("setResultSourceRangeEffect.of({ from: highlightFrom, to: highlightTo })");
expectSourceToContain('editorViewModule.EditorView.scrollIntoView(targetFrom, { y: "center" })');
expect(body).toContain("currentView.focus();");
});
});
@@ -0,0 +1,414 @@
import { describe, expect, it } from "vitest";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { cteDefinitionSources, findCteColumnResolution, findCteReferenceAt, resolveCteColumnOrigins } from "@/lib/sql/semantic/cteNavigation";
import type { SqlSemanticModel } from "@/lib/sql/semantic/types";
function modelAt(sql: string, marker: string, occurrence = 0): { model: SqlSemanticModel; pos: number } {
let pos = sql.indexOf(marker);
for (let index = 0; index < occurrence; index += 1) pos = sql.indexOf(marker, pos + 1);
expect(pos).toBeGreaterThanOrEqual(0);
return { model: buildSqlSemanticModel(sql, pos), pos };
}
describe("CTE definition enrichment", () => {
it("records name/body spans, outputs, stars and body sources", () => {
const sql = "WITH w AS (SELECT id, t.name FROM orders t) SELECT id FROM w";
const { model } = modelAt(sql, "id", 1); // outer id
const definition = cteDefinitionSources(model).find((source) => source.name === "w");
expect(definition).toBeDefined();
expect(sql.slice(definition!.nameSpan!.start, definition!.nameSpan!.end)).toBe("w");
expect(sql[definition!.bodySpan!.start]).toBe("(");
expect(sql[definition!.bodySpan!.end - 1]).toBe(")");
const bodySources = definition!.bodySources ?? [];
expect(bodySources.some((source) => source.name === "orders")).toBe(true);
// Body physical tables stay nested on the definition and never leak to outer row sources.
expect(model.rowSources.some((source) => source.name === "orders")).toBe(false);
});
it("traces bare and qualified projections and marks jump spans on the column segment", () => {
const sql = "WITH w AS (SELECT id, t.name FROM orders t) SELECT id FROM w";
const model = buildSqlSemanticModel(sql, sql.length);
const definition = cteDefinitionSources(model)[0]!;
const outputs = definition.cteOutputs ?? [];
expect(outputs.map((column) => column.name)).toEqual(["id", "name"]);
expect(sql.slice(outputs[0]!.jumpSpan.start, outputs[0]!.jumpSpan.end)).toBe("id");
expect(sql.slice(outputs[1]!.jumpSpan.start, outputs[1]!.jumpSpan.end)).toBe("name");
expect(outputs[0]!.origin).toEqual({ qualifierParts: [], column: "id" });
expect(outputs[1]!.origin).toEqual({ qualifierParts: ["t"], column: "name" });
});
it("records bare and qualified stars", () => {
const sql = "WITH w AS (SELECT t.* FROM orders t) SELECT * FROM w";
const model = buildSqlSemanticModel(sql, sql.length);
const stars = cteDefinitionSources(model)[0]!.cteStars ?? [];
expect(stars).toHaveLength(1);
expect(stars[0]!.qualifierParts).toEqual(["t"]);
expect(sql.slice(stars[0]!.starSpan.start, stars[0]!.starSpan.end)).toBe("*");
// The legacy flat columns list keeps its pre-existing shape (q.* qualifier leaks as a name);
// accurate star metadata lives on cteStars.
expect(cteDefinitionSources(model)[0]!.columns).toEqual(["t"]);
});
it("inherits positional body origins through an explicit column list", () => {
const sql = "WITH w(a, b) AS (SELECT id, name FROM orders) SELECT a FROM w";
const model = buildSqlSemanticModel(sql, sql.length);
const definition = cteDefinitionSources(model)[0]!;
const outputs = definition.cteOutputs ?? [];
expect(outputs.map((column) => column.name)).toEqual(["a", "b"]);
expect(sql.slice(outputs[0]!.jumpSpan.start, outputs[0]!.jumpSpan.end)).toBe("a");
expect(outputs[0]!.origin).toEqual({ qualifierParts: [], column: "id" });
expect(definition.columns).toEqual(["a", "b"]);
});
it("does not attach origins to expression/alias projections", () => {
const sql = "WITH w AS (SELECT n + 1 AS doubled, flag FROM orders) SELECT doubled FROM w";
const model = buildSqlSemanticModel(sql, sql.length);
const outputs = cteDefinitionSources(model)[0]!.cteOutputs ?? [];
expect(outputs[0]!.name).toBe("doubled");
expect(outputs[0]!.origin).toBeUndefined();
expect(sql.slice(outputs[0]!.jumpSpan.start, outputs[0]!.jumpSpan.end)).toBe("doubled");
expect(outputs[1]!.origin).toEqual({ qualifierParts: [], column: "flag" });
});
});
describe("findCteReferenceAt", () => {
it("hits the outer reference name and its declared alias but never the definition name", () => {
const sql = "WITH recent AS (SELECT id FROM orders) SELECT x FROM recent r WHERE r.x = 1";
const outerNamePos = sql.indexOf("recent r");
const aliasDeclarationPos = sql.indexOf(" r WHERE") + 1;
const definitionNamePos = sql.indexOf("recent");
const hitByName = findCteReferenceAt(buildSqlSemanticModel(sql, outerNamePos + 2), outerNamePos + 2);
expect(hitByName?.definition.name).toBe("recent");
const hitByAlias = findCteReferenceAt(buildSqlSemanticModel(sql, aliasDeclarationPos + 1), aliasDeclarationPos);
expect(hitByAlias?.reference.alias).toBe("r");
expect(hitByAlias?.definition.name).toBe("recent");
expect(findCteReferenceAt(buildSqlSemanticModel(sql, definitionNamePos + 2), definitionNamePos + 2)).toBeNull();
});
});
describe("findCteColumnResolution", () => {
it("resolves a star-derived column to the body star span", () => {
const sql = "WITH w AS (SELECT * FROM orders) SELECT x FROM w WHERE x = 1";
const pos = sql.indexOf("x", sql.indexOf("SELECT", sql.indexOf(")")));
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "x");
expect(hit?.definition.name).toBe("w");
expect(hit?.output).toBeUndefined();
expect(hit?.stars).toHaveLength(1);
expect(sql.slice(hit!.stars![0]!.starSpan.start, hit!.stars![0]!.starSpan.end)).toBe("*");
});
it("resolves a named output column", () => {
const sql = "WITH w AS (SELECT id FROM orders) SELECT id FROM w";
const outerId = sql.lastIndexOf("id");
const model = buildSqlSemanticModel(sql, outerId + 1);
const hit = findCteColumnResolution(model, "id");
expect(hit?.output?.name).toBe("id");
expect(sql.slice(hit!.output!.jumpSpan.start, hit!.output!.jumpSpan.end)).toBe("id");
});
it("resolves qualified columns through the cursor-intent target source", () => {
const sql = "WITH w AS (SELECT * FROM orders) SELECT w.id FROM w";
const idPos = sql.indexOf("id", sql.indexOf("w.id"));
const model = buildSqlSemanticModel(sql, idPos + 1);
const hit = findCteColumnResolution(model, "id", "w");
expect(hit?.definition.name).toBe("w");
expect(hit?.stars).toHaveLength(1);
});
it("returns null when several visible CTEs expose the same bare column", () => {
const sql = "WITH a AS (SELECT id FROM orders), b AS (SELECT id FROM invoices) SELECT id FROM a, b";
const outerId = sql.lastIndexOf("id");
const model = buildSqlSemanticModel(sql, outerId + 1);
expect(findCteColumnResolution(model, "id")).toBeNull();
});
it("does not let a later CTE claim a bare column of an earlier CTE body", () => {
const sql = "WITH w AS (SELECT id FROM orders), later AS (SELECT id FROM invoices) SELECT id FROM later";
const bodyId = sql.indexOf("SELECT id FROM orders") + "SELECT ".length;
const model = buildSqlSemanticModel(sql, bodyId + 1);
// Before the visibility guard this resolved to `later` and ctrl+click jumped into that body.
expect(findCteColumnResolution(model, "id")).toBeNull();
// The same column still resolves from the outer block, where `later` is visible.
const outer = buildSqlSemanticModel(sql, sql.lastIndexOf("id") + 1);
expect(findCteColumnResolution(outer, "id")?.definition.name).toBe("later");
});
it("does not let a CTE referenced only from a deeper subquery claim an outer bare column", () => {
const sql = "WITH w AS (SELECT x FROM a) SELECT q FROM real_t, (SELECT x FROM w) s";
const outerQ = sql.indexOf("SELECT q FROM real_t") + "SELECT ".length;
const model = buildSqlSemanticModel(sql, outerQ + 1);
// `w` is only referenced inside the derived table; it must not own the outer bare `q`.
expect(findCteColumnResolution(model, "q")).toBeNull();
// Inside the subquery that references `w`, its own column still resolves.
const innerX = sql.indexOf("x FROM w") + 1;
const inner = buildSqlSemanticModel(sql, innerX);
expect(findCteColumnResolution(inner, "x")?.definition.name).toBe("w");
});
it("keeps bare columns of a CTE body on the CTE that body references", () => {
const sql = "WITH a AS (SELECT id FROM orders), b AS (SELECT id FROM a) SELECT id FROM b";
const bodyId = sql.indexOf("SELECT id FROM a") + "SELECT ".length;
const model = buildSqlSemanticModel(sql, bodyId + 1);
expect(findCteColumnResolution(model, "id")?.definition.name).toBe("a");
});
it("returns null for a column absent from an explicit column list", () => {
const sql = "WITH w(a, b) AS (SELECT id, name FROM orders) SELECT c FROM w";
const outerC = sql.lastIndexOf("c");
const model = buildSqlSemanticModel(sql, outerC + 1);
expect(findCteColumnResolution(model, "c")).toBeNull();
});
it("never lets a star-bodied CTE claim a column qualified by a physical table", () => {
const sql = "WITH w AS (SELECT * FROM a) SELECT t.id FROM orders t JOIN w ON t.id = w.id";
// Hover/click build the model at the position, so the cursor intent already points at `t`.
const model = buildSqlSemanticModel(sql, sql.indexOf("t.id") + 2);
// Before the guard this returned the CTE `w` through its body star.
expect(findCteColumnResolution(model, "id", "t")).toBeNull();
});
it("never lets a named CTE output claim a column qualified by a physical table", () => {
const sql = "WITH w AS (SELECT id FROM a) SELECT t.id FROM orders t JOIN w ON t.id = w.id";
const model = buildSqlSemanticModel(sql, sql.indexOf("t.id") + 2);
expect(findCteColumnResolution(model, "id", "t")).toBeNull();
});
it("still resolves the CTE-qualified column in the same statement", () => {
const sql = "WITH w AS (SELECT * FROM a) SELECT t.id FROM orders t JOIN w ON t.id = w.id";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("w.id") + 2);
const hit = findCteColumnResolution(model, "id", "w");
expect(hit?.definition.name).toBe("w");
expect(hit?.stars).toHaveLength(1);
});
});
describe("resolveCteColumnOrigins", () => {
it("traces a star-derived column to the physical body table", () => {
const sql = "WITH w AS (SELECT * FROM orders) SELECT x FROM w";
const pos = sql.indexOf("x", sql.indexOf("SELECT", sql.indexOf(")")));
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "x");
const origins = resolveCteColumnOrigins(model, hit!, "x");
expect(origins).toHaveLength(1);
expect(origins[0]!.source.name).toBe("orders");
expect(origins[0]!.column).toBe("x");
});
it("traces qualified projections to the aliased body table", () => {
const sql = "WITH w AS (SELECT t.id FROM orders t) SELECT id FROM w";
const pos = sql.lastIndexOf("id");
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "id");
const origins = resolveCteColumnOrigins(model, hit!, "id");
expect(origins.map((origin) => ({ name: origin.source.name, column: origin.column }))).toEqual([{ name: "orders", column: "id" }]);
});
it("follows CTE chains down to the physical table", () => {
const sql = "WITH a AS (SELECT id FROM orders), b AS (SELECT id FROM a) SELECT id FROM b";
const pos = sql.lastIndexOf("id");
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "id");
expect(hit?.definition.name).toBe("b");
const origins = resolveCteColumnOrigins(model, hit!, "id");
expect(origins).toHaveLength(1);
expect(origins[0]!.source.name).toBe("orders");
expect(origins[0]!.column).toBe("id");
});
it("traces through a derived table inside the CTE body", () => {
const sql = "WITH w AS (SELECT X.id FROM (SELECT T.id FROM orders T) X) SELECT id FROM w";
const pos = sql.lastIndexOf("id");
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "id");
expect(hit?.definition.name).toBe("w");
expect(resolveCteColumnOrigins(model, hit!, "id").map((origin) => ({ name: origin.source.name, column: origin.column }))).toEqual([{ name: "orders", column: "id" }]);
});
it("expands a derived table's body star down to the physical table", () => {
const sql = "WITH w AS (SELECT X.qty FROM (SELECT T.* FROM orders T) X) SELECT qty FROM w";
const pos = sql.lastIndexOf("qty");
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "qty");
expect(resolveCteColumnOrigins(model, hit!, "qty").map((origin) => ({ name: origin.source.name, column: origin.column }))).toEqual([{ name: "orders", column: "qty" }]);
});
it("keeps every union branch of a derived-table CTE body", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT T.* FROM t1 T) X UNION ALL SELECT X.a FROM (SELECT T.* FROM t2 T) X) SELECT a FROM w";
const pos = sql.lastIndexOf("a");
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "a");
expect(resolveCteColumnOrigins(model, hit!, "a").map((origin) => origin.source.name)).toEqual(["t1", "t2"]);
});
it("does not recurse through a recursive CTE self-reference", () => {
const sql = "WITH RECURSIVE r(n) AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM r) SELECT n FROM r";
const pos = sql.lastIndexOf("n");
const model = buildSqlSemanticModel(sql, pos + 1);
const definition = cteDefinitionSources(model)[0]!;
const hit = findCteColumnResolution(model, "n");
expect(definition.bodySources?.some((source) => source.name === "r")).toBe(true);
expect(() => resolveCteColumnOrigins(model, hit!, "n")).not.toThrow();
expect(resolveCteColumnOrigins(model, hit!, "n")).toEqual([]);
});
it("returns no origin for expression outputs", () => {
const sql = "WITH w AS (SELECT n + 1 AS doubled FROM orders) SELECT doubled FROM w";
const pos = sql.lastIndexOf("doubled");
const model = buildSqlSemanticModel(sql, pos + 1);
const hit = findCteColumnResolution(model, "doubled");
expect(resolveCteColumnOrigins(model, hit!, "doubled")).toEqual([]);
});
});
describe("多星号与派生表别名列的溯源", () => {
it("多星号 body 保留全部候选星号,并逐个展开为来源", () => {
const sql = "WITH w AS (SELECT a.*, b.* FROM t1 a, t2 b) SELECT id FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("id") + 1);
const hit = findCteColumnResolution(model, "id");
expect(hit?.stars).toHaveLength(2);
expect(hit?.stars?.map((star) => star.qualifierParts)).toEqual([["a"], ["b"]]);
// 两个来源都返回,由调用方用列元数据裁决(见 CteColumnHit.stars 注释)。
expect(resolveCteColumnOrigins(model, hit!, "id").map((origin) => origin.source.name)).toEqual(["t1", "t2"]);
});
it("限定星号只展开它自己的来源,不受其它星号影响", () => {
const sql = "WITH w AS (SELECT a.*, b.* FROM t1 a, t2 b) SELECT id FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("id") + 1);
const hit = findCteColumnResolution(model, "id");
const second = { ...hit!, stars: [hit!.stars![1]!] };
expect(resolveCteColumnOrigins(model, second, "id").map((origin) => origin.source.name)).toEqual(["t2"]);
});
it("派生表别名列 X(a) 让外层 a 继续溯源到物理列", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT id FROM orders) X(a)) SELECT a FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("a") + 1);
const hit = findCteColumnResolution(model, "a");
expect(hit?.output?.name).toBe("a");
expect(resolveCteColumnOrigins(model, hit!, "a").map((origin) => ({ name: origin.source.name, column: origin.column }))).toEqual([{ name: "orders", column: "id" }]);
});
it("别名列数量与投影数量不一致时不做按位改名,避免错配", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT id, name FROM orders) X(a)) SELECT a FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("a") + 1);
const derived = cteDefinitionSources(model)[0]!.bodySources![0]!;
const hit = findCteColumnResolution(model, "a");
// 内层仍投影 id/name,改名被放弃:外层 a 仍解析到 w,但溯源在这里中断,
// 而不是把 X.a 错配到 orders.id。
expect(derived.cteOutputs?.map((column) => column.name)).toEqual(["id", "name"]);
expect(hit?.output?.name).toBe("a");
expect(resolveCteColumnOrigins(model, hit!, "a")).toEqual([]);
});
it("命名投影命中时不再回退到星号", () => {
const sql = "WITH w AS (SELECT id, a.* FROM t1 a) SELECT id FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("id") + 1);
const hit = findCteColumnResolution(model, "id");
// 命名输出优先,星号只在没有任何命名命中时充当候选。
expect(hit?.output?.name).toBe("id");
expect(hit?.stars).toBeUndefined();
expect(resolveCteColumnOrigins(model, hit!, "id").map((origin) => origin.source.name)).toEqual(["t1"]);
});
it("同一来源被多个星号重复引用时,来源去重", () => {
const sql = "WITH w AS (SELECT a.*, a.* FROM t1 a) SELECT id FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("id") + 1);
const hit = findCteColumnResolution(model, "id");
expect(hit?.stars).toHaveLength(2);
expect(resolveCteColumnOrigins(model, hit!, "id").map((origin) => origin.source.name)).toEqual(["t1"]);
});
it("星号 body + 别名列:改名被放弃,退化到星号兜底并由元数据筛选兜底", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT * FROM orders) X(a)) SELECT a FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("a") + 1);
const derived = cteDefinitionSources(model)[0]!.bodySources![0]!;
const hit = findCteColumnResolution(model, "a");
// 星号无法与别名列表建立位置对应,因此这里不做改名:内层不产出命名输出,
// 溯源只能落到星号,列名沿用外层别名 `a`。调用方按真实列元数据筛选,
// 内层没有 a 列时整条链路被丢弃(不会显示错误的注释)。
expect(derived.columns).toEqual(["a"]);
expect(derived.cteOutputs).toEqual([]);
expect(resolveCteColumnOrigins(model, hit!, "a").map((origin) => ({ name: origin.source.name, column: origin.column }))).toEqual([{ name: "orders", column: "a" }]);
});
it("别名列不创造 origin,也不改变内层跳转目标", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT id AS x FROM orders) X(a)) SELECT a FROM w";
const model = buildSqlSemanticModel(sql, sql.lastIndexOf("a") + 1);
const derived = cteDefinitionSources(model)[0]!.bodySources![0]!;
const output = derived.cteOutputs![0]!;
const hit = findCteColumnResolution(model, "a");
// 名称换成了外层别名,但 jumpSpan 仍指向内层别名 token、且不因此获得 origin。
expect(output.name).toBe("a");
expect(sql.slice(output.jumpSpan.start, output.jumpSpan.end)).toBe("x");
expect(resolveCteColumnOrigins(model, hit!, "a")).toEqual([]);
});
});
describe("CTE 溯源链路的深度边界", () => {
/** c1 → c2 → … → cN 的线性链路,最外层引用 cN。 */
function chainedSql(length: number): string {
const definitions = Array.from({ length }, (_, index) => (index === 0 ? "c1 AS (SELECT id FROM orders)" : `c${index + 1} AS (SELECT id FROM c${index})`));
return `WITH ${definitions.join(", ")} SELECT id FROM c${length}`;
}
it("九层链路仍在深度上限内,可追溯到物理表", () => {
const sql = chainedSql(9);
const { model } = modelAt(sql, "id FROM c9");
const hit = findCteColumnResolution(model, "id");
expect(hit?.definition.name).toBe("c9");
expect(resolveCteColumnOrigins(model, hit!, "id").map((origin) => origin.source.name)).toEqual(["orders"]);
});
it("超过八层(十层链路)时截断为空,交由上层回退", () => {
const sql = chainedSql(10);
const { model } = modelAt(sql, "id FROM c10");
const hit = findCteColumnResolution(model, "id");
expect(hit?.definition.name).toBe("c10");
expect(resolveCteColumnOrigins(model, hit!, "id")).toEqual([]);
});
it("限定符解析不到任何 CTE 引用时返回 null", () => {
const sql = "WITH w AS (SELECT * FROM orders) SELECT w.id FROM w";
const { model } = modelAt(sql, "WITH");
expect(findCteColumnResolution(model, "id", "zzz")).toBeNull();
});
});
@@ -100,6 +100,40 @@ describe("sqlSemanticModel baseline fixtures", () => {
expect(model.rowSources.some((source) => source.name === "users")).toBe(false);
});
it("enriches derived tables inside a CTE body but never outside one", () => {
const { sql, cursor } = sqlFixtureCursor("WITH w AS (SELECT x.id FROM (SELECT t.id FROM orders t) x) SELECT w.| FROM w");
const model = buildSqlSemanticModel(sql, cursor);
const definition = model.rowSources.find((source) => source.id.startsWith("cte:"))!;
const inner = definition.bodySources?.find((source) => source.kind === "subquery");
// The inline view mirrors a CTE definition's body metadata so lineage can descend into it.
expect(inner?.cteOutputs?.map((output) => output.name)).toEqual(["id"]);
expect(inner?.cteStars).toEqual([]);
expect(inner?.bodySources?.map((source) => source.name)).toEqual(["orders"]);
// Its tables still stay nested on the source and never leak to the outer model.
expect(definition.bodySources?.some((source) => source.name === "orders")).toBe(false);
// A derived table outside a CTE body keeps its previous metadata-free shape.
const plain = buildSqlSemanticModel("SELECT * FROM (SELECT id FROM users) sq WHERE sq.id = 1", 20);
const outer = plain.rowSources.find((source) => source.kind === "subquery")!;
expect(outer.cteOutputs).toBeUndefined();
expect(outer.cteStars).toBeUndefined();
expect(outer.bodySources).toBeUndefined();
});
it("enriches only the outermost derived table of a CTE body", () => {
const sql = "WITH w AS (SELECT x.id FROM (SELECT y.id FROM (SELECT t.id FROM orders t) y) x) SELECT id FROM w";
const model = buildSqlSemanticModel(sql, sql.length);
const definition = model.rowSources.find((source) => source.id.startsWith("cte:"))!;
const outerView = definition.bodySources?.find((source) => source.kind === "subquery");
const nestedView = outerView?.bodySources?.find((source) => source.kind === "subquery");
expect(outerView?.name).toBe("x");
expect(outerView?.bodySources?.map((source) => source.name)).toEqual(["y"]);
expect(nestedView?.name).toBe("y");
expect(nestedView?.bodySources).toBeUndefined();
});
it("suppresses completion inside string literals without metadata scope", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT 'u.|' FROM users");
const model = buildSqlSemanticModel(sql, cursor);
@@ -428,3 +462,119 @@ describe("sqlSemanticModel baseline fixtures", () => {
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "#temp", kind: "table" })]));
});
});
describe("CTE 富化边界与熔断", () => {
function cteDefinitionOf(sql: string, cursor = sql.length) {
const model = buildSqlSemanticModel(sql, cursor);
const definition = model.rowSources.find((source) => source.id.startsWith("cte:"));
expect(definition, "未解析出 CTE 定义源").toBeDefined();
return definition!;
}
it("多段星号链 s.t.* 记录完整限定符链", () => {
const sql = "WITH w AS (SELECT s.t.* FROM s.t) SELECT id FROM w";
const definition = cteDefinitionOf(sql);
const star = definition.cteStars?.[0];
expect(definition.cteStars?.map((item) => item.qualifierParts)).toEqual([["s", "t"]]);
expect(sql.slice(star!.starSpan.start, star!.starSpan.end)).toBe("*");
expect(definition.cteOutputs).toEqual([]);
expect(definition.bodySources?.map((source) => ({ name: source.name, qualifierParts: source.qualifierParts }))).toEqual([{ name: "t", qualifierParts: ["s"] }]);
});
it("无别名的表达式投影只提供跳转范围,不挂 origin", () => {
const sql = "WITH w AS (SELECT n + 1 FROM orders) SELECT id FROM w";
const definition = cteDefinitionOf(sql);
const output = definition.cteOutputs?.[0];
expect(output?.name).toBe("n");
expect(output?.origin).toBeUndefined();
expect(sql.slice(output!.jumpSpan.start, output!.jumpSpan.end)).toBe("n + 1");
});
it("body 没有顶层 SELECT(如 VALUES)时不产生星号,显式列名保留但无 origin", () => {
const sql = "WITH w(a) AS (VALUES (1)) SELECT a FROM w";
const definition = cteDefinitionOf(sql);
expect(definition.cteStars).toEqual([]);
expect(definition.cteOutputs?.map((column) => column.name)).toEqual(["a"]);
expect(definition.cteOutputs?.[0]?.origin).toBeUndefined();
});
it("多星号 body 保留全部星号与来源表(供上层按序回退)", () => {
const sql = "WITH w AS (SELECT a.*, b.* FROM t1 a, t2 b) SELECT c FROM w";
const definition = cteDefinitionOf(sql);
expect(definition.cteStars?.map((star) => star.qualifierParts)).toEqual([["a"], ["b"]]);
expect(definition.bodySources?.map((source) => source.name)).toEqual(["t1", "t2"]);
});
it("派生表别名列 X(a) 按位改名到外层名字,origin 仍指向内层真实列", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT id FROM orders) X(a)) SELECT id FROM w";
const definition = cteDefinitionOf(sql);
const derived = definition.bodySources?.find((source) => source.name === "X");
expect(derived?.columns).toEqual(["a"]);
expect(derived?.cteOutputs?.map((column) => column.name)).toEqual(["a"]);
expect(derived?.cteOutputs?.[0]?.origin).toEqual({ qualifierParts: [], column: "id" });
expect(sql.slice(derived!.cteOutputs![0]!.jumpSpan.start, derived!.cteOutputs![0]!.jumpSpan.end)).toBe("id");
expect(derived?.bodySources?.map((source) => source.name)).toEqual(["orders"]);
});
it("派生表别名列与星号共存时不改名(无法按位对应)", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT * FROM orders) X(a)) SELECT id FROM w";
const definition = cteDefinitionOf(sql);
const derived = definition.bodySources?.find((source) => source.name === "X");
// columns 走宽松规则拿到别名,cteOutputs 走严格规则不接受改名 —— 两者故意不一致。
expect(derived?.columns).toEqual(["a"]);
expect(derived?.cteOutputs).toEqual([]);
expect(derived?.cteStars).toHaveLength(1);
});
it("派生表别名列长度与投影长度不符时不改名,count 少的一方不被补齐", () => {
const sql = "WITH w AS (SELECT X.a FROM (SELECT id FROM orders) X(a, b)) SELECT id FROM w";
const definition = cteDefinitionOf(sql);
const derived = definition.bodySources?.find((source) => source.name === "X");
// 别名多于投影:columns 按位截断为 ["a"],cteOutputs 保持内层名字 ["id"]。
expect(derived?.columns).toEqual(["a"]);
expect(derived?.cteOutputs?.map((column) => column.name)).toEqual(["id"]);
});
it("括号未闭合时 bodySpan 收敛到已解析的最后一个 token,不越界", () => {
const sql = "WITH w AS (SELECT id FROM orders";
const definition = cteDefinitionOf(sql, 0);
expect(sql.slice(definition.bodySpan!.start, definition.bodySpan!.end)).toBe("(SELECT id FROM orders");
expect(definition.bodySpan!.end).toBeLessThanOrEqual(sql.length);
});
it("语句 token 超限时整体跳过富化,扁平列名仍可用", () => {
const filler = Array.from({ length: 7000 }, () => "a = 1 or").join(" ");
const sql = `WITH w AS (SELECT id FROM orders) SELECT id FROM w where ${filler} 1 = 1`;
const definition = cteDefinitionOf(sql, 0);
expect(definition.cteOutputs).toBeUndefined();
expect(definition.cteStars).toBeUndefined();
expect(definition.bodySources).toBeUndefined();
expect(definition.columns).toEqual(["id"]);
});
it("仅 body token 超限时只跳过该 body,同语句其它 CTE 仍完整富化", () => {
const projections = Array.from({ length: 2501 }, (_, index) => `c${index}`).join(",");
const sql = `WITH w AS (SELECT ${projections} FROM orders), small AS (SELECT id FROM orders) SELECT id FROM small`;
const model = buildSqlSemanticModel(sql, 0);
const definitions = model.rowSources.filter((source) => source.id.startsWith("cte:"));
const oversized = definitions.find((source) => source.name === "w");
const neighbor = definitions.find((source) => source.name === "small");
expect(oversized?.cteOutputs).toBeUndefined();
expect(oversized?.cteStars).toBeUndefined();
expect(oversized?.bodySources).toBeUndefined();
expect(oversized?.columns).toHaveLength(2501);
// 同语句里的其它 CTE 不受影响,说明熔断是按 body 粒度生效的。
expect(neighbor?.cteOutputs?.map((column) => column.name)).toEqual(["id"]);
expect(neighbor?.bodySources?.map((source) => source.name)).toEqual(["orders"]);
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { quoteIdentifier } from "@/lib/editor/hoverTableSql";
import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
import {
extractIdentifierAt,
extractIdentifierDetailsAt,
@@ -94,6 +95,22 @@ describe("extractQualifiedIdentifierAt", () => {
expect(splitQualifiedIdentifier(requoted)).toEqual(["log", "public-rate_kingdee"]);
});
it("extracts unquoted Unicode identifiers (e.g. CJK CTE names and columns)", () => {
const sql = "WITH 窗口函数 AS (SELECT * FROM TABLENAME) SELECT 字段 FROM 窗口函数";
expect(extractIdentifierAt(sql, sql.indexOf("窗口函数", sql.indexOf("SELECT")))).toBe("窗口函数");
const outerReference = extractQualifiedIdentifierAt(sql, sql.lastIndexOf("窗口函数"));
expect(outerReference?.parts.map((part) => part.value)).toEqual(["窗口函数"]);
const mixed = "SELECT t.中文名 FROM orders t";
expect(extractQualifiedIdentifierAt(mixed, mixed.indexOf("中文名"))?.parts.map((part) => part.value)).toEqual(["t", "中文名"]);
});
it("keeps digits as continuation characters of Unicode identifiers", () => {
const sql = "select 字段1 from t";
expect(extractIdentifierAt(sql, sql.indexOf("1"))).toBe("字段1");
});
});
describe("matchTable", () => {
@@ -321,3 +338,40 @@ describe("call-site navigation helpers", () => {
});
});
});
describe("标识符提取的字符边界", () => {
it("支持 @ / # / $ 作为标识符首字符", () => {
const sql = "select @var, #tmp, $col from t";
expect(extractIdentifierAt(sql, sql.indexOf("@var"))).toBe("@var");
expect(extractIdentifierAt(sql, sql.indexOf("#tmp"))).toBe("#tmp");
expect(extractIdentifierAt(sql, sql.indexOf("$col"))).toBe("$col");
});
it("数字开头的片段不被当作标识符(只识别其后的字母段)", () => {
const sql = "select 1abc from t";
expect(extractIdentifierAt(sql, sql.indexOf("1abc"))).toBeNull();
expect(extractIdentifierAt(sql, sql.indexOf("abc"))).toBe("abc");
});
it("引号包裹的中文标识符去掉引号并标记 quoted", () => {
const sql = 'select "中文列" from "订单表"';
const details = extractIdentifierDetailsAt(sql, sql.indexOf("中文列"));
expect(details?.identifier).toBe("中文列");
expect(details?.quoted).toBe(true);
expect(extractQualifiedIdentifierAt(sql, sql.indexOf("订单表"))?.parts).toEqual([{ value: "订单表", quoted: true }]);
});
it("对含中文/变量前缀的 SQL,逐 token 还原结果与语义分词器一致", () => {
const sql = "with 汇总 as (select 字段1, @变量 from t1) select 字段1 from 汇总";
const tokens = tokenizeSqlSemantic(sql);
const identifiers = tokens.filter((token) => tokenIsIdentifier(token));
expect(identifiers.map((token) => token.text)).toEqual(expect.arrayContaining(["汇总", "字段1", "@变量"]));
for (const token of identifiers) {
expect(extractIdentifierAt(sql, token.span.start)).toBe(unquoteSqlSemanticIdentifier(token));
}
});
});
@@ -0,0 +1,250 @@
/**
* CTE (WITH ... AS) navigation helpers shared by Ctrl/Cmd+click and hover handling.
*
* A CTE appears twice in the semantic model:
* - a **definition** row source (`id` prefix `cte:`) carrying `nameSpan` / `bodySpan` /
* `cteOutputs` / `cteStars` / `bodySources` enrichment;
* - zero or more **reference** row sources (`from:` / `join:` …, `kind: "cte"`) where the
* CTE is used as a row source by another query block.
*
* Derived tables (`kind: "subquery"`) that sit directly inside a CTE body carry the same body
* enrichment, which lets a lineage keep tracing through `FROM (SELECT ...) x` to the physical
* tables inside the inline view.
*/
import type { SqlSemanticCteOutputColumn, SqlSemanticCteStar, SqlSemanticModel, SqlSemanticRowSource } from "@/lib/sql/semantic/types";
/** CTE chains can nest (a → b → physical table); stop well before pathological recursion. */
const MAX_CTE_ORIGIN_DEPTH = 8;
function containsPosition(span: { start: number; end: number }, position: number): boolean {
return position >= span.start && position <= span.end;
}
function sameName(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
/** CTE definition row sources (`WITH c AS (...)`) in declaration order. */
export function cteDefinitionSources(model: SqlSemanticModel): SqlSemanticRowSource[] {
return model.rowSources.filter((source) => source.kind === "cte" && source.id.startsWith("cte:"));
}
/** CTE *usages* as row sources (`FROM/JOIN cte`), excluding the definitions themselves. */
export function cteReferenceSources(model: SqlSemanticModel): SqlSemanticRowSource[] {
return model.rowSources.filter((source) => source.kind === "cte" && !source.id.startsWith("cte:"));
}
function findCteDefinition(model: SqlSemanticModel, name: string): SqlSemanticRowSource | undefined {
const definitions = cteDefinitionSources(model);
for (let index = definitions.length - 1; index >= 0; index -= 1) {
const definition = definitions[index];
if (definition && sameName(definition.name, name)) return definition;
}
return undefined;
}
export interface CteReferenceHit {
reference: SqlSemanticRowSource;
definition: SqlSemanticRowSource;
}
/**
* Position lies on an outer-query CTE reference name or alias (never on the definition name
* itself — definitions have no `qualifiedName` and are excluded by id prefix).
*/
export function findCteReferenceAt(model: SqlSemanticModel, position: number): CteReferenceHit | null {
for (const reference of cteReferenceSources(model)) {
const onName = !!reference.qualifiedName && containsPosition(reference.qualifiedName.span, position);
const onAlias = !!reference.aliasSpan && containsPosition(reference.aliasSpan, position);
if (!onName && !onAlias) continue;
const definition = findCteDefinition(model, reference.name);
if (!definition?.nameSpan) continue;
return { reference, definition };
}
return null;
}
export interface CteColumnHit {
definition: SqlSemanticRowSource;
/** Matched named output column; absent when the column arrives via a body star. */
output?: SqlSemanticCteOutputColumn;
/**
* Every star projection that could supply the column, in body order (absent when a named output
* matched). All of them are kept instead of the first one, because `SELECT a.*, b.*` gives no
* way to tell which star owns the column without column metadata — callers pick among the traced
* origins with the metadata they already fetch.
*/
stars?: SqlSemanticCteStar[];
}
/** Nesting depth of the parenthesis range containing `position` (0 at statement level). */
function nestingDepthAt(model: SqlSemanticModel, position: number): number {
let depth = 0;
for (const token of model.tokens) {
if (token.span.start > position) break;
if (token.span.end > position) return token.depth;
depth = token.depth;
}
return depth;
}
/**
* Picks the matching named output, or the stars that can supply the column.
* Note: a body `t.*` star still exposes its columns to the outer query both bare and
* CTE-qualified (as `w.col`), so the outer qualifier never participates in star selection;
* star qualifier parts only steer physical-source expansion during origin tracing.
*/
function resolveCteOutputOrStars(definition: SqlSemanticRowSource, columnName: string): CteColumnHit | null {
const output = definition.cteOutputs?.find((candidate) => sameName(candidate.name, columnName));
if (output) return { definition, output };
const stars = definition.cteStars ?? [];
if (stars.length === 0) return null;
return { definition, stars };
}
/**
* Resolves an outer-query column to the CTE that produces it. The model must be built at the
* click/hover position (cursor intent drives qualified-column target resolution).
*
* - Qualified columns (`w.col`) use the cursor intent target source or the qualifier name, and
* resolve *only* through the CTE that owns that qualifier.
* - Bare columns resolve only when exactly one CTE *visible at the cursor* supplies the name,
* otherwise `null` is returned so callers fall back to physical-table handling. Visibility means
* the CTE is declared before the cursor and is referenced at the cursor's own nesting level —
* without that guard a later, unrelated CTE body claims bare columns of an earlier query block.
*/
export function findCteColumnResolution(model: SqlSemanticModel, columnName: string, qualifier?: string): CteColumnHit | null {
const intentTargetId = model.cursorIntent.targetSourceId;
if (qualifier || intentTargetId) {
let target: SqlSemanticRowSource | undefined;
if (intentTargetId) {
target = model.rowSources.find((source) => source.id === intentTargetId);
}
if (!target && qualifier) {
target = cteReferenceSources(model).find((source) => (!!source.alias && sameName(source.alias, qualifier)) || sameName(source.name, qualifier));
}
// The qualifier owns the column: a physical table (or any other non-CTE row source) must never
// fall through to the bare-column scan below, which ignores qualifiers and would let a
// star-bodied CTE claim `t.id` from an unrelated physical table.
if (!target || target.kind !== "cte" || target.id.startsWith("cte:")) return null;
const definition = findCteDefinition(model, target.name);
return definition ? resolveCteOutputOrStars(definition, columnName) : null;
}
const hits: CteColumnHit[] = [];
const seenDefinitionIds = new Set<string>();
const cursorDepth = nestingDepthAt(model, model.cursor);
for (const reference of cteReferenceSources(model)) {
const definition = findCteDefinition(model, reference.name);
if (!definition || seenDefinitionIds.has(definition.id)) continue;
// Only a CTE declared before the cursor *and* referenced in the cursor's own query block can
// own a bare column; otherwise an unrelated query block (typically a CTE declared further
// down, or one referenced only from a deeper subquery) hijacks the name and click/hover
// lands on that far-away CTE.
if (definition.sourceSpan.start > model.cursor) continue;
if (nestingDepthAt(model, reference.sourceSpan.start) !== cursorDepth) continue;
seenDefinitionIds.add(definition.id);
const hit = resolveCteOutputOrStars(definition, columnName);
if (hit) hits.push(hit);
}
return hits.length === 1 ? hits[0] : null;
}
/** A physical row source reached after tracing CTE output columns. */
export interface CteColumnOrigin {
source: SqlSemanticRowSource;
column: string;
}
function isPhysicalOriginSource(source: SqlSemanticRowSource): boolean {
return source.kind === "table" || source.kind === "table_function" || source.kind === "mutation_target";
}
function sourceMatchesQualifier(source: SqlSemanticRowSource, qualifierParts: readonly string[]): boolean {
const qualifier = qualifierParts[qualifierParts.length - 1]?.toLowerCase();
if (!qualifier) return false;
return source.alias?.toLowerCase() === qualifier || source.name.toLowerCase() === qualifier;
}
function dedupeOrigins(origins: CteColumnOrigin[]): CteColumnOrigin[] {
const seen = new Set<string>();
const result: CteColumnOrigin[] = [];
for (const origin of origins) {
const key = `${origin.source.sourceSpan.start}:${origin.source.sourceSpan.end}:${origin.column.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(origin);
}
return result;
}
function originsForDefinition(model: SqlSemanticModel, definition: SqlSemanticRowSource, hit: CteColumnHit, columnName: string, visited: Set<string>, depth: number): CteColumnOrigin[] {
if (depth > MAX_CTE_ORIGIN_DEPTH || visited.has(definition.id)) return [];
visited.add(definition.id);
if (hit.output?.origin) {
const { qualifierParts, column } = hit.output.origin;
return expandOriginSources(model, definition, qualifierParts, column, visited, depth);
}
if (hit.stars && hit.stars.length > 0) {
// `SELECT a.*, b.*` yields one candidate star per body source; trace them all and let the
// caller's column metadata decide which one actually owns the name (see CteColumnHit.stars).
return dedupeOrigins(hit.stars.flatMap((star) => expandStarSources(model, definition, star, columnName, visited, depth)));
}
return [];
}
function followBodySource(model: SqlSemanticModel, ownerDefinition: SqlSemanticRowSource, source: SqlSemanticRowSource, column: string, visited: Set<string>, depth: number): CteColumnOrigin[] {
// Recursive CTE self-reference — never trace back into the same definition.
if (sameName(source.name, ownerDefinition.name)) return [];
if (source.kind === "cte") {
const upstream = findCteDefinition(model, source.name);
if (!upstream) return [];
const upstreamHit = resolveCteOutputOrStars(upstream, column);
if (!upstreamHit) return [];
return originsForDefinition(model, upstream, upstreamHit, column, visited, depth + 1);
}
if (source.kind === "subquery") {
// Derived table (enriched only when it sits directly inside a CTE body): resolve the column
// against the inline view's own projections/stars, then keep tracing inside its body.
const innerHit = resolveCteOutputOrStars(source, column);
return innerHit ? originsForDefinition(model, source, innerHit, column, visited, depth + 1) : [];
}
if (isPhysicalOriginSource(source)) {
return [{ source, column }];
}
return [];
}
function expandOriginSources(model: SqlSemanticModel, definition: SqlSemanticRowSource, qualifierParts: readonly string[], column: string, visited: Set<string>, depth: number): CteColumnOrigin[] {
const sources = definition.bodySources ?? [];
// Bare columns may come from any source, including an upstream CTE (chain); followBodySource
// itself decides which source kinds are traceable.
const matched = qualifierParts.length > 0 ? sources.filter((source) => sourceMatchesQualifier(source, qualifierParts)) : sources;
const origins: CteColumnOrigin[] = [];
for (const source of matched) {
origins.push(...followBodySource(model, definition, source, column, visited, depth));
}
return dedupeOrigins(origins);
}
function expandStarSources(model: SqlSemanticModel, definition: SqlSemanticRowSource, star: SqlSemanticCteStar, columnName: string, visited: Set<string>, depth: number): CteColumnOrigin[] {
const sources = (definition.bodySources ?? []).filter((source) => !sameName(source.name, definition.name));
const matched = star.qualifierParts.length > 0 ? sources.filter((source) => sourceMatchesQualifier(source, star.qualifierParts)) : sources;
const origins: CteColumnOrigin[] = [];
for (const source of matched) {
origins.push(...followBodySource(model, definition, source, columnName, visited, depth));
}
return dedupeOrigins(origins);
}
/**
* Traces a resolved CTE column through simple projections and CTE chains down to the physical
* table column(s) whose metadata can drive hover comments. Returns an empty list for
* expressions, explicit-list mismatches, cycles, or over-deep chains — callers then fall back.
*/
export function resolveCteColumnOrigins(model: SqlSemanticModel, hit: CteColumnHit, outerColumnName: string): CteColumnOrigin[] {
return originsForDefinition(model, hit.definition, hit, outerColumnName, new Set<string>(), 0);
}
+201 -3
View File
@@ -4,6 +4,8 @@ import { resolveSqlStatementWindow } from "@/lib/sql/sqlSyntaxTreeWindow";
import type {
SqlSemanticBuildOptions,
SqlSemanticClauseSpans,
SqlSemanticCteOutputColumn,
SqlSemanticCteStar,
SqlSemanticCursorIntent,
SqlSemanticIdentifierPart,
SqlSemanticModel,
@@ -28,6 +30,10 @@ const TABLE_FUNCTION_INTRODUCERS = new Set(["from", "join", "straight_join", "ap
const TOP_LEVEL_STATEMENT_WORDS = new Set(["select", "insert", "delete", "merge", "create", "alter", "drop", "truncate", "call", "exec", "execute", "grant", "revoke"]);
const SQLSERVER_DEFAULT_SCHEMA = "dbo";
const SQLSERVER_UPDATE_STATISTICS_SCOPES = new Set(["all", "index", "table"]);
// CTE navigation enrichment (body projection origins + body row sources) is best-effort; on very
// large statements it is skipped entirely so click/hover parsing stays cheap.
const CTE_ENRICHMENT_STATEMENT_TOKEN_LIMIT = 20_000;
const CTE_ENRICHMENT_BODY_TOKEN_LIMIT = 5_000;
interface ParseState {
dialect: SqlSemanticDialectAdapter;
@@ -36,6 +42,8 @@ interface ParseState {
cteSources: SqlSemanticRowSource[];
expandGroupedSources?: boolean;
groupedSourceScopes?: SqlSemanticGroupedSourceScope[];
/** Enrich derived-table row sources parsed from this state (one level, CTE bodies only). */
enrichDerivedTables?: boolean;
}
export interface SqlSemanticGroupedSourceScope {
@@ -259,6 +267,136 @@ function parseSelectProjections(tokens: readonly SqlSemanticToken[], dialect: Sq
.filter((projection): projection is SqlSemanticProjection => projection != null && projection.name !== "*");
}
/**
* Analyzes one comma-separated CTE body SELECT projection for navigation metadata:
* a bare/qualified star or a named output with an optional plain-column origin.
*/
function cteBodyProjectionShape(state: ParseState, group: readonly SqlSemanticToken[]): { output?: SqlSemanticCteOutputColumn; star?: SqlSemanticCteStar } | null {
const useful = group.filter((item) => item.kind !== "comment");
if (useful.length === 0) return null;
const dialect = state.dialect;
// Bare `*`
if (useful.length === 1 && useful[0]?.text === "*") {
return { star: { starSpan: useful[0].span, qualifierParts: [] } };
}
// Qualified `q.*` (possibly a multi-part `a.b.*`) — the whole group must be the star chain.
const last = useful[useful.length - 1];
if (last?.text === "*" && useful[useful.length - 2]?.text === ".") {
const qualifierParts: string[] = [];
let cursor = useful.length - 3;
let valid = cursor >= 0;
while (valid && cursor >= 0) {
const identifier = useful[cursor];
if (!identifier || !tokenIsIdentifier(identifier)) {
valid = false;
break;
}
qualifierParts.unshift(identifierPart(identifier, dialect).name);
cursor -= 1;
if (cursor < 0) break;
if (useful[cursor]?.text !== ".") {
valid = false;
break;
}
cursor -= 1;
}
if (valid && qualifierParts.length > 0) {
return { star: { starSpan: last.span, qualifierParts } };
}
}
const projection = projectionNameFromTokens(useful, dialect);
if (!projection || projection.name === "*") return null;
// `expression AS alias` — navigate to the alias token; the expression itself is not traceable.
if (projection.aliasSpan) {
return { output: { name: projection.name, jumpSpan: projection.aliasSpan } };
}
// Plain `col` / `q.col` chain (identifiers separated only by dots) — traceable to a body source.
let isIdentifierChain = useful.length % 2 === 1;
for (let index = 0; isIdentifierChain && index < useful.length; index += 1) {
const token = useful[index];
if (!token) {
isIdentifierChain = false;
break;
}
if (index % 2 === 0) {
if (!tokenIsIdentifier(token)) isIdentifierChain = false;
} else if (token.text !== ".") {
isIdentifierChain = false;
}
}
if (isIdentifierChain) {
const identifiers = useful.filter((_, index) => index % 2 === 0);
const columnToken = identifiers[identifiers.length - 1];
if (columnToken) {
return {
output: {
name: identifierPart(columnToken, dialect).name,
jumpSpan: columnToken.span,
origin: {
qualifierParts: identifiers.slice(0, -1).map((token) => identifierPart(token, dialect).name),
column: identifierPart(columnToken, dialect).name,
},
},
};
}
}
// Any other expression without an alias — select the whole projection span, no origin.
return { output: { name: projection.name, jumpSpan: projection.span } };
}
/**
* CTE-body counterpart of parseSelectProjections that keeps stars and per-projection spans
* instead of flattening everything to bare column names.
*/
function parseCteBodyProjectionShapes(state: ParseState, bodyTokens: readonly SqlSemanticToken[]): { outputs: SqlSemanticCteOutputColumn[]; stars: SqlSemanticCteStar[] } {
const outputs: SqlSemanticCteOutputColumn[] = [];
const stars: SqlSemanticCteStar[] = [];
const baseDepth = bodyTokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY);
if (!Number.isFinite(baseDepth)) return { outputs, stars };
const selectDepth = baseDepth;
const selectIndex = bodyTokens.findIndex((item) => item.depth === selectDepth && item.kind === "word" && item.normalized === "select");
if (selectIndex < 0) return { outputs, stars };
let fromIndex = bodyTokens.findIndex((item, index) => index > selectIndex && item.depth === selectDepth && item.kind === "word" && item.normalized === "from");
if (fromIndex < 0) fromIndex = bodyTokens.length;
const projectionTokens = bodyTokens.slice(selectIndex + 1, fromIndex);
for (const group of splitTopLevelByComma(projectionTokens)) {
const shape = cteBodyProjectionShape(state, group);
if (shape?.output) outputs.push(shape.output);
if (shape?.star) stars.push(shape.star);
}
return { outputs, stars };
}
/** Parses row sources visible inside one CTE body; preceding CTEs are in scope for chaining. */
function parseCteBodySources(state: ParseState, bodyTokens: readonly SqlSemanticToken[], precedingCtes: readonly SqlSemanticRowSource[]): SqlSemanticRowSource[] {
if (bodyTokens.length === 0) return [];
let bodyDepth = Number.POSITIVE_INFINITY;
for (const token of bodyTokens) bodyDepth = Math.min(bodyDepth, token.depth);
if (!Number.isFinite(bodyDepth)) return [];
const first = bodyTokens.find((token) => token.depth === bodyDepth);
const last = bodyTokens[bodyTokens.length - 1];
if (!first || !last) return [];
const bodyState: ParseState = {
...state,
tokens: bodyTokens as SqlSemanticToken[],
statement: {
kind: "select",
span: { start: first.span.start, end: last.span.end },
text: state.statement.text,
},
cteSources: precedingCtes as SqlSemanticRowSource[],
// Body parsing must not leak grouped-join scopes into the outer statement parse.
groupedSourceScopes: state.groupedSourceScopes ? [] : undefined,
};
return parseRowSourcesAtDepth(bodyState, bodyDepth);
}
function parseCteSources(state: ParseState): SqlSemanticRowSource[] {
const tokens = state.tokens;
const first = tokens.findIndex((item) => item.kind === "word" && item.normalized === "with");
@@ -266,6 +404,7 @@ function parseCteSources(state: ParseState): SqlSemanticRowSource[] {
const sources: SqlSemanticRowSource[] = [];
let index = first + 1;
if (tokens[index]?.normalized === "recursive") index += 1;
const enrich = tokens.length <= CTE_ENRICHMENT_STATEMENT_TOKEN_LIMIT;
while (index < tokens.length) {
while (tokens[index]?.text === ",") index += 1;
@@ -274,13 +413,13 @@ function parseCteSources(state: ParseState): SqlSemanticRowSource[] {
const namePart = identifierPart(nameToken, state.dialect);
index += 1;
const explicitColumns: string[] = [];
const explicitColumns: Array<{ name: string; span: SqlSemanticSpan }> = [];
if (tokens[index]?.text === "(") {
const close = findMatchingParenToken(tokens, index);
if (close > index) {
for (const part of splitTopLevelByComma(tokens.slice(index + 1, close))) {
const identifier = part.find(tokenIsIdentifier);
if (identifier) explicitColumns.push(identifierPart(identifier, state.dialect).name);
if (identifier) explicitColumns.push({ name: identifierPart(identifier, state.dialect).name, span: identifier.span });
}
index = close + 1;
}
@@ -292,7 +431,32 @@ function parseCteSources(state: ParseState): SqlSemanticRowSource[] {
const bodyClose = findMatchingParenToken(tokens, bodyOpen);
const safeBodyClose = bodyClose < 0 ? tokens.length - 1 : bodyClose;
const bodyTokens = tokens.slice(bodyOpen + 1, safeBodyClose);
const bodyColumns = explicitColumns.length > 0 ? explicitColumns : parseSelectProjections(bodyTokens, state.dialect).map((projection) => projection.name);
// columns: unchanged flat name list consumed by completion/intention logic.
const bodyColumns = explicitColumns.length > 0 ? explicitColumns.map((column) => column.name) : parseSelectProjections(bodyTokens, state.dialect).map((projection) => projection.name);
let cteOutputs: SqlSemanticCteOutputColumn[] | undefined;
let cteStars: SqlSemanticCteStar[] | undefined;
let bodySources: SqlSemanticRowSource[] | undefined;
if (enrich && bodyTokens.length <= CTE_ENRICHMENT_BODY_TOKEN_LIMIT) {
const shapes = parseCteBodyProjectionShapes(state, bodyTokens);
cteStars = shapes.stars;
if (explicitColumns.length > 0) {
// Explicit `WITH c(a,b)` names win; body projection origins are inherited positionally
// so hover comments can still trace through the list, while navigation lands on list items.
cteOutputs = explicitColumns.map((column, columnIndex) => ({
name: column.name,
jumpSpan: column.span,
...(shapes.outputs[columnIndex]?.origin ? { origin: shapes.outputs[columnIndex]!.origin } : {}),
}));
} else {
cteOutputs = shapes.outputs;
}
// Derived tables in the body (12-branch `FROM (SELECT ...) X` bodies and friends) receive one
// level of enrichment so CTE lineage can keep tracing down to the physical tables inside them.
bodySources = parseCteBodySources({ ...state, enrichDerivedTables: true }, bodyTokens, sources);
}
sources.push({
id: `cte:${namePart.name}:${sources.length}`,
kind: "cte",
@@ -300,6 +464,11 @@ function parseCteSources(state: ParseState): SqlSemanticRowSource[] {
qualifierParts: [],
sourceSpan: { start: nameToken.span.start, end: tokens[safeBodyClose]?.span.end ?? nameToken.span.end },
columns: bodyColumns,
nameSpan: nameToken.span,
bodySpan: { start: tokens[bodyOpen]?.span.start ?? nameToken.span.start, end: tokens[safeBodyClose]?.span.end ?? nameToken.span.end },
...(cteOutputs ? { cteOutputs } : {}),
...(cteStars ? { cteStars } : {}),
...(bodySources ? { bodySources } : {}),
});
index = safeBodyClose + 1;
@@ -340,6 +509,19 @@ function mergeColumnAliases(columns: readonly string[], aliases: readonly string
return columns.map((column, index) => aliases[index] ?? column);
}
/**
* Derived tables can rename the inline-view outputs (`FROM (SELECT id FROM t) x(a)`): the outer
* query sees `x.a` while the body still projects `id`. The rename list is positional, so it can
* only be applied when the body projects names one-for-one — no stars and equal lengths. Otherwise
* the outputs are returned untouched, because a positional rename against a star or a shorter list
* would point `x.a` at an unrelated column. `origin` / `jumpSpan` are intentionally kept: both
* still describe the body side, which is where tracing and Ctrl+click must continue.
*/
function applyDerivedTableColumnAliases(outputs: SqlSemanticCteOutputColumn[], stars: readonly SqlSemanticCteStar[], aliases: readonly string[] | undefined): SqlSemanticCteOutputColumn[] {
if (!aliases?.length || stars.length > 0 || aliases.length !== outputs.length) return outputs;
return outputs.map((output, index) => ({ ...output, name: aliases[index] ?? output.name }));
}
function consumeSqlServerTableHint(tokens: readonly SqlSemanticToken[], index: number, dialect: SqlSemanticDialectAdapter): number {
if (dialect.id !== "sqlserver") return index;
const openIndex = tokens[index]?.normalized === "with" && tokens[index + 1]?.text === "(" ? index + 1 : index;
@@ -358,6 +540,21 @@ function parseSubquerySource(state: ParseState, openIndex: number, introducer: s
parseSelectProjections(bodyTokens, state.dialect).map((projection) => projection.name),
alias.columns,
);
// A derived table reached from a CTE body carries the same body metadata a CTE definition does,
// so `followBodySource` can resolve its projections/stars and trace one level into the inline
// view. Derived tables outside CTE bodies stay metadata-free (the common path stays cheap).
let body: Pick<SqlSemanticRowSource, "cteOutputs" | "cteStars" | "bodySources"> = {};
if (state.enrichDerivedTables && bodyTokens.length <= CTE_ENRICHMENT_BODY_TOKEN_LIMIT) {
const shapes = parseCteBodyProjectionShapes(state, bodyTokens);
body = {
// Outer column aliases rename the outputs so `x.a` resolves; tracing stays on the body side.
cteOutputs: applyDerivedTableColumnAliases(shapes.outputs, shapes.stars, alias.columns),
cteStars: shapes.stars,
// Enrichment stops here: derived tables nested inside this inline view are not parsed again,
// so depth can never re-parse arbitrarily nested views on every model build.
bodySources: parseCteBodySources({ ...state, enrichDerivedTables: false }, bodyTokens, state.cteSources),
};
}
return {
source: {
id: `${introducer}:subquery:${sourceIndex}`,
@@ -368,6 +565,7 @@ function parseSubquerySource(state: ParseState, openIndex: number, introducer: s
aliasSpan: alias.aliasSpan,
sourceSpan: { start: state.tokens[openIndex]?.span.start ?? 0, end: state.tokens[alias.nextIndex - 1]?.span.end ?? alias.aliasSpan?.end ?? state.tokens[close]?.span.end ?? 0 },
columns,
...body,
},
nextIndex: alias.nextIndex,
};
@@ -51,6 +51,35 @@ export interface SqlSemanticProjection {
aliasSpan?: SqlSemanticSpan;
}
/**
* Traceable origin of a CTE output column that is a plain column reference
* (`col` or `q.col`) in the CTE body. Expression/alias outputs carry no origin.
*/
export interface SqlSemanticCteColumnOrigin {
/** Qualifier chain before the column, e.g. `t` for `t.id`; empty for a bare `id`. */
qualifierParts: string[];
/** Referenced column name inside the CTE body. */
column: string;
}
/** A named column produced by a CTE (`WITH c(a,b)` list item or SELECT projection). */
export interface SqlSemanticCteOutputColumn {
/** Output column name as seen by the outer query. */
name: string;
/** Editor span to select when navigating to this output column inside the CTE body. */
jumpSpan: SqlSemanticSpan;
/** Present only when the body projection is a traceable plain column reference. */
origin?: SqlSemanticCteColumnOrigin;
}
/** A star projection (`*` or `q.*`) inside a CTE body. */
export interface SqlSemanticCteStar {
/** Span of the `*` token itself (navigation target). */
starSpan: SqlSemanticSpan;
/** Qualifier chain for `q.*`; empty for a bare `*`. */
qualifierParts: string[];
}
export interface SqlSemanticRowSource {
id: string;
kind: SqlSemanticRowSourceKind;
@@ -64,6 +93,20 @@ export interface SqlSemanticRowSource {
columnAliases?: string[];
metadataTarget?: SqlSemanticMetadataTarget;
unresolved?: boolean;
/** CTE name token span — only populated on `cte:` definition row sources. */
nameSpan?: SqlSemanticSpan;
/** CTE body parentheses span — only populated on `cte:` definition row sources. */
bodySpan?: SqlSemanticSpan;
/**
* Named output columns with in-body navigation/trace metadata. Populated on `cte:` definitions,
* and — reusing the same shape — on derived tables (`kind: "subquery"`) directly inside a CTE
* body, so lineage can continue through `FROM (SELECT ...) x` to the tables inside it.
*/
cteOutputs?: SqlSemanticCteOutputColumn[];
/** Star projections of the body (CTE definitions and enriched derived tables). */
cteStars?: SqlSemanticCteStar[];
/** Row sources visible inside the body (physical tables + upstream CTEs) — same two owners. */
bodySources?: SqlSemanticRowSource[];
}
export interface SqlSemanticClauseSpans {
+11 -2
View File
@@ -306,8 +306,17 @@ export function mergeSqlObjectNavigationType(left?: SqlObjectNavigationType, rig
return left ?? right;
}
// Aligned with the semantic tokenizer (tokens.ts WORD_START/WORD_PART) so Ctrl/Cmd+click,
// hover range extraction and Unicode identifiers (e.g. CJK names) agree on identifier chars.
const IDENTIFIER_START_CHAR = /^[A-Za-z_@$#\p{ID_Start}]$/u;
const IDENTIFIER_PART_CHAR = /^[A-Za-z0-9_@$#\p{ID_Continue}]$/u;
function isIdentifierStartChar(char: string | undefined): boolean {
return !!char && IDENTIFIER_START_CHAR.test(char);
}
function isIdentifierChar(char: string | undefined): boolean {
return !!char && /^[A-Za-z0-9_$]$/.test(char);
return !!char && IDENTIFIER_PART_CHAR.test(char);
}
function readQuotedPart(text: string, start: number): IdentifierPart | null {
@@ -332,7 +341,7 @@ function readQuotedPart(text: string, start: number): IdentifierPart | null {
}
function readUnquotedPart(text: string, start: number): IdentifierPart | null {
if (!isIdentifierChar(text[start])) return null;
if (!isIdentifierStartChar(text[start])) return null;
let end = start + 1;
while (end < text.length && isIdentifierChar(text[end])) end += 1;
return { value: text.slice(start, end), start, end, quoted: false };