feat(editor): semantic column navigation for CTE columns
This commit is contained in:
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);
|
||||
|
||||
+179
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user