diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index a99d96154f..b25390b0f4 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -133,6 +133,7 @@ import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; +import { createSqlAliasHighlights } from "@/lib/editor/codemirrorSqlAliasHighlights"; import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints"; import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; import { focusEditorView } from "@/lib/editor/queryEditorFocus"; @@ -6059,6 +6060,7 @@ onMounted(async () => { const SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS = 100; const refreshSqlSemanticHighlightEffect = StateEffect.define(); buildSqlSemanticHighlightExtension = () => [ + createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }), ViewPlugin.fromClass( class { decorations: import("@codemirror/view").DecorationSet; diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/aliasHighlights.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/aliasHighlights.spec.ts new file mode 100644 index 0000000000..83f960f193 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/semantic/aliasHighlights.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { sqlAliasHighlightGroups, type SqlAliasHighlightGroup } from "@/lib/sql/semantic/aliasHighlights"; + +function spanTexts(sql: string, spans: Array<{ start: number; end: number }>): string[] { + return spans.map((span) => sql.slice(span.start, span.end)); +} + +function groupByDeclaration(sql: string, groups: SqlAliasHighlightGroup[]): Map { + return new Map(groups.map((group) => [sql.slice(group.declaration.start, group.declaration.end), group])); +} + +describe("sqlAliasHighlightGroups", () => { + it("highlights an alias declaration, qualifiers, and qualified references", () => { + const sql = "SELECT u.id, o.amount FROM users u JOIN orders o ON o.user_id = u.id"; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql)); + + expect(spanTexts(sql, [groups.get("u")!.declaration])).toEqual(["u"]); + expect(spanTexts(sql, groups.get("u")!.qualifiers)).toEqual(["u", "u"]); + expect(spanTexts(sql, groups.get("u")!.references)).toEqual(["u.id", "u.id"]); + expect(spanTexts(sql, groups.get("o")!.qualifiers)).toEqual(["o", "o"]); + expect(spanTexts(sql, groups.get("o")!.references)).toEqual(["o.amount", "o.user_id"]); + }); + + it("keeps a star column inside the reference span", () => { + const sql = "SELECT u.* FROM users u"; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql)); + + expect(spanTexts(sql, groups.get("u")!.references)).toEqual(["u.*"]); + }); + + it("still returns an unreferenced alias so its declaration can highlight itself", () => { + const sql = "SELECT id FROM users u"; + const groups = sqlAliasHighlightGroups(sql); + + expect(groups).toHaveLength(1); + expect(spanTexts(sql, [groups[0].declaration])).toEqual(["u"]); + expect(groups[0].qualifiers).toEqual([]); + expect(groups[0].references).toEqual([]); + }); + + it("mixes referenced and unreferenced aliases without dropping either", () => { + const sql = "SELECT u.id FROM users u, audit_log a"; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql)); + + expect(spanTexts(sql, groups.get("u")!.references)).toEqual(["u.id"]); + expect(groups.get("a")!.references).toEqual([]); + }); + + it("drops duplicate alias declarations that would produce no spans at all", () => { + const sql = "SELECT u.id FROM users u, logs u"; + const groups = sqlAliasHighlightGroups(sql); + + expect(groups).toEqual([]); + }); + + it("does not mistake schema.table.column or table function calls for alias references", () => { + const sql = "SELECT db.users.id FROM db.users"; + const groups = sqlAliasHighlightGroups(sql); + + expect(groups).toEqual([]); + }); + + it("resolves inner alias references to the shadowing subquery block", () => { + const sql = "SELECT * FROM users u WHERE u.id IN (SELECT ur.role FROM user_roles ur)"; + const groups = sqlAliasHighlightGroups(sql); + + expect(groups).toHaveLength(2); + expect( + spanTexts( + sql, + groups.map((group) => group.declaration), + ), + ).toEqual(["u", "ur"]); + expect(spanTexts(sql, groups[0].references)).toEqual(["u.id"]); + expect(spanTexts(sql, groups[1].references)).toEqual(["ur.role"]); + }); + + it("shadows an outer alias reused as the inner subquery alias", () => { + const sql = "SELECT * FROM users u WHERE u.id IN (SELECT u.role FROM user_roles u)"; + const groups = sqlAliasHighlightGroups(sql); + + expect(groups).toHaveLength(2); + expect( + spanTexts( + sql, + groups.map((group) => group.declaration), + ), + ).toEqual(["u", "u"]); + expect(spanTexts(sql, groups[0].references)).toEqual(["u.id"]); + expect(spanTexts(sql, groups[1].references)).toEqual(["u.role"]); + }); + + it("scopes grouped join sources and their group alias separately", () => { + const sql = "SELECT g.id FROM (users a JOIN orders b ON a.user_id = b.id) g WHERE g.amount > 0"; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql)); + + expect(spanTexts(sql, groups.get("a")!.references)).toEqual(["a.user_id"]); + expect(spanTexts(sql, groups.get("b")!.references)).toEqual(["b.id"]); + expect(spanTexts(sql, groups.get("g")!.qualifiers)).toEqual(["g", "g"]); + expect(spanTexts(sql, groups.get("g")!.references)).toEqual(["g.id", "g.amount"]); + }); + + it("keeps an unaliased parenthesized join transparent to the enclosing scope", () => { + const sql = "SELECT a.id FROM (users a JOIN orders b ON a.user_id = b.id)"; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql)); + + expect(spanTexts(sql, groups.get("a")!.references)).toEqual(["a.id", "a.user_id"]); + expect(spanTexts(sql, groups.get("b")!.references)).toEqual(["b.id"]); + }); + + it.each([ + ["unbalanced open parenthesis", "SELECT * FROM (SELECT * FROM users u"], + ["stray close parenthesis", "SELECT * FROM users u) WHERE u.id > 0"], + ["unclosed string literal", "SELECT u.id FROM users u WHERE u.name = 'oops"], + ["unclosed mysql backtick identifier", "SELECT `e`.id FROM users `e"], + ["truncated before alias", "SELECT u.id FROM users"], + ["empty statement", ""], + ] as const)("returns no groups for a %s", (_label, sql) => { + expect(sqlAliasHighlightGroups(sql, { databaseType: "mysql" })).toEqual([]); + }); + + it("matches mysql backtick-quoted aliases and references", () => { + const sql = "SELECT `e`.id FROM `analytics`.`events` `e`"; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql, { databaseType: "mysql" })); + + expect(spanTexts(sql, [groups.get("`e`")!.declaration])).toEqual(["`e`"]); + expect(spanTexts(sql, groups.get("`e`")!.references)).toEqual(["`e`.id"]); + }); + + it("treats mysql double quotes as strings, not alias qualifiers", () => { + const sql = 'SELECT "e".id FROM users e'; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql, { databaseType: "mysql" })); + + expect(groups.get("e")!.references).toEqual([]); + }); + + it("matches sqlite double-quoted aliases and references", () => { + const sql = 'SELECT "u".id FROM users AS "u"'; + const groups = groupByDeclaration(sql, sqlAliasHighlightGroups(sql, { databaseType: "sqlite" })); + + expect(spanTexts(sql, [groups.get('"u"')!.declaration])).toEqual(['"u"']); + expect(spanTexts(sql, groups.get('"u"')!.references)).toEqual(['"u".id']); + }); + + it.each([ + ["mysql unquoted alias", { databaseType: "mysql" }, "SELECT u.id FROM users U", true], + ["mysql backtick alias with different case", { databaseType: "mysql" }, "SELECT `e`.id FROM users `E`", true], + ["sql server unquoted alias", { databaseType: "sqlserver" }, "SELECT u.id FROM users U", true], + ["sql server bracket alias", { databaseType: "sqlserver" }, "SELECT [u].id FROM users [U]", true], + ["sqlite ascii fold", { databaseType: "sqlite" }, "SELECT U.id FROM users u", true], + ["postgres folds unquoted identifiers", { databaseType: "postgres" }, "SELECT u.id FROM users U", true], + ["postgres quoted alias keeps its case", { databaseType: "postgres" }, 'SELECT u.id FROM users "U"', false], + ["oracle folds unquoted identifiers up", { databaseType: "oracle" }, "SELECT U.ID FROM users u", true], + ["oracle quoted alias keeps its case", { databaseType: "oracle" }, 'SELECT u.id FROM users "u"', false], + ["generic dialect stays case-sensitive", {}, "SELECT u.id FROM users U", false], + ] as const)("resolves %s case handling", (_label, options, sql, matches) => { + const groups = sqlAliasHighlightGroups(sql, options); + const withReferences = groups.filter((group) => group.references.length > 0); + + expect(withReferences).toHaveLength(matches ? 1 : 0); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorSqlAliasHighlights.ts b/apps/desktop/src/lib/editor/codemirrorSqlAliasHighlights.ts new file mode 100644 index 0000000000..ee70b73605 --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorSqlAliasHighlights.ts @@ -0,0 +1,96 @@ +import { StateEffect, type Extension, type Text } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { MAX_ALIAS_HIGHLIGHT_SQL_LENGTH, sqlAliasHighlightGroups, type SqlAliasHighlightGroup } from "@/lib/sql/semantic/aliasHighlights"; +import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; +import { resolveSqlStatementWindow, resolveStatementWindowFromSyntaxTree } from "@/lib/sql/sqlSyntaxTreeWindow"; +import type { SqlSemanticBuildOptions } from "@/lib/sql/semantic/types"; + +const refreshAliasHighlights = StateEffect.define(); +const aliasMark = Decoration.mark({ class: "cm-sqlAliasHighlight" }); + +export function createSqlAliasHighlights(options: SqlSemanticBuildOptions & { enabled: boolean }): Extension { + if (!options.enabled) return []; + return [ + ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none; + private timer: ReturnType | null = null; + private doc: Text | null = null; + private sql = ""; + private window: { from: number; to: number } | null = null; + private groups: SqlAliasHighlightGroup[] = []; + + constructor(view: EditorView) { + this.schedule(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet || update.focusChanged) { + this.decorations = Decoration.none; + this.schedule(update.view); + } else if (update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshAliasHighlights)))) { + this.render(update.view); + } else if (update.viewportChanged) { + this.render(update.view); + } + } + + destroy() { + if (this.timer !== null) clearTimeout(this.timer); + } + + private schedule(view: EditorView) { + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + if (!view.hasFocus || view.state.selection.ranges.length !== 1 || !view.state.selection.main.empty) return; + this.timer = setTimeout(() => { + this.timer = null; + view.dispatch({ effects: refreshAliasHighlights.of(null) }); + }, 100); + } + + private render(view: EditorView) { + const { state } = view; + this.decorations = Decoration.none; + if (this.timer !== null || !view.hasFocus || state.selection.ranges.length !== 1 || !state.selection.main.empty) return; + const cursor = state.selection.main.head; + if (this.doc !== state.doc) { + this.doc = state.doc; + this.sql = ""; + this.window = null; + } + if (!this.window || cursor < this.window.from || cursor >= this.window.to) { + let window = resolveStatementWindowFromSyntaxTree(state, cursor); + if (!window) { + // Avoid flattening huge documents while the incremental parser is still catching up. + if (state.doc.length > MAX_ALIAS_HIGHLIGHT_SQL_LENGTH) return; + this.sql ||= state.doc.toString(); + window = resolveSqlStatementWindow(this.sql, cursor, state, sqlSemanticDialectFor(options).id); + } + this.window = window; + this.groups = window.to - window.from <= MAX_ALIAS_HIGHLIGHT_SQL_LENGTH ? sqlAliasHighlightGroups(state.doc.sliceString(window.from, window.to), options) : []; + } + const offset = this.window.from; + const localCursor = cursor - offset; + const containsCursor = (span: { start: number; end: number }) => span.start <= localCursor && localCursor <= span.end; + const group = this.groups.find((candidate) => containsCursor(candidate.declaration) || candidate.qualifiers.some(containsCursor)); + if (!group) return; + const ranges = [group.declaration, ...group.references].filter((span) => view.visibleRanges.some((visible) => span.end + offset > visible.from && span.start + offset < visible.to)).map((span) => aliasMark.range(span.start + offset, span.end + offset)); + this.decorations = Decoration.set(ranges, true); + } + }, + { decorations: (plugin) => plugin.decorations }, + ), + EditorView.baseTheme({ + ".cm-sqlAliasHighlight": { + backgroundColor: "rgb(34 139 74 / 0.16)", + boxShadow: "inset 0 0 0 1px rgb(34 139 74 / 0.28)", + borderRadius: "2px", + }, + "&dark .cm-sqlAliasHighlight": { + backgroundColor: "rgb(110 231 183 / 0.18)", + boxShadow: "inset 0 0 0 1px rgb(110 231 183 / 0.32)", + }, + }), + ]; +} diff --git a/apps/desktop/src/lib/sql/semantic/aliasHighlights.ts b/apps/desktop/src/lib/sql/semantic/aliasHighlights.ts new file mode 100644 index 0000000000..bd8d519917 --- /dev/null +++ b/apps/desktop/src/lib/sql/semantic/aliasHighlights.ts @@ -0,0 +1,138 @@ +import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; +import { sqlSemanticQueryBlockSources, type SqlSemanticGroupedSourceScope } from "@/lib/sql/semantic/model"; +import { tokenizeSqlSemantic, tokenIsIdentifier, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens"; +import type { SqlSemanticBuildOptions, SqlSemanticSpan, SqlSemanticToken } from "@/lib/sql/semantic/types"; + +export interface SqlAliasHighlightGroup { + declaration: SqlSemanticSpan; + qualifiers: SqlSemanticSpan[]; + references: SqlSemanticSpan[]; +} + +interface QueryBlock { + start: number; + end: number; + depth: number; + parent?: QueryBlock; + aliases: Map; + sources: SqlSemanticSpan[]; +} + +const SET_OPERATORS = new Set(["union", "intersect", "except"]); +// MySQL aliases and SQL Server default collations compare identifiers case-insensitively. +const CASE_INSENSITIVE_ALIAS_DIALECTS = new Set(["mysql", "sqlserver", "sqlite"]); +export const MAX_ALIAS_HIGHLIGHT_SQL_LENGTH = 128 * 1024; + +function lastBlock(blocks: QueryBlock[], predicate: (block: QueryBlock) => boolean): QueryBlock | undefined { + for (let index = blocks.length - 1; index >= 0; index--) { + if (predicate(blocks[index])) return blocks[index]; + } + return undefined; +} + +/** Input is a single statement; all returned spans are relative to that statement. */ +export function sqlAliasHighlightGroups(sql: string, options: SqlSemanticBuildOptions = {}): SqlAliasHighlightGroup[] { + if (sql.length > MAX_ALIAS_HIGHLIGHT_SQL_LENGTH) return []; + const dialect = sqlSemanticDialectFor(options); + const allTokens = tokenizeSqlSemantic(sql, dialect.id, { + mysqlDashCommentRequiresWhitespace: dialect.id === "mysql", + mysqlBackslashEscape: dialect.id === "mysql", + mysqlDoubleQuoteIsString: dialect.id === "mysql", + }); + if (allTokens.some((token) => token.closed === false)) return []; + const tokens = allTokens.filter((token) => token.kind !== "comment"); + if (tokens.length > 20_000) return []; + let parentheses = 0; + for (const token of tokens) { + if (token.text === "(") parentheses++; + if (token.text === ")" && --parentheses < 0) return []; + } + if (parentheses !== 0) return []; + const tokensByStart = new Map(tokens.map((token) => [token.span.start, token])); + const nameOf = (token: SqlSemanticToken) => { + const name = dialect.normalizeIdentifier(unquoteSqlSemanticIdentifier(token), token.kind === "quoted_identifier"); + // These dialects compare even quoted identifiers case-insensitively for ASCII letters. + // Keep the spelling normalizer unchanged, and preserve other dialects' quote and case semantics. + return CASE_INSENSITIVE_ALIAS_DIALECTS.has(dialect.id) ? name.replace(/[A-Z]/g, (letter) => letter.toLowerCase()) : name; + }; + const blocks: QueryBlock[] = []; + const groups: SqlAliasHighlightGroup[] = []; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if (token.kind !== "word" || token.normalized !== "select") continue; + if (blocks.length >= 128) return []; + let end = index + 1; + while (end < tokens.length) { + const next = tokens[end]; + if (next.depth < token.depth || next.text === ";" || (next.depth === token.depth && next.kind === "word" && SET_OPERATORS.has(next.normalized))) break; + end++; + } + const groupedScopes: SqlSemanticGroupedSourceScope[] = []; + const sources = sqlSemanticQueryBlockSources(tokens.slice(index, end), options, groupedScopes); + const scopes = [ + { start: index, end, depth: token.depth, sources }, + ...groupedScopes.map((scope) => ({ + start: tokens.findIndex((candidate) => candidate.span.start >= scope.span.start), + end: tokens.findIndex((candidate) => candidate.span.start >= scope.span.end), + depth: scope.depth, + sources: scope.sources, + })), + ]; + for (const scope of scopes) { + if (scope.start < 0 || scope.end <= scope.start) continue; + if (blocks.length >= 128) return []; + const block: QueryBlock = { start: scope.start, end: scope.end, depth: scope.depth, aliases: new Map(), sources: [] }; + for (const source of scope.sources) { + // Only exclude the source name itself; table-function arguments can reference aliases. + if (source.qualifiedName) block.sources.push(source.qualifiedName.span); + if (!source.aliasSpan) { + // An unaliased local table also hides an outer alias with the same name. + const parts = source.qualifiedName?.parts; + const localName = parts?.[parts.length - 1]; + const localToken = localName && tokensByStart.get(localName.span.start); + if (localToken) block.aliases.set(nameOf(localToken), null); + continue; + } + const declaration = tokensByStart.get(source.aliasSpan.start); + if (!declaration) continue; + const key = nameOf(declaration); + const group: SqlAliasHighlightGroup = { declaration: source.aliasSpan, qualifiers: [], references: [] }; + if (block.aliases.has(key)) block.aliases.set(key, null); + else block.aliases.set(key, group); + } + blocks.push(block); + } + } + + // Group scopes can be discovered before their nested SELECTs. Establish containment only + // after collecting all scopes so inner names never leak into the enclosing query. + blocks.sort((left, right) => left.start - right.start || right.end - left.end); + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index]; + block.parent = lastBlock(blocks.slice(0, index), (candidate) => candidate.depth < block.depth && candidate.start <= block.start && candidate.end >= block.end); + } + + for (let index = 0; index + 2 < tokens.length; index++) { + const qualifier = tokens[index]; + const column = tokens[index + 2]; + if (!tokenIsIdentifier(qualifier) || tokens[index + 1].text !== "." || (column.text !== "*" && !tokenIsIdentifier(column))) continue; + // Do not mistake schema.table.column or a qualified function call for alias.column. + if (tokens[index - 1]?.text === "." || tokens[index + 3]?.text === "." || tokens[index + 3]?.text === "(") continue; + const block = lastBlock(blocks, (candidate) => candidate.start <= index && index < candidate.end); + if (!block || block.sources.some((span) => span.start <= qualifier.span.start && column.span.end <= span.end)) continue; + const key = nameOf(qualifier); + let owner: QueryBlock | undefined = block; + while (owner && !owner.aliases.has(key)) owner = owner.parent; + const group = owner?.aliases.get(key); + if (!group) continue; + group.qualifiers.push(qualifier.span); + group.references.push({ start: qualifier.span.start, end: column.span.end }); + } + for (const block of blocks) { + // Keep unreferenced declarations too: the caller still highlights the declaration itself + // when the cursor sits on it. Only shadowed (null) entries produce no spans at all. + for (const group of block.aliases.values()) if (group) groups.push(group); + } + return groups; +} diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index 816c2661fb..408f84707e 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -34,6 +34,14 @@ interface ParseState { tokens: SqlSemanticToken[]; statement: SqlSemanticStatement; cteSources: SqlSemanticRowSource[]; + expandGroupedSources?: boolean; + groupedSourceScopes?: SqlSemanticGroupedSourceScope[]; +} + +export interface SqlSemanticGroupedSourceScope { + span: SqlSemanticSpan; + depth: number; + sources: SqlSemanticRowSource[]; } interface QuerySourceRange { @@ -437,6 +445,25 @@ function parseRowSource(state: ParseState, target: number, introducer: string, s return parseTableFunctionSource(state, target, introducer, sourceIndex) ?? parseTableSource(state, target, introducer, sourceIndex); } +function parseRowSourceList(state: ParseState, target: number, introducer: string, sourceIndex: number): { sources: SqlSemanticRowSource[]; nextIndex: number } | null { + const open = state.tokens[target]; + if (state.expandGroupedSources && open?.text === "(" && open.depth < 128) { + const close = findMatchingParenToken(state.tokens, target); + const first = state.tokens[target + 1]; + const isQuery = first?.kind === "word" && (first.normalized === "select" || first.normalized === "with"); + if (close >= 0 && !isQuery) { + // An unaliased parenthesized join is transparent to the surrounding query scope. + // A SELECT body or an explicitly aliased group must retain its own boundary. + const from: SqlSemanticToken = { ...open, kind: "word", text: "from", normalized: "from", depth: open.depth + 1 }; + const sources = parseRowSourcesAtDepth({ ...state, tokens: [from, ...state.tokens.slice(target + 1, close)] }, from.depth, sourceIndex); + if (!aliasAfter(state.tokens, close + 1, state.dialect).alias) return { sources, nextIndex: close + 1 }; + state.groupedSourceScopes?.push({ span: { start: open.span.end, end: state.tokens[close].span.start }, depth: from.depth, sources }); + } + } + const parsed = parseRowSource(state, target, introducer, sourceIndex); + return parsed ? { sources: [parsed.source], nextIndex: parsed.nextIndex } : null; +} + function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIndexOffset = 0): SqlSemanticRowSource[] { const sources: SqlSemanticRowSource[] = []; let inSelectFromClause = false; @@ -449,9 +476,9 @@ function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIn else if (inSelectFromClause && FROM_CLAUSE_BOUNDARIES.has(item.normalized)) inSelectFromClause = false; } if (inSelectFromClause && item.text === ",") { - const parsed = parseRowSource(state, index + 1, "from", sourceIndexOffset + sources.length); + const parsed = parseRowSourceList(state, index + 1, "from", sourceIndexOffset + sources.length); if (parsed) { - sources.push(parsed.source); + sources.push(...parsed.sources); index = parsed.nextIndex - 1; } continue; @@ -465,9 +492,9 @@ function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIn while (JOIN_MODIFIERS.has(state.tokens[target]?.normalized ?? "")) target += 1; target = sqlServerMaintenanceTableTarget(state.tokens, target, normalized, state.dialect); for (;;) { - const parsed = parseRowSource(state, target, normalized, sourceIndexOffset + sources.length); + const parsed = parseRowSourceList(state, target, normalized, sourceIndexOffset + sources.length); if (!parsed) break; - sources.push(parsed.source); + sources.push(...parsed.sources); index = parsed.nextIndex - 1; const separator = state.tokens[parsed.nextIndex]; @@ -478,6 +505,17 @@ function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIn return dedupeSources(sources); } +/** Parse declarations in one query block without merging outer or sibling sources. */ +export function sqlSemanticQueryBlockSources(tokens: SqlSemanticToken[], options: SqlSemanticBuildOptions = {}, groupedSourceScopes?: SqlSemanticGroupedSourceScope[]): SqlSemanticRowSource[] { + if (!tokens.length) return []; + const statement: SqlSemanticStatement = { + kind: statementKind(tokens), + span: { start: tokens[0].span.start, end: tokens[tokens.length - 1].span.end }, + text: "", + }; + return parseRowSourcesAtDepth({ dialect: sqlSemanticDialectFor(options), tokens, statement, cteSources: [], expandGroupedSources: true, groupedSourceScopes }, tokens[0].depth); +} + function querySourceRanges(tokens: readonly SqlSemanticToken[], cursor: number): QuerySourceRange[] { const rootDepth = tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY); const fallbackDepth = Number.isFinite(rootDepth) ? rootDepth : 0;