mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
feat(editor): highlight sql table aliases and references
This commit is contained in:
@@ -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<null>();
|
||||
buildSqlSemanticHighlightExtension = () => [
|
||||
createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }),
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: import("@codemirror/view").DecorationSet;
|
||||
|
||||
@@ -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<string, SqlAliasHighlightGroup> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<null>();
|
||||
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<typeof setTimeout> | 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)",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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<string, SqlAliasHighlightGroup | null>;
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user