1109 lines
53 KiB
TypeScript
1109 lines
53 KiB
TypeScript
import { sqlSemanticDialectFor, type SqlSemanticDialectAdapter } from "@/lib/sql/semantic/dialect";
|
|
import { findActiveSqlStatementSpan, isSuppressedSqlSemanticContext, tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
|
|
import { resolveSqlStatementWindow } from "@/lib/sql/sqlSyntaxTreeWindow";
|
|
import type {
|
|
SqlSemanticBuildOptions,
|
|
SqlSemanticClauseSpans,
|
|
SqlSemanticCteOutputColumn,
|
|
SqlSemanticCteStar,
|
|
SqlSemanticCursorIntent,
|
|
SqlSemanticIdentifierPart,
|
|
SqlSemanticModel,
|
|
SqlSemanticProjection,
|
|
SqlSemanticQualifiedName,
|
|
SqlSemanticRowSource,
|
|
SqlSemanticScope,
|
|
SqlSemanticSpan,
|
|
SqlSemanticStatement,
|
|
SqlSemanticStatementKind,
|
|
SqlSemanticToken,
|
|
} from "@/lib/sql/semantic/types";
|
|
|
|
const TABLE_INTRODUCERS = new Set(["from", "join", "straight_join", "update", "into", "using", "apply"]);
|
|
const TABLE_FUNCTION_NAMES = new Set(["table", "xmltable", "json_table", "the", "read_csv", "read_parquet", "read_json", "unnest"]);
|
|
const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]);
|
|
const CLAUSE_BOUNDARIES = new Set(["where", "group", "having", "order", "limit", "offset", "union", "intersect", "except", "on", "set", "values", "returning"]);
|
|
const FROM_CLAUSE_BOUNDARIES = new Set([...CLAUSE_BOUNDARIES, "window", "qualify", "fetch", "for", "connect", "start", "model"].filter((item) => item !== "on"));
|
|
const ALIAS_BLACKLIST = new Set([...FROM_CLAUSE_BOUNDARIES, "on", "join", "straight_join", "left", "right", "inner", "outer", "cross", "full", "natural", "as", "select", "from", "with"]);
|
|
const TABLE_TARGET_MODIFIERS = new Set(["lateral", "only"]);
|
|
const TABLE_FUNCTION_INTRODUCERS = new Set(["from", "join", "straight_join", "apply"]);
|
|
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;
|
|
tokens: SqlSemanticToken[];
|
|
statement: SqlSemanticStatement;
|
|
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 {
|
|
span: SqlSemanticSpan;
|
|
depth: number;
|
|
sources: SqlSemanticRowSource[];
|
|
}
|
|
|
|
interface QuerySourceRange {
|
|
depth: number;
|
|
startIndex: number;
|
|
endIndex: number;
|
|
}
|
|
|
|
interface TrailingIdentifier {
|
|
prefix: string;
|
|
replacementRange: SqlSemanticSpan;
|
|
qualifierParts: string[];
|
|
}
|
|
|
|
function significantTokens(tokens: readonly SqlSemanticToken[]): SqlSemanticToken[] {
|
|
return tokens.filter((item) => item.kind !== "comment");
|
|
}
|
|
|
|
function tokenTextAt(sql: string, span: SqlSemanticSpan): string {
|
|
return sql.slice(span.start, span.end);
|
|
}
|
|
|
|
function firstWord(tokens: readonly SqlSemanticToken[]): string {
|
|
return tokens.find((item) => item.kind === "word")?.normalized ?? "";
|
|
}
|
|
|
|
function statementKind(tokens: readonly SqlSemanticToken[]): SqlSemanticStatementKind {
|
|
const word = firstWord(tokens);
|
|
if (word === "with" || word === "select") return "select";
|
|
if (word === "insert") return "insert";
|
|
if (word === "update") return "update";
|
|
if (word === "delete") return "delete";
|
|
if (word === "call" || word === "exec" || word === "execute") return "call";
|
|
return "unknown";
|
|
}
|
|
|
|
function identifierPart(tokenValue: SqlSemanticToken, dialect: SqlSemanticDialectAdapter): SqlSemanticIdentifierPart {
|
|
const quoted = tokenValue.kind === "quoted_identifier";
|
|
const raw = unquoteSqlSemanticIdentifier(tokenValue);
|
|
return {
|
|
raw: tokenValue.text,
|
|
name: dialect.normalizeIdentifier(raw, quoted),
|
|
span: tokenValue.span,
|
|
quote: tokenValue.quote,
|
|
};
|
|
}
|
|
|
|
function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: number, dialect: SqlSemanticDialectAdapter): { name: SqlSemanticQualifiedName; nextIndex: number } | null {
|
|
const parts: SqlSemanticIdentifierPart[] = [];
|
|
let index = startIndex;
|
|
while (index < tokens.length) {
|
|
const current = tokens[index];
|
|
if (!tokenIsIdentifier(current)) break;
|
|
parts.push(identifierPart(current, dialect));
|
|
if (tokens[index + 1]?.text !== ".") {
|
|
index += 1;
|
|
break;
|
|
}
|
|
index += 2;
|
|
if (dialect.id === "sqlserver" && tokens[index]?.text === ".") {
|
|
const omittedSchema = tokens[index];
|
|
parts.push({ raw: "", name: SQLSERVER_DEFAULT_SCHEMA, span: omittedSchema.span });
|
|
while (tokens[index]?.text === ".") index += 1;
|
|
}
|
|
if (!tokenIsIdentifier(tokens[index])) return null;
|
|
}
|
|
if (parts.length === 0) return null;
|
|
return {
|
|
name: {
|
|
parts,
|
|
span: { start: parts[0]?.span.start ?? tokens[startIndex]?.span.start ?? 0, end: parts[parts.length - 1]?.span.end ?? tokens[startIndex]?.span.end ?? 0 },
|
|
},
|
|
nextIndex: index,
|
|
};
|
|
}
|
|
|
|
function sqlServerMaintenanceTableTarget(tokens: readonly SqlSemanticToken[], target: number, introducer: string, dialect: SqlSemanticDialectAdapter): number {
|
|
if (dialect.id !== "sqlserver" || introducer !== "update") return target;
|
|
if (tokens[target]?.normalized === "statistics") return target + 1;
|
|
// ASE accepts UPDATE {ALL | INDEX | TABLE} STATISTICS; these scope words
|
|
// describe the maintenance operation and must never become table targets.
|
|
if (SQLSERVER_UPDATE_STATISTICS_SCOPES.has(tokens[target]?.normalized ?? "") && tokens[target + 1]?.normalized === "statistics") return target + 2;
|
|
return target;
|
|
}
|
|
|
|
function updateIntroducesMutationTarget(tokens: readonly SqlSemanticToken[], updateIndex: number): boolean {
|
|
const update = tokens[updateIndex];
|
|
if (update?.kind !== "word" || update.normalized !== "update") return false;
|
|
for (let index = updateIndex - 1; index >= 0; index -= 1) {
|
|
const item = tokens[index];
|
|
if (!item || item.depth !== update.depth) continue;
|
|
if (item.text === ";") break;
|
|
if (item.kind === "word" && (item.normalized === "update" || TOP_LEVEL_STATEMENT_WORDS.has(item.normalized))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Finds concrete table-name tokens for visual highlighting without consulting
|
|
* metadata. Only the final identifier in a qualified name is returned, so
|
|
* schemas/catalogs and aliases keep the regular identifier color.
|
|
*/
|
|
export function sqlSemanticTableNameSpans(sql: string, options: SqlSemanticBuildOptions = {}): SqlSemanticSpan[] {
|
|
const dialect = sqlSemanticDialectFor(options);
|
|
const tokens = significantTokens(tokenizeSqlSemantic(sql, dialect.id));
|
|
const spans: SqlSemanticSpan[] = [];
|
|
const seen = new Set<string>();
|
|
const commaContinuesTableListByDepth = new Map<number, boolean>();
|
|
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const item = tokens[index];
|
|
if (!item) continue;
|
|
const commaContinuesTableList = item.text === "," && commaContinuesTableListByDepth.get(item.depth) === true;
|
|
if (item.kind === "word") {
|
|
if (item.normalized === "from") commaContinuesTableListByDepth.set(item.depth, true);
|
|
else if (item.normalized === "select" || item.normalized === "join" || TABLE_INTRODUCERS.has(item.normalized) || CLAUSE_BOUNDARIES.has(item.normalized)) commaContinuesTableListByDepth.set(item.depth, false);
|
|
}
|
|
const introduced = item?.kind === "word" && TABLE_INTRODUCERS.has(item.normalized) && (item.normalized !== "update" || updateIntroducesMutationTarget(tokens, index));
|
|
if (!introduced && !commaContinuesTableList) continue;
|
|
|
|
let target = index + 1;
|
|
while (TABLE_TARGET_MODIFIERS.has(tokens[target]?.normalized ?? "")) target += 1;
|
|
target = sqlServerMaintenanceTableTarget(tokens, target, item?.normalized ?? "", dialect);
|
|
if (tokens[target]?.text === "(") continue;
|
|
|
|
const qualified = readQualifiedName(tokens, target, dialect);
|
|
const followedByParenthesis = qualified && tokens[qualified.nextIndex]?.text === "(";
|
|
if (!qualified || tokens[target]?.depth !== item?.depth || (followedByParenthesis && (!introduced || TABLE_FUNCTION_INTRODUCERS.has(item.normalized)))) continue;
|
|
const tablePart = qualified.name.parts[qualified.name.parts.length - 1];
|
|
if (!tablePart) continue;
|
|
const key = `${tablePart.span.start}:${tablePart.span.end}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
spans.push(tablePart.span);
|
|
}
|
|
|
|
return spans;
|
|
}
|
|
|
|
function sourceNameFromQualifiedName(name: SqlSemanticQualifiedName): { name: string; qualifierParts: string[] } {
|
|
const parts = name.parts.map((part) => part.name);
|
|
return {
|
|
name: parts[parts.length - 1] ?? "",
|
|
qualifierParts: parts.slice(0, -1),
|
|
};
|
|
}
|
|
|
|
function findMatchingParenToken(tokens: readonly SqlSemanticToken[], openIndex: number): number {
|
|
if (tokens[openIndex]?.text !== "(") return -1;
|
|
const startDepth = tokens[openIndex]?.depth ?? 0;
|
|
for (let index = openIndex + 1; index < tokens.length; index += 1) {
|
|
const item = tokens[index];
|
|
if (item?.text === ")" && item.depth === startDepth) return index;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function splitTopLevelByComma(tokens: readonly SqlSemanticToken[]): SqlSemanticToken[][] {
|
|
const groups: SqlSemanticToken[][] = [];
|
|
let current: SqlSemanticToken[] = [];
|
|
const baseDepth = tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY);
|
|
for (const item of tokens) {
|
|
if (item.text === "," && item.depth === baseDepth) {
|
|
groups.push(current);
|
|
current = [];
|
|
} else {
|
|
current.push(item);
|
|
}
|
|
}
|
|
if (current.length > 0) groups.push(current);
|
|
return groups;
|
|
}
|
|
|
|
function projectionNameFromTokens(tokens: readonly SqlSemanticToken[], dialect: SqlSemanticDialectAdapter): SqlSemanticProjection | null {
|
|
const useful = tokens.filter((item) => item.kind !== "comment");
|
|
if (useful.length === 0) return null;
|
|
let asIndex = -1;
|
|
for (let index = useful.length - 1; index >= 0; index -= 1) {
|
|
if (useful[index]?.kind === "word" && useful[index]?.normalized === "as") {
|
|
asIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
const aliasToken = asIndex >= 0 ? useful[asIndex + 1] : undefined;
|
|
if (tokenIsIdentifier(aliasToken)) {
|
|
const name = identifierPart(aliasToken, dialect).name;
|
|
return {
|
|
name,
|
|
alias: name,
|
|
aliasSpan: aliasToken.span,
|
|
sourceExpression: useful.map((item) => item.text).join(" "),
|
|
span: { start: useful[0]?.span.start ?? 0, end: useful[useful.length - 1]?.span.end ?? 0 },
|
|
};
|
|
}
|
|
const lastIdentifier = [...useful].reverse().find(tokenIsIdentifier);
|
|
if (!lastIdentifier) return null;
|
|
const name = identifierPart(lastIdentifier, dialect).name;
|
|
return {
|
|
name,
|
|
sourceExpression: useful.map((item) => item.text).join(" "),
|
|
span: { start: useful[0]?.span.start ?? 0, end: useful[useful.length - 1]?.span.end ?? 0 },
|
|
};
|
|
}
|
|
|
|
function parseSelectProjections(tokens: readonly SqlSemanticToken[], dialect: SqlSemanticDialectAdapter): SqlSemanticProjection[] {
|
|
const baseDepth = tokens.reduce((min, item) => Math.min(min, item.depth), Number.POSITIVE_INFINITY);
|
|
const selectDepth = Number.isFinite(baseDepth) ? baseDepth : 0;
|
|
const selectIndex = tokens.findIndex((item) => item.depth === selectDepth && item.kind === "word" && item.normalized === "select");
|
|
if (selectIndex < 0) return [];
|
|
let fromIndex = tokens.findIndex((item, index) => index > selectIndex && item.depth === selectDepth && item.kind === "word" && item.normalized === "from");
|
|
if (fromIndex < 0) fromIndex = tokens.length;
|
|
const projectionTokens = tokens.slice(selectIndex + 1, fromIndex);
|
|
return splitTopLevelByComma(projectionTokens)
|
|
.map((group) => projectionNameFromTokens(group, dialect))
|
|
.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");
|
|
if (first < 0) return [];
|
|
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;
|
|
const nameToken = tokens[index];
|
|
if (!tokenIsIdentifier(nameToken)) break;
|
|
const namePart = identifierPart(nameToken, state.dialect);
|
|
index += 1;
|
|
|
|
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({ name: identifierPart(identifier, state.dialect).name, span: identifier.span });
|
|
}
|
|
index = close + 1;
|
|
}
|
|
}
|
|
|
|
if (tokens[index]?.normalized === "as") index += 1;
|
|
if (tokens[index]?.text !== "(") break;
|
|
const bodyOpen = index;
|
|
const bodyClose = findMatchingParenToken(tokens, bodyOpen);
|
|
const safeBodyClose = bodyClose < 0 ? tokens.length - 1 : bodyClose;
|
|
const bodyTokens = tokens.slice(bodyOpen + 1, safeBodyClose);
|
|
|
|
// 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",
|
|
name: namePart.name,
|
|
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;
|
|
|
|
if (tokens[index]?.text !== ",") break;
|
|
index += 1;
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
function correlationColumnsAfter(tokens: readonly SqlSemanticToken[], index: number, dialect: SqlSemanticDialectAdapter): { columns: string[]; nextIndex: number } | null {
|
|
if (tokens[index]?.text !== "(") return null;
|
|
const close = findMatchingParenToken(tokens, index);
|
|
if (close < 0) return null;
|
|
const columns = splitTopLevelByComma(tokens.slice(index + 1, close))
|
|
.map((group) => group.find(tokenIsIdentifier))
|
|
.filter((item): item is SqlSemanticToken => item != null)
|
|
.map((item) => identifierPart(item, dialect).name);
|
|
return { columns, nextIndex: close + 1 };
|
|
}
|
|
|
|
function aliasAfter(tokens: readonly SqlSemanticToken[], index: number, dialect: SqlSemanticDialectAdapter, options: { allowCorrelationColumns?: boolean } = {}): { alias?: string; aliasSpan?: SqlSemanticSpan; columns?: string[]; nextIndex: number } {
|
|
let cursor = index;
|
|
if (tokens[cursor]?.kind === "word" && tokens[cursor]?.normalized === "as") cursor += 1;
|
|
const aliasToken = tokens[cursor];
|
|
if (tokenIsIdentifier(aliasToken)) {
|
|
const alias = identifierPart(aliasToken, dialect).name;
|
|
if (!ALIAS_BLACKLIST.has(alias.toLowerCase())) {
|
|
const columns = options.allowCorrelationColumns === false ? null : correlationColumnsAfter(tokens, cursor + 1, dialect);
|
|
return { alias, aliasSpan: aliasToken.span, columns: columns?.columns, nextIndex: columns?.nextIndex ?? cursor + 1 };
|
|
}
|
|
}
|
|
return { nextIndex: index };
|
|
}
|
|
|
|
function mergeColumnAliases(columns: readonly string[], aliases: readonly string[] | undefined): string[] {
|
|
if (!aliases?.length) return [...columns];
|
|
if (columns.length === 0) return [...aliases];
|
|
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;
|
|
if (tokens[openIndex]?.text !== "(") return index;
|
|
const close = findMatchingParenToken(tokens, openIndex);
|
|
return close < 0 ? index : close + 1;
|
|
}
|
|
|
|
function parseSubquerySource(state: ParseState, openIndex: number, introducer: string, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
|
const close = findMatchingParenToken(state.tokens, openIndex);
|
|
if (close < 0) return null;
|
|
const alias = aliasAfter(state.tokens, close + 1, state.dialect);
|
|
if (!alias.alias) return null;
|
|
const bodyTokens = state.tokens.slice(openIndex + 1, close);
|
|
const columns = mergeColumnAliases(
|
|
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}`,
|
|
kind: "subquery",
|
|
name: alias.alias,
|
|
qualifierParts: [],
|
|
alias: alias.alias,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function parseTableFunctionSource(state: ParseState, nameIndex: number, introducer: string, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
|
const isMutationTarget = introducer === "update" || introducer === "into" || (state.statement.kind === "delete" && introducer === "from");
|
|
if (isMutationTarget) return null;
|
|
const qualified = readQualifiedName(state.tokens, nameIndex, state.dialect);
|
|
if (!qualified || state.tokens[qualified.nextIndex]?.text !== "(") return null;
|
|
const { name, qualifierParts } = sourceNameFromQualifiedName(qualified.name);
|
|
if (state.dialect.id !== "postgres" && !TABLE_FUNCTION_NAMES.has(name.toLowerCase())) return null;
|
|
const close = findMatchingParenToken(state.tokens, qualified.nextIndex);
|
|
const safeClose = close < 0 ? qualified.nextIndex : close;
|
|
let aliasIndex = safeClose + 1;
|
|
if (state.dialect.id === "postgres" && state.tokens[aliasIndex]?.normalized === "with" && state.tokens[aliasIndex + 1]?.normalized === "ordinality") aliasIndex += 2;
|
|
const alias = aliasAfter(state.tokens, aliasIndex, state.dialect);
|
|
const sourceName = alias.alias ?? name;
|
|
return {
|
|
source: {
|
|
id: `${introducer}:table_function:${sourceIndex}`,
|
|
kind: "table_function",
|
|
name: sourceName,
|
|
qualifiedName: qualified.name,
|
|
qualifierParts,
|
|
alias: alias.alias,
|
|
aliasSpan: alias.aliasSpan,
|
|
sourceSpan: { start: qualified.name.span.start, end: state.tokens[alias.nextIndex - 1]?.span.end ?? alias.aliasSpan?.end ?? state.tokens[safeClose]?.span.end ?? qualified.name.span.end },
|
|
columns: alias.columns,
|
|
unresolved: close < 0,
|
|
},
|
|
nextIndex: alias.nextIndex,
|
|
};
|
|
}
|
|
|
|
function parseTableSource(state: ParseState, nameIndex: number, introducer: string, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
|
const qualified = readQualifiedName(state.tokens, nameIndex, state.dialect);
|
|
if (!qualified) return null;
|
|
const { name, qualifierParts } = sourceNameFromQualifiedName(qualified.name);
|
|
const alias = aliasAfter(state.tokens, qualified.nextIndex, state.dialect, { allowCorrelationColumns: state.dialect.id !== "sqlserver" });
|
|
const nextIndex = consumeSqlServerTableHint(state.tokens, alias.nextIndex, state.dialect);
|
|
const cte = state.cteSources.find((source) => source.name.toLowerCase() === name.toLowerCase());
|
|
const kind = cte ? "cte" : introducer === "update" || introducer === "into" || (state.statement.kind === "delete" && introducer === "from") ? "mutation_target" : "table";
|
|
const source: SqlSemanticRowSource = {
|
|
id: `${introducer}:${name}:${sourceIndex}`,
|
|
kind,
|
|
name,
|
|
qualifiedName: qualified.name,
|
|
qualifierParts,
|
|
alias: alias.alias,
|
|
aliasSpan: alias.aliasSpan,
|
|
sourceSpan: { start: qualified.name.span.start, end: state.tokens[nextIndex - 1]?.span.end ?? alias.aliasSpan?.end ?? qualified.name.span.end },
|
|
columns: cte?.columns ? mergeColumnAliases(cte.columns, alias.columns) : undefined,
|
|
columnAliases: alias.columns,
|
|
metadataTarget: {
|
|
database: qualifierParts.length >= 2 ? qualifierParts[qualifierParts.length - 2] : undefined,
|
|
schema: qualifierParts[qualifierParts.length - 1],
|
|
table: name,
|
|
},
|
|
};
|
|
return { source, nextIndex };
|
|
}
|
|
|
|
function isPostgresLateralSource(state: ParseState, target: number, introducer: string): boolean {
|
|
if (state.dialect.id !== "postgres" || state.tokens[target]?.normalized !== "lateral" || (introducer !== "from" && introducer !== "join")) return false;
|
|
const sourceIndex = target + 1;
|
|
if (state.tokens[sourceIndex]?.text === "(") return true;
|
|
const qualified = readQualifiedName(state.tokens, sourceIndex, state.dialect);
|
|
return !!qualified && state.tokens[qualified.nextIndex]?.text === "(";
|
|
}
|
|
|
|
function parseRowSource(state: ParseState, target: number, introducer: string, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
|
if (isPostgresLateralSource(state, target, introducer)) target += 1;
|
|
if (state.tokens[target]?.text === "(") return parseSubquerySource(state, target, introducer, sourceIndex);
|
|
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 parseDorisLateralView(state: ParseState, index: number, sourceIndex: number): { source: SqlSemanticRowSource; nextIndex: number } | null {
|
|
if (state.dialect.id !== "doris" || state.tokens[index]?.normalized !== "lateral" || state.tokens[index + 1]?.normalized !== "view") return null;
|
|
// Doris's `LATERAL VIEW [OUTER] fn(...) alias AS col` -- skip the optional OUTER marker so the
|
|
// OUTER form models the same function columns as the plain form.
|
|
let functionIndex = index + 2;
|
|
if (state.tokens[functionIndex]?.normalized === "outer") functionIndex += 1;
|
|
const functionName = readQualifiedName(state.tokens, functionIndex, state.dialect);
|
|
if (!functionName || state.tokens[functionName.nextIndex]?.text !== "(") return null;
|
|
const close = findMatchingParenToken(state.tokens, functionName.nextIndex);
|
|
if (close < 0) return null;
|
|
let aliasIndex = close + 1;
|
|
if (state.tokens[aliasIndex]?.normalized === "as") aliasIndex += 1;
|
|
const alias = state.tokens[aliasIndex];
|
|
if (!alias || alias.kind !== "word") return null;
|
|
let columnIndex = aliasIndex + 1;
|
|
if (state.tokens[columnIndex]?.normalized === "as") columnIndex += 1;
|
|
const columns: string[] = [];
|
|
while (state.tokens[columnIndex]?.kind === "word") {
|
|
columns.push(state.tokens[columnIndex].text);
|
|
columnIndex += 1;
|
|
if (state.tokens[columnIndex]?.text !== ",") break;
|
|
columnIndex += 1;
|
|
}
|
|
const endToken = state.tokens[Math.max(aliasIndex, columnIndex - 1)] ?? alias;
|
|
const name = alias.text;
|
|
return {
|
|
source: {
|
|
id: `table-function:${sourceIndex}:${name}`,
|
|
kind: "table_function",
|
|
name,
|
|
alias: name,
|
|
qualifierParts: [name],
|
|
qualifiedName: functionName.name,
|
|
sourceSpan: { start: state.tokens[index].span.start, end: endToken.span.end },
|
|
columns: columns.length ? columns : undefined,
|
|
},
|
|
nextIndex: columnIndex,
|
|
};
|
|
}
|
|
|
|
function parseRowSourcesAtDepth(state: ParseState, sourceDepth: number, sourceIndexOffset = 0): SqlSemanticRowSource[] {
|
|
const sources: SqlSemanticRowSource[] = [];
|
|
let inSelectFromClause = false;
|
|
for (let index = 0; index < state.tokens.length; index += 1) {
|
|
const item = state.tokens[index];
|
|
if (!item) continue;
|
|
if (item.depth !== sourceDepth) continue;
|
|
if (item.kind === "word") {
|
|
if (state.statement.kind === "select" && item.normalized === "from") inSelectFromClause = true;
|
|
else if (inSelectFromClause && FROM_CLAUSE_BOUNDARIES.has(item.normalized)) inSelectFromClause = false;
|
|
}
|
|
if (inSelectFromClause && item.text === ",") {
|
|
const parsed = parseRowSourceList(state, index + 1, "from", sourceIndexOffset + sources.length);
|
|
if (parsed) {
|
|
sources.push(...parsed.sources);
|
|
index = parsed.nextIndex - 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (item.kind !== "word") continue;
|
|
const normalized = item.normalized;
|
|
if (!TABLE_INTRODUCERS.has(normalized)) continue;
|
|
if (normalized === "update" && !updateIntroducesMutationTarget(state.tokens, index)) continue;
|
|
if (JOIN_MODIFIERS.has(normalized)) continue;
|
|
let target = index + 1;
|
|
while (JOIN_MODIFIERS.has(state.tokens[target]?.normalized ?? "")) target += 1;
|
|
target = sqlServerMaintenanceTableTarget(state.tokens, target, normalized, state.dialect);
|
|
for (;;) {
|
|
const parsed = parseRowSourceList(state, target, normalized, sourceIndexOffset + sources.length);
|
|
if (!parsed) break;
|
|
sources.push(...parsed.sources);
|
|
index = parsed.nextIndex - 1;
|
|
|
|
// Track the position past any LATERAL VIEW clauses so the FROM-list separator check sees
|
|
// the real comma after them (parsed.nextIndex points at the first "lateral" token itself).
|
|
let afterSources = parsed.nextIndex;
|
|
let lateral = parseDorisLateralView(state, afterSources, sourceIndexOffset + sources.length);
|
|
while (lateral) {
|
|
sources.push(lateral.source);
|
|
index = lateral.nextIndex - 1;
|
|
afterSources = lateral.nextIndex;
|
|
lateral = parseDorisLateralView(state, afterSources, sourceIndexOffset + sources.length);
|
|
}
|
|
|
|
const separator = state.tokens[afterSources];
|
|
if (normalized !== "from" || separator?.text !== "," || separator.depth !== sourceDepth) break;
|
|
target = afterSources + 1;
|
|
}
|
|
}
|
|
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;
|
|
const cursorDepth = [...tokens].reverse().find((item) => item.span.start < cursor)?.depth ?? fallbackDepth;
|
|
const starts = new Map<number, number>();
|
|
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const item = tokens[index];
|
|
if (!item || item.span.start >= cursor) break;
|
|
if (item.kind === "word" && item.normalized === "select" && item.depth <= cursorDepth) {
|
|
starts.set(item.depth, index);
|
|
}
|
|
}
|
|
if (!starts.has(fallbackDepth)) starts.set(fallbackDepth, 0);
|
|
|
|
return [...starts.entries()]
|
|
.sort(([left], [right]) => right - left)
|
|
.map(([depth, startIndex]) => {
|
|
let endIndex = tokens.length;
|
|
if (depth > fallbackDepth) {
|
|
for (let index = startIndex + 1; index < tokens.length; index += 1) {
|
|
if ((tokens[index]?.depth ?? depth) < depth) {
|
|
endIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return { depth, startIndex, endIndex };
|
|
});
|
|
}
|
|
|
|
function parseRowSources(state: ParseState, cursor: number): SqlSemanticRowSource[] {
|
|
const ranges = querySourceRanges(state.tokens, cursor);
|
|
const sources: SqlSemanticRowSource[] = [];
|
|
for (const range of ranges) {
|
|
const scopedState = {
|
|
...state,
|
|
tokens: state.tokens.slice(range.startIndex, range.endIndex),
|
|
};
|
|
sources.push(...parseRowSourcesAtDepth(scopedState, range.depth, sources.length));
|
|
}
|
|
return dedupeSources([...sources, ...state.cteSources]);
|
|
}
|
|
|
|
function dedupeSources(sources: SqlSemanticRowSource[]): SqlSemanticRowSource[] {
|
|
const seen = new Set<string>();
|
|
const result: SqlSemanticRowSource[] = [];
|
|
for (const source of sources) {
|
|
const key = `${source.kind}:${source.name}:${source.alias ?? ""}:${source.sourceSpan.start}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(source);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function clauseSpans(tokens: readonly SqlSemanticToken[]): SqlSemanticClauseSpans {
|
|
const spans: SqlSemanticClauseSpans = {};
|
|
const depth = tokens[0]?.depth ?? 0;
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const item = tokens[index];
|
|
if (!item || item.depth !== depth || item.kind !== "word") continue;
|
|
const next = tokens[index + 1];
|
|
const start = item.span.start;
|
|
const end = next?.span.start ?? tokens[tokens.length - 1]?.span.end ?? item.span.end;
|
|
if (item.normalized === "select") spans.select = { start, end };
|
|
if (item.normalized === "from") spans.from = { start, end };
|
|
if (item.normalized === "where") spans.where = { start, end };
|
|
if (item.normalized === "having") spans.having = { start, end };
|
|
if (item.normalized === "limit") spans.limit = { start, end };
|
|
if (item.normalized === "group" && next?.normalized === "by") spans.groupBy = { start, end: next.span.end };
|
|
if (item.normalized === "order" && next?.normalized === "by") spans.orderBy = { start, end: next.span.end };
|
|
if (item.normalized === "set") spans.updateSet = { start, end };
|
|
}
|
|
return spans;
|
|
}
|
|
|
|
function trailingIdentifier(tokens: readonly SqlSemanticToken[], cursor: number, dialect: SqlSemanticDialectAdapter): TrailingIdentifier {
|
|
const before = tokens.filter((item) => item.span.start < cursor && item.kind !== "comment" && item.kind !== "string");
|
|
const last = before[before.length - 1];
|
|
if (!last) return { prefix: "", replacementRange: { start: cursor, end: cursor }, qualifierParts: [] };
|
|
if (last.span.end < cursor && last.text !== ".") {
|
|
return { prefix: "", replacementRange: { start: cursor, end: cursor }, qualifierParts: [] };
|
|
}
|
|
|
|
let prefix = "";
|
|
let replacementRange: SqlSemanticSpan = { start: cursor, end: cursor };
|
|
let index = before.length - 1;
|
|
let hasQualifier = false;
|
|
if (tokenIsIdentifier(last) && cursor <= last.span.end) {
|
|
const rawPrefix = tokenTextAt(last.text, { start: 0, end: Math.max(0, cursor - last.span.start) });
|
|
prefix = last.kind === "quoted_identifier" ? unquoteSqlSemanticIdentifier({ ...last, text: rawPrefix.endsWith(last.quote ?? "") ? rawPrefix : rawPrefix + (last.quote === "[" ? "]" : (last.quote ?? "")) }) : rawPrefix;
|
|
replacementRange = { start: last.span.start, end: cursor };
|
|
index -= 1;
|
|
if (before[index]?.text === ".") {
|
|
hasQualifier = true;
|
|
index -= 1;
|
|
}
|
|
} else if (last.text === ".") {
|
|
hasQualifier = true;
|
|
index -= 1;
|
|
}
|
|
|
|
const qualifierParts: string[] = [];
|
|
if (hasQualifier) {
|
|
while (index >= 0) {
|
|
const identifier = before[index];
|
|
if (!tokenIsIdentifier(identifier)) break;
|
|
qualifierParts.unshift(identifierPart(identifier, dialect).name);
|
|
if (before[index - 1]?.text !== ".") break;
|
|
index -= 2;
|
|
}
|
|
}
|
|
|
|
return { prefix, replacementRange, qualifierParts };
|
|
}
|
|
|
|
function previousWord(tokens: readonly SqlSemanticToken[], cursor: number): string {
|
|
const before = tokens.filter((item) => item.span.end <= cursor && item.kind !== "comment");
|
|
return nearestPreviousSyntaxWord(before);
|
|
}
|
|
|
|
function wordBeforePosition(tokens: readonly SqlSemanticToken[], position: number): string {
|
|
const before = tokens.filter((item) => item.span.end <= position && item.kind !== "comment");
|
|
return nearestPreviousSyntaxWord(before);
|
|
}
|
|
|
|
function wordBeforeTrailingIdentifier(tokens: readonly SqlSemanticToken[], cursor: number, trailing: TrailingIdentifier): string {
|
|
const before = tokens.filter((item) => item.span.end <= cursor && item.kind !== "comment");
|
|
let index = before.length - 1;
|
|
let identifiersToSkip = trailing.qualifierParts.length + (trailing.prefix ? 1 : 0);
|
|
while (index >= 0 && identifiersToSkip > 0) {
|
|
if (before[index]?.text === ".") {
|
|
index -= 1;
|
|
continue;
|
|
}
|
|
if (!tokenIsIdentifier(before[index])) break;
|
|
identifiersToSkip -= 1;
|
|
index -= 1;
|
|
}
|
|
return nearestPreviousSyntaxWord(before.slice(0, index + 1));
|
|
}
|
|
|
|
/**
|
|
* Scans backward from the end of `before` for the nearest unquoted syntax word.
|
|
* A quoted identifier is a barrier: its object name must not participate in
|
|
* keyword comparisons even when it is named `from`, `join`, or `update`.
|
|
*/
|
|
function nearestPreviousSyntaxWord(tokens: readonly SqlSemanticToken[]): string {
|
|
for (let index = tokens.length - 1; index >= 0; index -= 1) {
|
|
const item = tokens[index];
|
|
if (!item) continue;
|
|
if (item.kind === "word") return item.normalized;
|
|
if (item.kind === "quoted_identifier") return "";
|
|
if (item.text === "." || item.kind === "comment") continue;
|
|
break;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function isTableListContinuation(tokens: readonly SqlSemanticToken[], position: number): boolean {
|
|
const before = tokens.filter((item) => item.span.end <= position && item.kind !== "comment");
|
|
let commaIndex = before.length - 1;
|
|
while (commaIndex >= 0 && (tokenIsIdentifier(before[commaIndex]) || before[commaIndex]?.text === ".")) commaIndex -= 1;
|
|
const comma = before[commaIndex];
|
|
if (comma?.text !== ",") return false;
|
|
const depth = comma.depth;
|
|
for (let index = commaIndex - 1; index >= 0; index -= 1) {
|
|
const token = before[index];
|
|
if (!token || token.depth !== depth || token.kind !== "word") continue;
|
|
if (TABLE_INTRODUCERS.has(token.normalized) || token.normalized === "from" || token.normalized === "join") return true;
|
|
if (CLAUSE_BOUNDARIES.has(token.normalized) || token.normalized === "select") return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasWordBefore(tokens: readonly SqlSemanticToken[], cursor: number, word: string): boolean {
|
|
return tokens.some((item) => item.span.end <= cursor && item.kind === "word" && item.normalized === word);
|
|
}
|
|
|
|
function isBeforeWord(tokens: readonly SqlSemanticToken[], cursor: number, word: string): boolean {
|
|
const target = tokens.find((item) => item.span.start >= cursor && item.kind === "word" && item.normalized === word);
|
|
return !!target;
|
|
}
|
|
|
|
function sourceForQualifier(sources: readonly SqlSemanticRowSource[], qualifierParts: readonly string[]): SqlSemanticRowSource | undefined {
|
|
const qualifier = qualifierParts[qualifierParts.length - 1]?.toLowerCase();
|
|
if (!qualifier) return undefined;
|
|
return sources.find((source) => source.alias?.toLowerCase() === qualifier || source.name.toLowerCase() === qualifier);
|
|
}
|
|
|
|
function starQualifierParts(before: readonly SqlSemanticToken[], starIndex: number, dialect: SqlSemanticDialectAdapter): string[] {
|
|
let index = starIndex - 1;
|
|
if (before[index]?.text !== ".") return [];
|
|
index -= 1;
|
|
const qualifierParts: string[] = [];
|
|
while (index >= 0) {
|
|
const identifier = before[index];
|
|
if (!tokenIsIdentifier(identifier)) break;
|
|
qualifierParts.unshift(identifierPart(identifier, dialect).name);
|
|
if (before[index - 1]?.text !== ".") break;
|
|
index -= 2;
|
|
}
|
|
return qualifierParts;
|
|
}
|
|
|
|
function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number, sources: readonly SqlSemanticRowSource[], dialect: SqlSemanticDialectAdapter, suppressed: boolean, kind: SqlSemanticStatementKind): SqlSemanticCursorIntent {
|
|
if (suppressed) {
|
|
return { kind: "suppressed", prefix: "", replacementRange: { start: cursor, end: cursor }, qualifierParts: [], expectedObjectKinds: [], confidence: "high", fallbackReason: "comment_or_string" };
|
|
}
|
|
const trailing = trailingIdentifier(tokens, cursor, dialect);
|
|
const previous = previousWord(tokens, cursor);
|
|
const targetSource = sourceForQualifier(sources, trailing.qualifierParts);
|
|
const before = tokens.filter((item) => item.span.end <= cursor);
|
|
const last = before[before.length - 1];
|
|
const wordBeforeReplacement = wordBeforePosition(tokens, trailing.replacementRange.start);
|
|
const wordBeforeTrailing = wordBeforeTrailingIdentifier(tokens, cursor, trailing);
|
|
const tableListContinuation = isTableListContinuation(tokens, trailing.replacementRange.start);
|
|
|
|
if (last?.text === "*" || trailing.prefix === "*") {
|
|
const starIndex = last?.text === "*" ? before.length - 1 : -1;
|
|
const qualifierParts = starIndex >= 0 ? starQualifierParts(before, starIndex, dialect) : trailing.qualifierParts;
|
|
const starTarget = sourceForQualifier(sources, qualifierParts);
|
|
const replacementRange = last?.text === "*" ? { start: last.span.start, end: Math.min(cursor, last.span.end) } : trailing.replacementRange;
|
|
return { kind: "star", prefix: "*", replacementRange, qualifierParts, targetSourceId: starTarget?.id, expectedObjectKinds: ["column"], confidence: "high" };
|
|
}
|
|
|
|
if (kind === "call") {
|
|
return { kind: "routine", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["routine", "procedure", "function"], confidence: "high" };
|
|
}
|
|
|
|
if (kind === "insert" && hasWordBefore(tokens, cursor, "into") && !hasWordBefore(tokens, cursor, "values")) {
|
|
const mutationTarget = sources.find((source) => source.kind === "mutation_target");
|
|
return {
|
|
kind: "insert_column",
|
|
prefix: trailing.prefix,
|
|
replacementRange: trailing.replacementRange,
|
|
qualifierParts: trailing.qualifierParts,
|
|
targetSourceId: mutationTarget?.id,
|
|
expectedObjectKinds: ["column"],
|
|
confidence: mutationTarget ? "medium" : "low",
|
|
fallbackReason: mutationTarget ? undefined : "unresolved_insert_target",
|
|
};
|
|
}
|
|
|
|
if (
|
|
trailing.qualifierParts.length > 0 &&
|
|
(previous === "from" ||
|
|
previous === "join" ||
|
|
TABLE_INTRODUCERS.has(previous) ||
|
|
TABLE_INTRODUCERS.has(wordBeforeReplacement) ||
|
|
TABLE_INTRODUCERS.has(wordBeforeTrailing) ||
|
|
(!!targetSource && !targetSource.alias && trailing.replacementRange.start >= targetSource.sourceSpan.start && trailing.replacementRange.start <= targetSource.sourceSpan.end + 1 && TABLE_INTRODUCERS.has(wordBeforePosition(tokens, targetSource.sourceSpan.start))))
|
|
) {
|
|
const role = dialect.qualifierRole(trailing.qualifierParts, "table");
|
|
return { kind: role === "catalog" ? "catalog" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "medium" };
|
|
}
|
|
|
|
if (trailing.qualifierParts.length > 0 && targetSource) {
|
|
return { kind: "alias_column", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, targetSourceId: targetSource.id, expectedObjectKinds: ["column"], confidence: "high" };
|
|
}
|
|
|
|
if (TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || TABLE_INTRODUCERS.has(wordBeforeTrailing) || previous === "from" || previous === "join" || wordBeforeReplacement === "from" || wordBeforeReplacement === "join" || tableListContinuation) {
|
|
return { kind: previous === "join" ? "table" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "high" };
|
|
}
|
|
|
|
if (previous === "call" || previous === "exec" || previous === "execute") {
|
|
return { kind: "routine", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["routine", "procedure", "function"], confidence: "high" };
|
|
}
|
|
|
|
if (previous === "set") {
|
|
return {
|
|
kind: "update_column",
|
|
prefix: trailing.prefix,
|
|
replacementRange: trailing.replacementRange,
|
|
qualifierParts: trailing.qualifierParts,
|
|
expectedObjectKinds: ["column"],
|
|
confidence: sources.length > 0 ? "medium" : "low",
|
|
fallbackReason: sources.length > 0 ? undefined : "unresolved_update_target",
|
|
};
|
|
}
|
|
|
|
if (["where", "on", "and", "or", "having", "by", "select"].includes(previous) && sources.length > 0 && !isBeforeWord(tokens, cursor, "from")) {
|
|
return { kind: previous === "on" ? "join_condition" : "column", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["column"], confidence: "medium" };
|
|
}
|
|
|
|
return { kind: "keyword", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: [], confidence: "low", fallbackReason: "keyword_context" };
|
|
}
|
|
|
|
function buildScope(statement: SqlSemanticStatement, rowSources: SqlSemanticRowSource[], projections: SqlSemanticProjection[], tokens: SqlSemanticToken[]): SqlSemanticScope {
|
|
return {
|
|
id: "root",
|
|
kind: statement.kind,
|
|
span: statement.span,
|
|
rowSources,
|
|
projections,
|
|
clauseSpans: clauseSpans(tokens),
|
|
};
|
|
}
|
|
|
|
export function buildSqlSemanticModel(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): SqlSemanticModel {
|
|
const safeCursor = Math.max(0, Math.min(cursor, sql.length));
|
|
const dialect = sqlSemanticDialectFor(options);
|
|
const window = resolveSqlStatementWindow(sql, safeCursor, options.editorState, dialect.id);
|
|
const windowSql = sql.slice(window.from, window.to);
|
|
const windowCursor = safeCursor - window.from;
|
|
const localTokens = tokenizeSqlSemantic(windowSql, dialect.id);
|
|
const localStatementSpan = findActiveSqlStatementSpan(windowSql, localTokens, windowCursor);
|
|
const allTokens = localTokens.map((token) => ({
|
|
...token,
|
|
span: { start: token.span.start + window.from, end: token.span.end + window.from },
|
|
}));
|
|
const statementSpan = { start: localStatementSpan.start + window.from, end: localStatementSpan.end + window.from };
|
|
const tokens = significantTokens(allTokens.filter((item) => item.span.end > statementSpan.start && item.span.start < statementSpan.end));
|
|
const kind = statementKind(tokens);
|
|
const statement: SqlSemanticStatement = {
|
|
kind,
|
|
span: statementSpan,
|
|
text: sql.slice(statementSpan.start, statementSpan.end),
|
|
};
|
|
const suppressed = isSuppressedSqlSemanticContext(allTokens, safeCursor);
|
|
const parseState: ParseState = { dialect, tokens, statement, cteSources: [] };
|
|
parseState.cteSources = parseCteSources(parseState);
|
|
const rowSources = parseRowSources(parseState, safeCursor);
|
|
const projections = parseSelectProjections(tokens, dialect);
|
|
const cursorIntent = buildCursorIntent(tokens, safeCursor, rowSources, dialect, suppressed, kind);
|
|
const scopes = [buildScope(statement, rowSources, projections, tokens)];
|
|
return {
|
|
databaseType: options.databaseType,
|
|
dialectId: dialect.id,
|
|
sql,
|
|
cursor: safeCursor,
|
|
statement,
|
|
tokens: allTokens,
|
|
scopes,
|
|
rowSources,
|
|
projections,
|
|
cursorIntent,
|
|
diagnostics: [],
|
|
};
|
|
}
|