From 8a1572369a171f49d3e23aeed4993aceb88a2cb2 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 08:43:42 +0800 Subject: [PATCH 1/2] feat(vnext): index bounded CTE visibility --- ...-parser-independent-relation-completion.md | 110 +- src/vnext/__tests__/bounded-sql-lexer.test.ts | 45 + src/vnext/__tests__/cte-layout.bench.ts | 138 ++ src/vnext/__tests__/cte-layout.test.ts | 1191 +++++++++++ src/vnext/bounded-sql-lexer.ts | 3 + src/vnext/cte-layout.ts | 1771 +++++++++++++++++ src/vnext/relation-completion-types.ts | 19 +- .../marimo-relation-completion.test-d.ts | 8 +- 8 files changed, 3275 insertions(+), 10 deletions(-) create mode 100644 src/vnext/__tests__/cte-layout.bench.ts create mode 100644 src/vnext/__tests__/cte-layout.test.ts create mode 100644 src/vnext/cte-layout.ts diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 79680a2..87d9ab2 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -171,8 +171,49 @@ WITH [RECURSIVE] main query ``` -It records only proven declaration names, body boundaries, declaration order, -and visibility. It is not a miniature general SQL AST. +The accepted grammar is dialect-owned and closed: + +| Dialect | Declarations per frame | `RECURSIVE` | Declared columns | Materialization modifier | +| --- | ---: | --- | --- | --- | +| PostgreSQL | up to the global bound | accepted | accepted | `MATERIALIZED` and `NOT MATERIALIZED` | +| DuckDB | up to the global bound | accepted | accepted | `MATERIALIZED` and `NOT MATERIALIZED` | +| BigQuery | up to the global bound | accepted | rejected | rejected | +| Dremio | one | rejected | accepted | rejected | + +This matrix describes only syntax the bounded recognizer can prove. Rejection +does not claim that a database engine rejects every extension or future +version; it makes the current completion result explicitly incomplete or +unavailable instead of borrowing another dialect's grammar. + +The recognizer records only: + +- flat query-block frames and their parent frame; +- proven declaration names, exact source spelling and name ranges; +- body ranges and declaration order; +- authenticated main-query `SELECT` entrypoints; and +- bounded candidate-name and uncertainty evidence for incomplete headers. + +It stores no AST, SQL substring, token tape, inferred output columns, or +recursive dependency graph. A declaration is committed only after its `AS` +body has a proven matching close parenthesis. Seeing `WITH name`, `name AS`, or +an opening body alone never invents a visible relation. Once a body opening is +proven, a separate bounded draft record retains its name, ordinal, body range, +and comparison evidence. Drafts can establish the current body phase and +fail-closed shadow uncertainty, but never become completion candidates. + +Candidate-name evidence, visible-candidate evidence, and shadow evidence are +distinct. Before a body opens, an active incomplete header has no supported +relation site; direct private projection still reports recovered unknown +coverage rather than exact absence. Once a body opens, its draft name is not +returned as a completion candidate, but it prevents the service from claiming +exact scope or exact non-shadowing where recursive or uncertain equality could +make it visible. Such a result is marked incomplete with +`cte-scope-uncertainty`. A proven declaration shadows an equivalent outer CTE +or unqualified catalog insertion only over its proven visibility range. A +duplicate equivalence class produces no arbitrary first- or last-wins +candidate and blocks an equivalent outer or catalog name wherever that class +would be visible. Uncertainty in one nested frame does not erase independently +proven candidates outside that frame. For non-recursive CTEs: @@ -184,13 +225,69 @@ For non-recursive CTEs: - a nested declaration shadows an outer name only inside its query block; and - nested declarations never leak outward. -Identifier equality follows the dialect. Duplicate names and structurally -ambiguous headers make the affected frame partial. `WITH RECURSIVE` may expose -proven names in the main query, but self and mutual-recursive body visibility -remain explicitly incomplete until implemented. +Identifier comparison follows the dialect and is tri-state: `equal`, +`distinct`, or `unknown`. PostgreSQL non-ASCII folding can depend on server +encoding and locale, while BigQuery and Dremio document case-insensitivity +without giving this package an authoritative general Unicode folding +algorithm. `unknown` therefore degrades namespace coverage and never means +`distinct`. The private layout builder consumes the same pairwise +`compareCteIdentifiers` operation exposed by the relation-completion dialect +runtime; it does not define a second nullable comparison key. Within the +256-name bound it snapshots symmetric pairwise results into frozen +equivalence classes and scoped uncertainty evidence. Throws, invalid values, +asymmetry, non-reflexivity, or inconsistent equivalence results fail closed. +Duplicate detection uses those classes rather than generic case folding. + +A declaration retains both its decoded value and exact source token. The +decoded value is the completion label; the exact token is the insertion text, +so required quoting, case, and escapes are never reconstructed by the catalog +renderer. CTEs are considered only for unqualified relation sites. Prefix +eligibility is also a dialect-owned tri-state operation distinct from equality; +generic locale-sensitive or Unicode case folding is never used. + +`WITH RECURSIVE` may expose proven names in the main query. The initial +recognizer also retains independently proven earlier-sibling and outer +visibility inside a recursive body, but self and forward or mutual-recursive +body candidates remain incomplete and add `recursive-cte-uncertainty`. Every +known frame-local recursive name still contributes shadow evidence inside a +recursive body, so withholding a self candidate cannot incorrectly reveal an +equivalent outer or catalog relation. The recognizer does not infer a recursive +dependency graph or output columns. DuckDB `USING KEY`, PostgreSQL +`SEARCH`/`CYCLE`, and any Dremio recursive extension remain unsupported. + +The index is cursor-independent and cached with the immutable source, +statement, embedded-region, and dialect-runtime identities. Visibility is a +pure projection over its flat frozen ranges. Building the index and recognizing +the relation query site may initially make two independent linear streaming +passes over the shared lexer; traversal fusion is allowed only after benchmark +evidence. Neither cursor movement nor catalog invalidation rebuilds the index. + +Stored source ranges remain half-open. Visibility projection accepts cursor +positions, so a cursor exactly before a proven closing delimiter remains in +the body or nested frame, and a top-level cursor at statement EOF remains in +the main query. A cursor after the delimiter is outside. At the first +untrusted boundary the enclosing draft phase may still contribute proven +positive evidence, but quality and shadow coverage are recovered rather than +exact. + +An opaque region in a CTE header or body, an unterminated quoted token, an +unsupported dialect modifier, a duplicate declaration, or a structurally +plausible but unfinished declaration makes the affected frame partial. The +first opaque region terminates exact structural coverage: visible punctuation +after an untyped barrier never closes a body or frame that started before it. +A resource limit records the same first-untrusted boundary. Partial artifacts +may still contribute declarations, entrypoints, and visibility ranges proven +entirely before that boundary, but they never produce exact absence or shadow +claims across it. An empty positive-only local-relation list never proves that no CTE is visible. +The first bounded grammar intentionally authenticates only `SELECT` query +leaders. PostgreSQL `VALUES` and data-modifying CTE bodies, DuckDB `FROM`-first +queries, and additionally parenthesized BigQuery recursive terms currently +fail closed. Their query-leader sets will become dialect-owned in a follow-up +before relation completion is declared feature-complete. + ### Embedded regions The first public template input is a complete set of length-preserving embedded @@ -525,6 +622,7 @@ The initial checked limits are: | Active statement scanned | 65,536 UTF-16 units | | Lexical tokens | 16,384 | | Parenthesis/query depth | 128 | +| CTE frames | 256 | | CTE declarations | 256 | | Identifier path segments | 32 global ceiling; dialect runtime sets the checked limit | | Identifier segment | 256 UTF-16 units | diff --git a/src/vnext/__tests__/bounded-sql-lexer.test.ts b/src/vnext/__tests__/bounded-sql-lexer.test.ts index 4635706..70837af 100644 --- a/src/vnext/__tests__/bounded-sql-lexer.test.ts +++ b/src/vnext/__tests__/bounded-sql-lexer.test.ts @@ -116,9 +116,18 @@ describe("bounded SQL lexer", () => { }); expect(lexer.next()).toBeNull(); expect(lexer.resource).toBeNull(); + expect(lexer.resourceAt).toBeNull(); }); it("fails closed immediately after the shared lexeme budget", () => { + const acceptedWords = Array.from( + { length: MAX_BOUNDED_SQL_LEXEMES }, + () => "x", + ).join(" "); + const accepted = lex(createIdentitySqlSource(acceptedWords)); + expect(accepted.lexemes).toHaveLength(MAX_BOUNDED_SQL_LEXEMES); + expect(accepted.resource).toBeNull(); + const words = Array.from( { length: MAX_BOUNDED_SQL_LEXEMES + 1 }, () => "x", @@ -126,6 +135,34 @@ describe("bounded SQL lexer", () => { const result = lex(createIdentitySqlSource(words)); expect(result.lexemes).toHaveLength(MAX_BOUNDED_SQL_LEXEMES); expect(result.resource).toBe("lexical-token"); + const source = createIdentitySqlSource(words); + const lexer = new BoundedSqlLexer( + source, + 0, + source.analysisText.length, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + while (lexer.next()) { + // Consume the bounded prefix. + } + expect(lexer.resourceAt).toBe( + words.lastIndexOf("x"), + ); + + const prefixed = ` ${words}`; + const prefixedSource = createIdentitySqlSource(prefixed); + const prefixedLexer = new BoundedSqlLexer( + prefixedSource, + 2, + prefixed.length, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + while (prefixedLexer.next()) { + // Consume the bounded prefix. + } + expect(prefixedLexer.resourceAt).toBe( + prefixed.lastIndexOf("x"), + ); }); it("reports oversized dollar-quote delimiters without emitting a token", () => { @@ -136,5 +173,13 @@ describe("bounded SQL lexer", () => { lexemes: [], resource: "dollar-quote-delimiter", }); + const lexer = new BoundedSqlLexer( + source, + 0, + source.analysisText.length, + POSTGRESQL_SQL_LEXICAL_PROFILE, + ); + expect(lexer.next()).toBeNull(); + expect(lexer.resourceAt).toBe(0); }); }); diff --git a/src/vnext/__tests__/cte-layout.bench.ts b/src/vnext/__tests__/cte-layout.bench.ts new file mode 100644 index 0000000..a3a7700 --- /dev/null +++ b/src/vnext/__tests__/cte-layout.bench.ts @@ -0,0 +1,138 @@ +import { bench, describe } from "vitest"; +import { + analyzeSqlCteLayout, + MAX_CTE_DECLARATIONS, + MAX_CTE_DEPTH, + MAX_CTE_FRAMES, + type SqlCteLayoutDialect, + visibleSqlCtesAt, +} from "../cte-layout.js"; +import { DUCKDB_SQL_LEXICAL_PROFILE } from "../lexical.js"; +import { createIdentitySqlSource } from "../source.js"; +import { + buildSqlStatementIndex, + type ExactSqlStatementSlot, +} from "../statement-index.js"; + +const dialect: SqlCteLayoutDialect = { + classifyIdentifierToken: (rawIdentifier, quoted) => ({ + status: "identifier", + value: { + component: { quoted, value: rawIdentifier }, + }, + }), + compareCteIdentifiers: (left, right) => + left.value.toLowerCase() === right.value.toLowerCase() + ? "equal" + : "distinct", + grammar: { + declaredColumns: true, + materialization: true, + maximumDeclarationsPerFrame: MAX_CTE_DECLARATIONS, + recursive: true, + }, + lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE, +}; + +function fixture(text: string): { + readonly source: ReturnType; + readonly slot: ExactSqlStatementSlot; +} { + const source = createIdentitySqlSource(text); + const slot = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ).slots[0]; + if (!slot || slot.boundaryQuality !== "exact") { + throw new Error("CTE benchmark fixture requires an exact statement"); + } + return { slot, source }; +} + +const tenKibibytes = 10 * 1_024; +const ordinaryPrefix = "SELECT "; +const ordinary = `${ordinaryPrefix}${"x,".repeat( + Math.floor((tenKibibytes - ordinaryPrefix.length - 1) / 2), +)}x`; +const ordinaryText = `${ordinary}${" ".repeat( + tenKibibytes - ordinary.length, +)}`; +const ordinaryFixture = fixture(ordinaryText); + +const declarationHeavyText = `WITH ${Array.from( + { length: MAX_CTE_DECLARATIONS }, + (_, index) => `c${index} AS (SELECT ${index})`, +).join(", ")} SELECT * FROM c255`; +const declarationHeavyFixture = fixture(declarationHeavyText); + +const depthHeavyText = Array.from( + { length: MAX_CTE_DEPTH }, + (_, index) => index, +).reduce( + (body, index) => + `WITH c${index} AS (${body}) SELECT * FROM c${index}`, + "SELECT 1", +); +const depthHeavyFixture = fixture(depthHeavyText); +const bareFrameHeavyText = `SELECT ${Array.from( + { length: MAX_CTE_FRAMES }, + () => "(WITH)", +).join(",")}`; +const bareFrameHeavyFixture = fixture(bareFrameHeavyText); +const depthLayout = analyzeSqlCteLayout( + depthHeavyFixture.source, + depthHeavyFixture.slot, + dialect, +); +if (depthLayout.status !== "ready") { + throw new Error("Depth benchmark requires a ready CTE layout"); +} +const projectedLayout = analyzeSqlCteLayout( + declarationHeavyFixture.source, + declarationHeavyFixture.slot, + dialect, +); +if (projectedLayout.status === "unavailable") { + throw new Error("CTE projection benchmark requires a layout"); +} + +describe("CTE layout", () => { + bench("ordinary 10 KiB statement", () => { + analyzeSqlCteLayout( + ordinaryFixture.source, + ordinaryFixture.slot, + dialect, + ); + }); + + bench("256 declarations", () => { + analyzeSqlCteLayout( + declarationHeavyFixture.source, + declarationHeavyFixture.slot, + dialect, + ); + }); + + bench("128-depth nested CTE", () => { + analyzeSqlCteLayout( + depthHeavyFixture.source, + depthHeavyFixture.slot, + dialect, + ); + }); + + bench("256 sequential incomplete frames", () => { + analyzeSqlCteLayout( + bareFrameHeavyFixture.source, + bareFrameHeavyFixture.slot, + dialect, + ); + }); + + bench("cached 256-declaration projection", () => { + visibleSqlCtesAt( + projectedLayout, + declarationHeavyText.length - 1, + ); + }); +}); diff --git a/src/vnext/__tests__/cte-layout.test.ts b/src/vnext/__tests__/cte-layout.test.ts new file mode 100644 index 0000000..ccf8172 --- /dev/null +++ b/src/vnext/__tests__/cte-layout.test.ts @@ -0,0 +1,1191 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlCteLayout, + MAX_CTE_DECLARATIONS, + MAX_CTE_DEPTH, + MAX_CTE_FRAMES, + MAX_CTE_IDENTIFIER_LENGTH, + MAX_CTE_STATEMENT_LENGTH, + type SqlCteLayout, + type SqlCteLayoutDialect, + type SqlCteLayoutIssue, + type SqlCteIdentifierResult, + visibleSqlCtesAt, +} from "../cte-layout.js"; +import { + BIGQUERY_SQL_LEXICAL_PROFILE, + DREMIO_SQL_LEXICAL_PROFILE, + DUCKDB_SQL_LEXICAL_PROFILE, + POSTGRESQL_SQL_LEXICAL_PROFILE, +} from "../lexical.js"; +import { + createIdentitySqlSource, + createMaskedSqlSource, +} from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, + type ExactSqlStatementSlot, +} from "../statement-index.js"; + +function decodeQuoted(raw: string): string { + const quote = raw.at(0) ?? ""; + return raw + .slice(1, -1) + .replaceAll(`${quote}${quote}`, quote) + .replaceAll(`\\${quote}`, quote); +} + +function isAscii(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) > 0x7f) { + return false; + } + } + return true; +} + +function createDialect( + kind: "bigquery" | "dremio" | "duckdb" | "postgresql", +): SqlCteLayoutDialect { + const lexicalProfile = + kind === "bigquery" + ? BIGQUERY_SQL_LEXICAL_PROFILE + : kind === "dremio" + ? DREMIO_SQL_LEXICAL_PROFILE + : kind === "duckdb" + ? DUCKDB_SQL_LEXICAL_PROFILE + : POSTGRESQL_SQL_LEXICAL_PROFILE; + return { + classifyIdentifierToken: (raw, quoted) => { + const value = quoted ? decodeQuoted(raw) : raw; + if ( + value.length === 0 || + (!quoted && + /^(?:as|materialized|not|recursive|select|with)$/i.test( + value, + )) + ) { + return { status: "unsupported" }; + } + return { + status: "identifier", + value: { + component: { quoted, value }, + }, + }; + }, + compareCteIdentifiers: (left, right) => { + const leftKey = + kind === "postgresql" && left.quoted + ? isAscii(left.value) + ? left.value + : null + : isAscii(left.value) + ? left.value.toLowerCase() + : null; + const rightKey = + kind === "postgresql" && right.quoted + ? isAscii(right.value) + ? right.value + : null + : isAscii(right.value) + ? right.value.toLowerCase() + : null; + if (leftKey !== null && rightKey !== null) { + return leftKey === rightKey ? "equal" : "distinct"; + } + return left.quoted === right.quoted && + left.value === right.value + ? "equal" + : "unknown"; + }, + grammar: { + declaredColumns: kind !== "bigquery", + materialization: + kind === "duckdb" || kind === "postgresql", + maximumDeclarationsPerFrame: + kind === "dremio" ? 1 : MAX_CTE_DECLARATIONS, + recursive: kind !== "dremio", + }, + lexicalProfile, + }; +} + +const postgres = createDialect("postgresql"); +const duckdb = createDialect("duckdb"); +const bigquery = createDialect("bigquery"); +const dremio = createDialect("dremio"); + +function analyze( + text: string, + dialect: SqlCteLayoutDialect = postgres, + regions: readonly { + readonly from: number; + readonly language: string; + readonly to: number; + }[] = [], +): Exclude { + const source = + regions.length === 0 + ? createIdentitySqlSource(text) + : createMaskedSqlSource(text, regions); + const index = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ); + const slot = index.slots[0]; + expect(slot?.boundaryQuality).toBe("exact"); + const result = analyzeSqlCteLayout( + source, + slot as ExactSqlStatementSlot, + dialect, + ); + expect(result.status).not.toBe("unavailable"); + return result as Exclude< + SqlCteLayout, + { status: "unavailable" } + >; +} + +function analyzeRaw( + text: string, + dialect: SqlCteLayoutDialect = postgres, +): SqlCteLayout { + const source = createIdentitySqlSource(text); + const slot = buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ).slots[0]; + expect(slot?.boundaryQuality).toBe("exact"); + return analyzeSqlCteLayout( + source, + slot as ExactSqlStatementSlot, + dialect, + ); +} + +function expectPartial( + text: string, + issue: SqlCteLayoutIssue, + dialect: SqlCteLayoutDialect = postgres, +): void { + const layout = analyze(text, dialect); + expect(layout.status).toBe("partial"); + expect(layout.issues).toContain(issue); +} + +function names( + layout: Exclude, + position: number, +): readonly string[] { + return visibleSqlCtesAt(layout, position).ctes.map( + (cte) => cte.name.value, + ); +} + +describe("bounded CTE layout", () => { + it("proves nonrecursive declaration order and main visibility", () => { + const text = + "WITH a AS (SELECT * FROM base), " + + "b AS (SELECT * FROM a) SELECT * FROM b"; + const layout = analyze(text); + expect(layout.status).toBe("ready"); + expect(layout.mainQueryEntrypoints).toEqual([ + { + depth: 0, + frameIndex: 0, + from: text.lastIndexOf("SELECT"), + }, + ]); + expect(names(layout, text.indexOf("base"))).toEqual([]); + expect( + names(layout, text.indexOf("FROM a") + "FROM ".length), + ).toEqual(["a"]); + expect( + names(layout, text.lastIndexOf("FROM b") + "FROM ".length), + ).toEqual(["a", "b"]); + }); + + it("keeps nested shadowing inside the nested query block", () => { + const text = + "WITH x AS (SELECT * FROM base) SELECT * FROM (" + + "WITH x AS (SELECT * FROM x) SELECT * FROM x) q, x"; + const layout = analyze(text); + const innerBody = text.indexOf("FROM x"); + const innerMain = text.indexOf("FROM x", innerBody + 1); + expect(names(layout, innerBody + 5)).toEqual(["x"]); + expect(names(layout, innerMain + 5)).toEqual(["x"]); + expect( + visibleSqlCtesAt(layout, innerBody + 5).ctes[0] + ?.declarationPosition, + ).toBe(text.indexOf("x")); + expect( + visibleSqlCtesAt(layout, innerMain + 5).ctes[0] + ?.declarationPosition, + ).toBe(text.indexOf("x", text.indexOf("(WITH") + 1)); + expect(names(layout, text.lastIndexOf(", x") + 2)).toEqual([ + "x", + ]); + }); + + it("treats cursor positions at closing delimiters as inside", () => { + const text = + "WITH x AS (SELECT 1) SELECT * FROM (" + + "WITH y AS (SELECT 2) SELECT * FROM y) q"; + const layout = analyze(text); + const close = text.lastIndexOf(")"); + expect(names(layout, close)).toEqual(["x", "y"]); + expect(names(layout, close + 1)).toEqual(["x"]); + }); + + it("keeps nested frame issues local to their proven scope", () => { + const text = + "WITH outer_cte AS (SELECT 1) SELECT * FROM (" + + "WITH x AS (SELECT 1), X AS (SELECT 2) SELECT * FROM x" + + ") q, outer_cte"; + const layout = analyze(text); + const nested = visibleSqlCtesAt( + layout, + text.indexOf("FROM x") + 5, + ); + expect(nested.quality).toBe("recovered"); + expect(nested.issues).toContain("duplicate-cte-name"); + + const outer = visibleSqlCtesAt( + layout, + text.lastIndexOf("outer_cte"), + ); + expect(outer).toMatchObject({ + issues: [], + quality: "exact", + }); + expect(outer.ctes.map((cte) => cte.name.value)).toEqual([ + "outer_cte", + ]); + + const trailingText = + "WITH outer_cte AS (SELECT 1) SELECT * FROM (" + + "WITH x AS (SELECT 1), X AS (SELECT 2) SELECT * FROM x" + + ") q,"; + expect( + visibleSqlCtesAt(analyze(trailingText), trailingText.length), + ).toMatchObject({ + ctes: [ + { + name: { quoted: false, value: "outer_cte" }, + }, + ], + issues: [], + quality: "exact", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "outer_cte" }], + }, + }); + }); + + it("withholds recursive self and forward candidates but shadows them", () => { + const text = + "WITH RECURSIVE r AS (SELECT * FROM r), " + + "s AS (SELECT * FROM r) SELECT * FROM s"; + const layout = analyze(text); + const firstBody = visibleSqlCtesAt( + layout, + text.indexOf("FROM r") + 5, + ); + expect(firstBody.ctes).toEqual([]); + expect(firstBody.issues).toContain("recursive-cte-position"); + expect(firstBody.shadowing).toEqual({ + coverage: "complete", + names: [ + { quoted: false, value: "r" }, + { quoted: false, value: "s" }, + ], + }); + const secondBody = visibleSqlCtesAt( + layout, + text.lastIndexOf("FROM r") + 5, + ); + expect(secondBody.ctes.map((cte) => cte.name.value)).toEqual([ + "r", + ]); + expect(names(layout, text.lastIndexOf("FROM s") + 5)).toEqual([ + "r", + "s", + ]); + + const unfinishedText = + "WITH RECURSIVE a AS (SELECT * FROM target), "; + expect( + visibleSqlCtesAt( + analyze(unfinishedText), + unfinishedText.indexOf("target"), + ), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + }); + + it("retains fail-closed visibility evidence for unfinished bodies", () => { + const text = + "WITH a AS (SELECT 1), b AS (SELECT * FROM target"; + const layout = analyze(text); + const visibility = visibleSqlCtesAt( + layout, + text.indexOf("target"), + ); + expect(layout.status).toBe("partial"); + expect(visibility).toMatchObject({ + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "a" }], + }, + }); + expect(visibility.ctes.map((cte) => cte.name.value)).toEqual([ + "a", + ]); + expect( + visibleSqlCtesAt(layout, text.length).ctes.map( + (cte) => cte.name.value, + ), + ).toEqual(["a"]); + + const recursiveText = + "WITH x AS (SELECT 1) SELECT * FROM (" + + "WITH RECURSIVE x AS (SELECT * FROM target"; + const recursiveLayout = analyze(recursiveText); + const recursiveVisibility = visibleSqlCtesAt( + recursiveLayout, + recursiveText.indexOf("target"), + ); + expect(recursiveVisibility.ctes).toEqual([]); + expect(recursiveVisibility.issues).toEqual([ + "ambiguous-cte-header", + "recursive-cte-position", + ]); + expect(recursiveVisibility.shadowing).toEqual({ + coverage: "unknown", + }); + + const nonrecursiveText = + "WITH x AS (SELECT 1) SELECT * FROM (" + + "WITH x AS (SELECT * FROM target"; + const nonrecursiveLayout = analyze(nonrecursiveText); + const fallback = visibleSqlCtesAt( + nonrecursiveLayout, + nonrecursiveText.indexOf("target"), + ); + expect(fallback.ctes.map((cte) => cte.declarationPosition)).toEqual([ + nonrecursiveText.indexOf("x"), + ]); + expect(fallback.quality).toBe("recovered"); + }); + + it("blocks duplicate equivalence classes instead of choosing a winner", () => { + const text = + "WITH a AS (SELECT 1), A AS (SELECT 2) SELECT * FROM a"; + const layout = analyze(text); + expect(layout.status).toBe("partial"); + const visibility = visibleSqlCtesAt( + layout, + text.lastIndexOf("FROM a") + 5, + ); + expect(visibility.ctes).toEqual([]); + expect(visibility.issues).toContain("duplicate-cte-name"); + expect(visibility.shadowing).toEqual({ + coverage: "complete", + names: [{ quoted: false, value: "A" }], + }); + expect(visibleSqlCtesAt(layout, text.length)).toMatchObject({ + ctes: [], + issues: ["duplicate-cte-name"], + quality: "recovered", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "A" }], + }, + }); + }); + + it("preserves decoded labels and exact quoted insertion spelling", () => { + const text = + 'WITH "a""b" AS MATERIALIZED (SELECT 1) ' + + 'SELECT * FROM "a""b"'; + const layout = analyze(text); + const cte = visibleSqlCtesAt( + layout, + text.lastIndexOf("FROM") + 5, + ).ctes[0]; + expect(cte).toEqual({ + declarationPosition: text.indexOf('"a""b"'), + name: { quoted: true, value: 'a"b' }, + sourceSpelling: '"a""b"', + }); + }); + + it("uses dialect-owned quoted equality without generic folding", () => { + const duplicateCases = [ + { + dialect: postgres, + text: + 'WITH foo AS (SELECT 1), "foo" AS (SELECT 2) ' + + "SELECT 1", + }, + { + dialect: duckdb, + text: + 'WITH foo AS (SELECT 1), "FOO" AS (SELECT 2) ' + + "SELECT 1", + }, + { + dialect: bigquery, + text: + "WITH foo AS (SELECT 1), `FOO` AS (SELECT 2) " + + "SELECT 1", + }, + ]; + for (const duplicate of duplicateCases) { + expect(analyze(duplicate.text, duplicate.dialect).issues).toContain( + "duplicate-cte-name", + ); + } + + const distinctText = + 'WITH foo AS (SELECT 1), "Foo" AS (SELECT 2) ' + + "SELECT * FROM foo"; + expect( + names(analyze(distinctText), distinctText.lastIndexOf("foo")), + ).toEqual(["foo", "Foo"]); + }); + + it("keeps every stored range statement-relative", () => { + const text = + "SELECT 0;\n WITH a AS (SELECT 1) SELECT * FROM a"; + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + postgres.lexicalProfile, + ); + const absolutePosition = text.lastIndexOf("FROM a") + 5; + const slot = findSqlStatementSlot( + index, + absolutePosition, + "left", + ); + expect(slot.boundaryQuality).toBe("exact"); + if (slot.boundaryQuality !== "exact") { + throw new Error("Expected an exact second statement"); + } + const layout = analyzeSqlCteLayout( + source, + slot, + postgres, + ); + expect(layout.status).toBe("ready"); + if (layout.status !== "ready") { + throw new Error("Expected an exact second-statement layout"); + } + expect(layout.frames[0]?.withStart).toBe( + text.indexOf("WITH") - slot.source.from, + ); + expect(layout.declarations[0]?.nameRange).toMatchObject({ + from: text.indexOf("a AS") - slot.source.from, + to: text.indexOf("a AS") - slot.source.from + 1, + }); + expect( + names(layout, absolutePosition - slot.source.from), + ).toEqual(["a"]); + }); + + it("uses the closed dialect grammar matrix", () => { + expect( + analyze( + "WITH a(x) AS NOT MATERIALIZED (SELECT 1) SELECT * FROM a", + duckdb, + ).status, + ).toBe("ready"); + expect( + analyze( + "WITH a(x) AS (SELECT 1) SELECT * FROM a", + bigquery, + ).issues, + ).toContain("unsupported-cte-extension"); + expect( + analyze( + "WITH a(x) AS (SELECT 1) SELECT * FROM a", + dremio, + ).status, + ).toBe("ready"); + expect( + analyze( + "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT 1", + dremio, + ).issues, + ).toContain("unsupported-cte-extension"); + }); + + it("balances bodies through dialect strings and comments", () => { + const text = + "WITH /* lead */ a /* name */ (x) AS NOT /* hint */ " + + "MATERIALIZED (SELECT '(' AS x /* ) , WITH */), " + + "b AS (SELECT ')' AS y -- )\n) SELECT * FROM b"; + const layout = analyze(text); + expect(layout.status).toBe("ready"); + expect( + names(layout, text.lastIndexOf("FROM b") + 5), + ).toEqual(["a", "b"]); + }); + + it.each([ + ["WITH", "ambiguous-cte-header"], + ["WITH RECURSIVE", "ambiguous-cte-header"], + ["WITH SELECT", "ambiguous-cte-header"], + ["WITH a", "ambiguous-cte-header"], + ["WITH a nope", "ambiguous-cte-header"], + ["WITH a() AS (SELECT 1) SELECT 1", "ambiguous-cte-header"], + [ + "WITH a(x,) AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + ], + [ + "WITH a(x y) AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + ], + ["WITH a(x) nope (SELECT 1)", "ambiguous-cte-header"], + ["WITH a AS nope", "ambiguous-cte-header"], + ["WITH a AS () SELECT 1", "ambiguous-cte-header"], + ["WITH a AS NOT nope", "ambiguous-cte-header"], + [ + "WITH a AS MATERIALIZED nope", + "ambiguous-cte-header", + ], + ["WITH a AS (VALUES (1)) SELECT 1", "unsupported-cte-extension"], + ["WITH a AS ('SELECT 1') SELECT 1", "unsupported-cte-extension"], + [ + "WITH a AS (WITH b AS (SELECT 1)) SELECT 1", + "ambiguous-cte-header", + ], + ["WITH a AS (WITH) SELECT 1", "ambiguous-cte-header"], + ["WITH a AS (SELECT 1) DELETE FROM t", "unsupported-cte-extension"], + ["WITH a AS (SELECT 1),", "ambiguous-cte-header"], + [ + "WITH a AS (SELECT 1), SELECT", + "ambiguous-cte-header", + ], + [ + "WITH a(SELECT) AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + ], + ] as const)( + "fails closed for malformed or unsupported CTE syntax %#", + (text, issue) => { + expectPartial(text, issue); + }, + ); + + it("rejects unsupported recursive and materialization modifiers", () => { + expectPartial( + "WITH RECURSIVE a AS (SELECT 1) SELECT 1", + "unsupported-cte-extension", + dremio, + ); + expectPartial( + "WITH a AS NOT MATERIALIZED (SELECT 1) SELECT 1", + "unsupported-cte-extension", + bigquery, + ); + expectPartial( + "WITH a AS MATERIALIZED (SELECT 1) SELECT 1", + "unsupported-cte-extension", + bigquery, + ); + }); + + it("stops exact structural coverage at an embedded barrier", () => { + const text = + "WITH a AS (SELECT {value}), b AS (SELECT 2) " + + "SELECT * FROM b"; + const from = text.indexOf("{value}"); + const layout = analyze(text, postgres, [ + { from, language: "python", to: from + "{value}".length }, + ]); + expect(layout.status).toBe("partial"); + expect(layout.exactThrough).toBe(from); + expect(layout.declarations).toEqual([]); + expect(layout.draftDeclarations).toHaveLength(1); + expect(layout.draftDeclarations[0]).toMatchObject({ + bodyRange: { from: text.indexOf("SELECT"), to: from }, + name: { quoted: false, value: "a" }, + }); + expect(layout.issues).toContain("opaque-template-context"); + expect( + visibleSqlCtesAt(layout, text.lastIndexOf("FROM b") + 5), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + expect(visibleSqlCtesAt(layout, from)).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + + const laterBarrierText = + "WITH a AS (SELECT 1), b AS (SELECT * FROM target {value}) " + + "SELECT * FROM b"; + const laterFrom = laterBarrierText.indexOf("{value}"); + const laterLayout = analyze(laterBarrierText, postgres, [ + { + from: laterFrom, + language: "python", + to: laterFrom + "{value}".length, + }, + ]); + const beforeBarrier = visibleSqlCtesAt( + laterLayout, + laterBarrierText.indexOf("target"), + ); + expect(beforeBarrier.ctes.map((cte) => cte.name.value)).toEqual([ + "a", + ]); + expect(beforeBarrier.quality).toBe("recovered"); + }); + + it("ignores deceptive WITH text in comments and strings", () => { + const layout = analyze( + "SELECT 'WITH a AS (SELECT 1)' /* WITH b */ FROM t", + ); + expect(layout).toMatchObject({ + declarations: [], + frames: [], + mainQueryEntrypoints: [], + status: "ready", + }); + }); + + it("validates classifier results without invoking accessors", () => { + let accessorInvoked = false; + const accessorDialect: SqlCteLayoutDialect = { + ...postgres, + classifyIdentifierToken: () => ({ + get status(): "identifier" { + accessorInvoked = true; + return "identifier"; + }, + value: { + component: { quoted: false, value: "a" }, + }, + }), + }; + expectPartial( + "WITH a AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + accessorDialect, + ); + expect(accessorInvoked).toBe(false); + + const malformedResults: readonly SqlCteIdentifierResult[] = [ + { status: "unsupported" }, + { + status: "identifier", + value: { + component: { quoted: true, value: "a" }, + }, + }, + { + status: "identifier", + value: new Proxy( + { + component: { quoted: false, value: "a" }, + }, + { + getOwnPropertyDescriptor() { + return undefined; + }, + }, + ), + }, + { + status: "identifier", + value: { + component: { quoted: false, value: "" }, + }, + }, + { + status: "identifier", + value: { + component: { + quoted: false, + value: "a".repeat(MAX_CTE_IDENTIFIER_LENGTH + 1), + }, + }, + }, + ]; + for (const result of malformedResults) { + expectPartial( + "WITH a AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + { ...postgres, classifyIdentifierToken: () => result }, + ); + } + expectPartial( + 'WITH "a" AS (SELECT 1) SELECT 1', + "ambiguous-cte-header", + { + ...postgres, + classifyIdentifierToken: () => ({ + status: "identifier", + value: { + component: { quoted: false, value: "a" }, + }, + }), + }, + ); + expectPartial( + "WITH a AS (SELECT 1) SELECT 1", + "ambiguous-cte-header", + { + ...postgres, + classifyIdentifierToken: () => { + throw new Error("hostile"); + }, + }, + ); + }); + + it("bounds raw and decoded identifier work before retention", () => { + let calls = 0; + const countingDialect: SqlCteLayoutDialect = { + ...postgres, + classifyIdentifierToken: (...arguments_) => { + calls += 1; + return postgres.classifyIdentifierToken(...arguments_); + }, + }; + const accepted = "a".repeat(MAX_CTE_IDENTIFIER_LENGTH); + expect( + analyze( + `WITH ${accepted} AS (SELECT 1) SELECT 1`, + countingDialect, + ).status, + ).toBe("ready"); + expect(calls).toBe(1); + calls = 0; + + const oversized = "a".repeat(MAX_CTE_IDENTIFIER_LENGTH + 1); + expectPartial( + `WITH ${oversized} AS (SELECT 1) SELECT 1`, + "ambiguous-cte-header", + countingDialect, + ); + expect(calls).toBe(0); + + const oversizedQuoted = `"${"a".repeat( + MAX_CTE_IDENTIFIER_LENGTH * 2 + 1, + )}"`; + expectPartial( + `WITH ${oversizedQuoted} AS (SELECT 1) SELECT 1`, + "ambiguous-cte-header", + countingDialect, + ); + expect(calls).toBe(0); + }); + + it("fails closed on hostile identifier comparators", () => { + const text = + "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a"; + const hostileComparators = [ + () => { + throw new Error("hostile"); + }, + () => "invalid", + (left: { value: string }, right: { value: string }) => + left.value <= right.value ? "equal" : "distinct", + (left: { value: string }, right: { value: string }) => + left.value === right.value || + (left.value === "a" && right.value === "b") || + (left.value === "b" && right.value === "a") || + (left.value === "b" && right.value === "c") || + (left.value === "c" && right.value === "b") + ? "equal" + : "distinct", + ] as const; + for (const compareCteIdentifiers of hostileComparators) { + const candidateText = + compareCteIdentifiers === hostileComparators.at(-1) + ? "WITH a AS (SELECT 1), b AS (SELECT 2), " + + "c AS (SELECT 3) SELECT * FROM c" + : text; + const layout = analyze(candidateText, { + ...postgres, + compareCteIdentifiers: + compareCteIdentifiers as SqlCteLayoutDialect["compareCteIdentifiers"], + }); + expect(layout.issues).toContain( + "unknown-cte-identifier-equivalence", + ); + expect( + visibleSqlCtesAt( + layout, + candidateText.lastIndexOf("FROM") + 5, + ), + ).toMatchObject({ + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + }); + + it("keeps exact non-ASCII identity but fails closed on unknown pairs", () => { + const text = "WITH café AS (SELECT 1) SELECT * FROM café"; + const layout = analyze(text); + const visibility = visibleSqlCtesAt( + layout, + text.lastIndexOf("café"), + ); + expect(visibility).toMatchObject({ + quality: "exact", + shadowing: { coverage: "complete" }, + }); + expect(visibility.ctes.map((cte) => cte.name.value)).toEqual([ + "café", + ]); + + const uncertainText = + "WITH café AS (SELECT 1), CAFÉ AS (SELECT 2) " + + "SELECT * FROM café"; + const uncertainLayout = analyze(uncertainText); + const uncertainVisibility = visibleSqlCtesAt( + uncertainLayout, + uncertainText.lastIndexOf("café"), + ); + expect(uncertainVisibility).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + expect(uncertainVisibility.issues).toContain( + "unknown-cte-identifier-equivalence", + ); + + const recursiveText = + "WITH RECURSIVE café AS (SELECT * FROM café) " + + "SELECT * FROM café"; + const recursiveLayout = analyze(recursiveText); + expect( + visibleSqlCtesAt( + recursiveLayout, + recursiveText.indexOf("FROM café") + 5, + ), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "complete" }, + }); + + const recursiveShadowCases = [ + "WITH café AS (SELECT 1) SELECT * FROM (" + + "WITH RECURSIVE CAFÉ AS (SELECT * FROM target) " + + "SELECT * FROM CAFÉ) q", + "WITH café AS (SELECT 1) SELECT * FROM (" + + "WITH RECURSIVE b AS (SELECT * FROM target), " + + "CAFÉ AS (SELECT 2) SELECT * FROM b) q", + ]; + for (const recursiveShadowText of recursiveShadowCases) { + expect( + visibleSqlCtesAt( + analyze(recursiveShadowText), + recursiveShadowText.indexOf("target"), + ), + ).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + }); + + it("fails partial at exact resource boundaries plus one", () => { + const nested = + "(".repeat(MAX_CTE_DEPTH + 1) + + "SELECT 1" + + ")".repeat(MAX_CTE_DEPTH + 1); + const depthLayout = analyze(nested); + expect(depthLayout.status).toBe("partial"); + expect(depthLayout).toMatchObject({ + resource: "parenthesis-depth", + }); + + const declarations = Array.from( + { length: MAX_CTE_DECLARATIONS + 1 }, + (_, index) => `c${index} AS (SELECT ${index})`, + ).join(", "); + const declarationLayout = analyze( + `WITH ${declarations} SELECT 1`, + ); + expect(declarationLayout.status).toBe("partial"); + expect(declarationLayout).toMatchObject({ + resource: "cte-declaration", + }); + + const acceptedDeclarations = Array.from( + { length: MAX_CTE_DECLARATIONS }, + (_, index) => `c${index} AS (SELECT ${index})`, + ).join(", "); + expect( + analyze(`WITH ${acceptedDeclarations} SELECT 1`).status, + ).toBe("ready"); + + const acceptedDepth = + "(".repeat(MAX_CTE_DEPTH) + + "SELECT 1" + + ")".repeat(MAX_CTE_DEPTH); + expect(analyzeRaw(acceptedDepth).status).toBe("ready"); + + expect( + analyzeRaw(" ".repeat(MAX_CTE_STATEMENT_LENGTH)).status, + ).toBe("ready"); + + expect( + analyzeRaw("x".repeat(MAX_CTE_STATEMENT_LENGTH + 1)), + ).toEqual({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); + + const manyTokens = Array.from( + { length: 8_193 }, + () => "x", + ).join(" "); + expect(analyzeRaw(`SELECT ${manyTokens.replaceAll(" ", ",")}`)).toMatchObject({ + resource: "lexical-token", + status: "partial", + }); + + const provenPrefix = + "WITH a AS (SELECT 1) SELECT * FROM a "; + const exhaustedSuffix = Array.from( + { length: 8_193 }, + () => "x", + ).join(","); + const prefixLayout = analyzeRaw( + `${provenPrefix}${exhaustedSuffix}`, + ); + expect(prefixLayout).toMatchObject({ + resource: "lexical-token", + status: "partial", + }); + if (prefixLayout.status === "unavailable") { + throw new Error("Expected a partial prefix layout"); + } + const prefixVisibility = visibleSqlCtesAt( + prefixLayout, + provenPrefix.indexOf("FROM a") + 5, + ); + expect(prefixVisibility).toMatchObject({ + issues: [], + quality: "exact", + }); + expect( + prefixVisibility.ctes.map((cte) => cte.name.value), + ).toEqual(["a"]); + + const nestedPrefix = `SELECT ${"(".repeat(MAX_CTE_DEPTH)}`; + expect( + analyzeRaw( + `${nestedPrefix}WITH a(x) AS (SELECT 1) SELECT 1`, + ), + ).toMatchObject({ + resource: "parenthesis-depth", + status: "partial", + }); + expect( + analyzeRaw(`${nestedPrefix}WITH a AS (SELECT 1) SELECT 1`), + ).toMatchObject({ + resource: "parenthesis-depth", + status: "partial", + }); + expect( + analyzeRaw( + `${nestedPrefix}WITH a AS MATERIALIZED (SELECT 1) SELECT 1`, + ), + ).toMatchObject({ + resource: "parenthesis-depth", + status: "partial", + }); + + const manyFrames = Array.from( + { length: MAX_CTE_DECLARATIONS + 1 }, + (_, index) => + `(WITH f${index} AS (SELECT 1) SELECT 1)`, + ).join(","); + expect(analyzeRaw(`SELECT ${manyFrames}`)).toMatchObject({ + resource: "cte-frame", + status: "partial", + }); + + const acceptedBareFrames = Array.from( + { length: MAX_CTE_FRAMES }, + () => "(WITH)", + ).join(","); + const acceptedFrameLayout = analyzeRaw( + `SELECT ${acceptedBareFrames}`, + ); + expect(acceptedFrameLayout).toMatchObject({ + status: "partial", + }); + expect(acceptedFrameLayout).not.toHaveProperty("resource"); + if (acceptedFrameLayout.status === "unavailable") { + throw new Error("Expected a bounded partial frame layout"); + } + expect(acceptedFrameLayout.frames).toHaveLength(MAX_CTE_FRAMES); + + const rejectedBareFrames = `${acceptedBareFrames},(WITH)`; + expect(analyzeRaw(`SELECT ${rejectedBareFrames}`)).toMatchObject({ + resource: "cte-frame", + status: "partial", + }); + }); + + it("rejects malformed dialect grammar without scanning", () => { + const result = analyzeRaw("SELECT 1", { + ...postgres, + grammar: { + ...postgres.grammar, + maximumDeclarationsPerFrame: 0, + }, + }); + expect(result).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + + const throwingDialect: SqlCteLayoutDialect = { + ...postgres, + get grammar(): SqlCteLayoutDialect["grammar"] { + throw new Error("hostile"); + }, + }; + expect(analyzeRaw("SELECT 1", throwingDialect)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + + let getterInvoked = false; + const getterGrammar = { + ...postgres, + grammar: { + ...postgres.grammar, + get recursive(): boolean { + getterInvoked = true; + return true; + }, + }, + }; + expect(analyzeRaw("SELECT 1", getterGrammar)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + expect(getterInvoked).toBe(false); + + const getterLexicalProfile = { + ...postgres, + lexicalProfile: { + ...postgres.lexicalProfile, + get nestedBlockComments(): boolean { + getterInvoked = true; + return true; + }, + }, + }; + expect(analyzeRaw("SELECT 1", getterLexicalProfile)).toEqual({ + reason: "resource-limit", + status: "unavailable", + }); + expect(getterInvoked).toBe(false); + }); + + it("projects only proven frame phases and validates positions", () => { + const text = + "WITH a AS (SELECT 1) SELECT * FROM a"; + const layout = analyze(text); + expect(visibleSqlCtesAt(layout, 1)).toMatchObject({ + ctes: [], + issues: [], + quality: "exact", + }); + expect(names(layout, text.length)).toEqual(["a"]); + + const partial = analyze("WITH a AS ("); + expect(visibleSqlCtesAt(partial, partial.exactThrough)).toMatchObject({ + ctes: [], + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { coverage: "complete", names: [] }, + }); + for (const incompleteHeader of [ + "WITH", + "WITH /* trailing comment */", + "WITH a", + "WITH a AS", + "WITH a(x)", + "WITH a AS NOT", + ]) { + expect( + visibleSqlCtesAt( + analyze(incompleteHeader), + incompleteHeader.length, + ), + ).toMatchObject({ + ctes: [], + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + + const nestedBareWith = + "WITH outer_cte AS (SELECT 1) SELECT * FROM (WITH) q,"; + const nestedLayout = analyze(nestedBareWith); + const nestedClose = nestedBareWith.lastIndexOf(")"); + expect(visibleSqlCtesAt(nestedLayout, nestedClose)).toMatchObject({ + ctes: [ + { + name: { quoted: false, value: "outer_cte" }, + }, + ], + issues: ["ambiguous-cte-header"], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + expect( + visibleSqlCtesAt(nestedLayout, nestedBareWith.length), + ).toMatchObject({ + ctes: [ + { + name: { quoted: false, value: "outer_cte" }, + }, + ], + issues: [], + quality: "exact", + shadowing: { + coverage: "complete", + names: [{ quoted: false, value: "outer_cte" }], + }, + }); + for (const position of [-1, Number.NaN, text.length + 1]) { + expect(visibleSqlCtesAt(layout, position)).toMatchObject({ + ctes: [], + quality: "recovered", + shadowing: { coverage: "unknown" }, + }); + } + }); +}); diff --git a/src/vnext/bounded-sql-lexer.ts b/src/vnext/bounded-sql-lexer.ts index de86136..5996b9c 100644 --- a/src/vnext/bounded-sql-lexer.ts +++ b/src/vnext/bounded-sql-lexer.ts @@ -46,6 +46,7 @@ export class BoundedSqlLexer { #pushed: BoundedSqlLexeme | null = null; #regionIndex: number; resource: BoundedSqlLexerResource | null = null; + resourceAt: number | null = null; constructor( source: SqlSourceSnapshot, @@ -143,6 +144,7 @@ export class BoundedSqlLexer { this.#cursor = result.to; if (result.delimiterTooLong) { this.resource = "dollar-quote-delimiter"; + this.resourceAt = from; return null; } return this.#record({ @@ -262,6 +264,7 @@ export class BoundedSqlLexer { this.#lexemeCount += 1; if (this.#lexemeCount > MAX_BOUNDED_SQL_LEXEMES) { this.resource = "lexical-token"; + this.resourceAt = lexeme.from; return null; } return lexeme; diff --git a/src/vnext/cte-layout.ts b/src/vnext/cte-layout.ts new file mode 100644 index 0000000..01491dd --- /dev/null +++ b/src/vnext/cte-layout.ts @@ -0,0 +1,1771 @@ +import { + BoundedSqlLexer, + type BoundedSqlLexeme as Lexeme, + type BoundedSqlLexerResource, +} from "./bounded-sql-lexer.js"; +import type { SqlLexicalProfile } from "./lexical.js"; +import type { SqlSourceSnapshot } from "./source.js"; +import type { ExactSqlStatementSlot } from "./statement-index.js"; +import type { SqlIdentifierComponent } from "./types.js"; + +const cteRangeBrand: unique symbol = Symbol("SqlCteRange"); + +export const MAX_CTE_STATEMENT_LENGTH = 65_536; +export const MAX_CTE_DEPTH = 128; +export const MAX_CTE_DECLARATIONS = 256; +export const MAX_CTE_FRAMES = 256; +export const MAX_CTE_IDENTIFIER_LENGTH = 256; + +export interface SqlCteRange { + readonly [cteRangeBrand]: "SqlCteRange"; + readonly from: number; + readonly to: number; +} + +export type SqlCteLayoutIssue = + | "ambiguous-cte-header" + | "duplicate-cte-name" + | "opaque-template-context" + | "recursive-cte-position" + | "unknown-cte-identifier-equivalence" + | "unsupported-cte-extension"; + +export type SqlCteLayoutResource = + | "active-statement" + | "cte-declaration" + | "cte-frame" + | "identifier-segment" + | "lexical-token" + | "parenthesis-depth"; + +export interface SqlCteIdentifier { + readonly component: SqlIdentifierComponent; +} + +export type SqlCteIdentifierResult = + | { + readonly status: "identifier"; + readonly value: SqlCteIdentifier; + } + | { + readonly status: "unsupported"; + }; + +export interface SqlCteGrammar { + readonly declaredColumns: boolean; + readonly materialization: boolean; + readonly maximumDeclarationsPerFrame: number; + readonly recursive: boolean; +} + +export interface SqlCteLayoutDialect { + readonly classifyIdentifierToken: ( + rawIdentifier: string, + quoted: boolean, + role: "cte-column" | "cte-name", + ) => SqlCteIdentifierResult; + readonly compareCteIdentifiers: ( + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, + ) => "distinct" | "equal" | "unknown"; + readonly grammar: SqlCteGrammar; + readonly lexicalProfile: SqlLexicalProfile; +} + +export interface SqlCteDeclaration { + readonly ambiguous: boolean; + readonly bodyRange: SqlCteRange; + readonly equivalenceClass: number; + readonly frameIndex: number; + readonly name: SqlIdentifierComponent; + readonly nameRange: SqlCteRange; + readonly ordinal: number; + readonly sourceSpelling: string; + readonly unknownEquivalenceClasses: readonly number[]; +} + +export interface SqlCteDraftDeclaration { + readonly ambiguous: boolean; + readonly bodyRange: SqlCteRange; + readonly equivalenceClass: number; + readonly frameIndex: number; + readonly name: SqlIdentifierComponent; + readonly nameRange: SqlCteRange; + readonly ordinal: number; + readonly sourceSpelling: string; + readonly unknownEquivalenceClasses: readonly number[]; +} + +export interface SqlCteFrame { + readonly baseDepth: number; + readonly declarationIndexes: readonly number[]; + readonly issues: readonly SqlCteLayoutIssue[]; + readonly mainQueryStart: number | null; + readonly parentFrameIndex: number | null; + readonly recursive: boolean; + readonly scopeRange: SqlCteRange; + readonly withStart: number; +} + +export interface SqlCteMainQueryEntrypoint { + readonly depth: number; + readonly frameIndex: number; + readonly from: number; +} + +interface SqlCteLayoutBase { + readonly declarations: readonly SqlCteDeclaration[]; + readonly draftDeclarations: readonly SqlCteDraftDeclaration[]; + readonly exactThrough: number; + readonly frames: readonly SqlCteFrame[]; + readonly issues: readonly SqlCteLayoutIssue[]; + readonly mainQueryEntrypoints: readonly SqlCteMainQueryEntrypoint[]; + readonly statementLength: number; +} + +export type SqlCteLayout = + | (SqlCteLayoutBase & { + readonly status: "ready"; + readonly issues: readonly []; + }) + | (SqlCteLayoutBase & { + readonly status: "partial"; + readonly issues: readonly [ + SqlCteLayoutIssue, + ...SqlCteLayoutIssue[], + ]; + readonly resource?: SqlCteLayoutResource; + }) + | { + readonly status: "unavailable"; + readonly reason: "opaque-statement" | "resource-limit"; + readonly resource?: SqlCteLayoutResource; + }; + +export interface SqlVisibleCte { + readonly declarationPosition: number; + readonly name: SqlIdentifierComponent; + readonly sourceSpelling: string; +} + +export type SqlCteShadowing = + | { + readonly coverage: "complete"; + readonly names: readonly SqlIdentifierComponent[]; + } + | { + readonly coverage: "unknown"; + }; + +export interface SqlCteVisibility { + readonly ctes: readonly SqlVisibleCte[]; + readonly issues: readonly SqlCteLayoutIssue[]; + readonly quality: "exact" | "recovered"; + readonly shadowing: SqlCteShadowing; +} + +type HeaderState = + | "after-as" + | "after-body" + | "after-name" + | "columns" + | "expect-as" + | "expect-body" + | "expect-materialized" + | "expect-name" + | "main" + | "modifier-or-name" + | "waiting-body"; + +interface DraftDeclaration { + bodyFrom: number; + bodyLead: "select" | "with" | null; + bodyLeadFrameIndex: number | null; + component: SqlIdentifierComponent; + nameFrom: number; + nameTo: number; + sourceSpelling: string; +} + +interface MutableDeclaration { + ambiguous: boolean; + bodyFrom: number; + bodyTo: number; + identityIndex: number; + frameIndex: number; + name: SqlIdentifierComponent; + nameFrom: number; + nameTo: number; + ordinal: number; + sourceSpelling: string; +} + +interface MutableDraftDeclaration { + ambiguous: boolean; + bodyFrom: number; + bodyTo: number; + identityIndex: number; + frameIndex: number; + name: SqlIdentifierComponent; + nameFrom: number; + nameTo: number; + ordinal: number; + sourceSpelling: string; +} + +interface MutableFrame { + baseDepth: number; + columnCount: number; + columnExpectIdentifier: boolean; + current: DraftDeclaration | null; + declarationIndexes: number[]; + index: number; + issues: Set; + mainQueryStart: number | null; + parentFrameIndex: number | null; + recursive: boolean; + scopeFrom: number; + scopeTo: number | null; + state: HeaderState; + withStart: number; +} + +interface Builder { + frame: MutableFrame | null; + leadingParent: MutableFrame | null; + withStart: number; +} + +interface IdentifierRelations { + readonly components: SqlIdentifierComponent[]; + readonly frameIndexes: number[]; + readonly unknownIndexes: Map>; + readonly parents: number[]; +} + +const LEXER_RESOURCES: Readonly< + Record +> = Object.freeze({ + "dollar-quote-delimiter": "identifier-segment", + "lexical-token": "lexical-token", +}); + +const missingDataProperty: unique symbol = Symbol( + "missingDataProperty", +); + +function readOwnDataProperty( + value: unknown, + key: PropertyKey, +): unknown | typeof missingDataProperty { + if (value === null || typeof value !== "object") { + return missingDataProperty; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor + ? descriptor.value + : missingDataProperty; +} + +function createRange(from: number, to: number): SqlCteRange { + const range: SqlCteRange = { + [cteRangeBrand]: "SqlCteRange", + from, + to, + }; + return Object.freeze(range); +} + +function wordEquals( + text: string, + token: Lexeme, + expected: string, +): boolean { + if (token.to - token.from !== expected.length) { + return false; + } + for (let index = 0; index < expected.length; index += 1) { + if ( + (text.charCodeAt(token.from + index) | 32) !== + expected.charCodeAt(index) + ) { + return false; + } + } + return true; +} + +function isComment(token: Lexeme): boolean { + return token.kind === "comment" || token.kind === "line-comment"; +} + +function isIdentifierToken(token: Lexeme): boolean { + return token.kind === "word" || token.kind === "quoted-identifier"; +} + +function punctuation(text: string, token: Lexeme): number { + return token.kind === "punctuation" + ? text.charCodeAt(token.from) + : -1; +} + +function freezeComponent( + component: SqlIdentifierComponent, +): SqlIdentifierComponent { + return Object.freeze({ + quoted: component.quoted, + value: component.value, + }); +} + +function normalizeIdentifier( + dialect: SqlCteLayoutDialect, + text: string, + token: Lexeme, + role: "cte-column" | "cte-name", +): SqlCteIdentifier | null { + if (!token.closed || !isIdentifierToken(token)) { + return null; + } + const raw = text.slice(token.from, token.to); + const maximumRawLength = + token.kind === "quoted-identifier" + ? MAX_CTE_IDENTIFIER_LENGTH * 2 + 2 + : MAX_CTE_IDENTIFIER_LENGTH; + if (raw.length > maximumRawLength) { + return null; + } + let result: SqlCteIdentifierResult; + try { + result = dialect.classifyIdentifierToken( + raw, + token.kind === "quoted-identifier", + role, + ); + const status = readOwnDataProperty(result, "status"); + if (status !== "identifier") { + return null; + } + const identifier = readOwnDataProperty(result, "value"); + const component = readOwnDataProperty(identifier, "component"); + const componentValue = readOwnDataProperty(component, "value"); + const componentQuoted = readOwnDataProperty( + component, + "quoted", + ); + if ( + typeof componentValue !== "string" || + typeof componentQuoted !== "boolean" || + componentQuoted !== (token.kind === "quoted-identifier") || + componentValue.length === 0 || + componentValue.length > MAX_CTE_IDENTIFIER_LENGTH + ) { + return null; + } + return Object.freeze({ + component: freezeComponent({ + quoted: componentQuoted, + value: componentValue, + }), + }); + } catch { + return null; + } +} + +function validateDialect( + dialect: SqlCteLayoutDialect, +): SqlCteLayoutDialect | null { + try { + const grammar = readOwnDataProperty(dialect, "grammar"); + const lexicalProfile = readOwnDataProperty( + dialect, + "lexicalProfile", + ); + const classifyIdentifierToken = readOwnDataProperty( + dialect, + "classifyIdentifierToken", + ); + const compareCteIdentifiers = readOwnDataProperty( + dialect, + "compareCteIdentifiers", + ); + const declaredColumns = readOwnDataProperty( + grammar, + "declaredColumns", + ); + const materialization = readOwnDataProperty( + grammar, + "materialization", + ); + const maximumDeclarationsPerFrame = readOwnDataProperty( + grammar, + "maximumDeclarationsPerFrame", + ); + const recursive = readOwnDataProperty(grammar, "recursive"); + const backtickQuotedIdentifiers = readOwnDataProperty( + lexicalProfile, + "backtickQuotedIdentifiers", + ); + const bigQueryStrings = readOwnDataProperty( + lexicalProfile, + "bigQueryStrings", + ); + const dollarQuotedStrings = readOwnDataProperty( + lexicalProfile, + "dollarQuotedStrings", + ); + const hashLineComments = readOwnDataProperty( + lexicalProfile, + "hashLineComments", + ); + const nestedBlockComments = readOwnDataProperty( + lexicalProfile, + "nestedBlockComments", + ); + const proceduralGuards = readOwnDataProperty( + lexicalProfile, + "proceduralGuards", + ); + const singleQuoteBackslash = readOwnDataProperty( + lexicalProfile, + "singleQuoteBackslash", + ); + if ( + typeof classifyIdentifierToken !== "function" || + typeof compareCteIdentifiers !== "function" || + typeof declaredColumns !== "boolean" || + typeof materialization !== "boolean" || + typeof recursive !== "boolean" || + !Number.isSafeInteger(maximumDeclarationsPerFrame) || + (maximumDeclarationsPerFrame as number) < 1 || + (maximumDeclarationsPerFrame as number) > + MAX_CTE_DECLARATIONS || + typeof backtickQuotedIdentifiers !== "boolean" || + typeof bigQueryStrings !== "boolean" || + typeof dollarQuotedStrings !== "boolean" || + typeof hashLineComments !== "boolean" || + typeof nestedBlockComments !== "boolean" || + !["bigquery", "none", "postgresql"].includes( + proceduralGuards as string, + ) || + !["always", "e-prefix", "never"].includes( + singleQuoteBackslash as string, + ) + ) { + return null; + } + return Object.freeze({ + classifyIdentifierToken: + classifyIdentifierToken as SqlCteLayoutDialect["classifyIdentifierToken"], + compareCteIdentifiers: + compareCteIdentifiers as SqlCteLayoutDialect["compareCteIdentifiers"], + grammar: Object.freeze({ + declaredColumns, + materialization, + maximumDeclarationsPerFrame: + maximumDeclarationsPerFrame as number, + recursive, + }), + lexicalProfile: Object.freeze({ + backtickQuotedIdentifiers, + bigQueryStrings, + dollarQuotedStrings, + hashLineComments, + nestedBlockComments, + proceduralGuards: + proceduralGuards as SqlLexicalProfile["proceduralGuards"], + singleQuoteBackslash: + singleQuoteBackslash as SqlLexicalProfile["singleQuoteBackslash"], + }), + }); + } catch { + return null; + } +} + +function relationRoot( + relations: IdentifierRelations, + index: number, +): number { + let root = index; + while (relations.parents[root] !== root) { + root = relations.parents[root] ?? root; + } + let cursor = index; + while (relations.parents[cursor] !== root) { + const parent = relations.parents[cursor] ?? root; + relations.parents[cursor] = root; + cursor = parent; + } + return root; +} + +function compareIdentifiers( + dialect: SqlCteLayoutDialect, + left: SqlIdentifierComponent, + right: SqlIdentifierComponent, +): "distinct" | "equal" | "unknown" { + try { + const forward = dialect.compareCteIdentifiers(left, right); + const reverse = dialect.compareCteIdentifiers(right, left); + return forward === reverse && + (forward === "distinct" || + forward === "equal" || + forward === "unknown") + ? forward + : "unknown"; + } catch { + return "unknown"; + } +} + +function isAncestorFrame( + frames: readonly MutableFrame[], + possibleAncestor: number, + descendant: number, +): boolean { + let cursor: number | null = descendant; + while (cursor !== null) { + if (cursor === possibleAncestor) { + return true; + } + cursor = frames[cursor]?.parentFrameIndex ?? null; + } + return false; +} + +function registerIdentifier( + dialect: SqlCteLayoutDialect, + frames: readonly MutableFrame[], + frame: MutableFrame, + component: SqlIdentifierComponent, + relations: IdentifierRelations, +): number { + const markUnknown = (left: number, right: number): void => { + frame.issues.add("unknown-cte-identifier-equivalence"); + const leftUnknown = + relations.unknownIndexes.get(left) ?? new Set(); + leftUnknown.add(right); + relations.unknownIndexes.set(left, leftUnknown); + const rightUnknown = + relations.unknownIndexes.get(right) ?? new Set(); + rightUnknown.add(left); + relations.unknownIndexes.set(right, rightUnknown); + }; + const identityIndex = relations.components.length; + relations.components.push(component); + relations.frameIndexes.push(frame.index); + relations.parents.push(identityIndex); + if ( + compareIdentifiers(dialect, component, component) !== "equal" + ) { + markUnknown(identityIndex, identityIndex); + } + const comparisonsByRoot = new Map< + number, + Map<"distinct" | "equal" | "unknown", number[]> + >(); + for (let priorIndex = 0; priorIndex < identityIndex; priorIndex += 1) { + const priorFrameIndex = relations.frameIndexes[priorIndex]; + const priorComponent = relations.components[priorIndex]; + if ( + priorFrameIndex === undefined || + !priorComponent || + (priorFrameIndex !== frame.index && + !isAncestorFrame( + frames, + priorFrameIndex, + frame.index, + )) + ) { + continue; + } + const comparison = compareIdentifiers( + dialect, + priorComponent, + component, + ); + const root = relationRoot(relations, priorIndex); + const byComparison = + comparisonsByRoot.get(root) ?? new Map(); + const indexes = byComparison.get(comparison) ?? []; + indexes.push(priorIndex); + byComparison.set(comparison, indexes); + comparisonsByRoot.set(root, byComparison); + } + const equalRoots = [...comparisonsByRoot.entries()].filter( + ([, comparisons]) => comparisons.has("equal"), + ); + const inconsistent = + equalRoots.length > 1 || + equalRoots.some(([, comparisons]) => comparisons.size > 1); + for (const [root, comparisons] of comparisonsByRoot) { + if (inconsistent && comparisons.has("equal")) { + for (const indexes of comparisons.values()) { + for (const priorIndex of indexes) { + markUnknown(identityIndex, priorIndex); + } + } + continue; + } + for (const priorIndex of comparisons.get("unknown") ?? []) { + markUnknown(identityIndex, priorIndex); + } + if (comparisons.has("equal")) { + relations.parents[identityIndex] = root; + } + } + return identityIndex; +} + +function freezeIssues( + issues: Iterable, +): readonly SqlCteLayoutIssue[] { + return Object.freeze([...new Set(issues)].sort()); +} + +function layoutUnavailable( + reason: "opaque-statement" | "resource-limit", + resource?: SqlCteLayoutResource, +): SqlCteLayout { + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, status: "unavailable" }, + ); +} + +function findOpenParentFrame( + frames: readonly MutableFrame[], + depth: number, +): number | null { + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index]; + if ( + frame && + frame.baseDepth < depth && + frame.scopeTo === null + ) { + return frame.index; + } + } + return null; +} + +function createFrame( + frames: MutableFrame[], + depth: number, + withStart: number, + leadingParent: MutableFrame | null, +): MutableFrame { + const frame: MutableFrame = { + baseDepth: depth, + columnCount: 0, + columnExpectIdentifier: true, + current: null, + declarationIndexes: [], + index: frames.length, + issues: new Set(), + mainQueryStart: null, + parentFrameIndex: findOpenParentFrame(frames, depth), + recursive: false, + scopeFrom: withStart, + scopeTo: null, + state: "modifier-or-name", + withStart, + }; + frames.push(frame); + if (leadingParent?.current) { + leadingParent.current.bodyLeadFrameIndex = frame.index; + } + return frame; +} + +function markPartial( + frame: MutableFrame | null, + issues: Set, + issue: SqlCteLayoutIssue, +): void { + issues.add(issue); + frame?.issues.add(issue); +} + +function processName( + frame: MutableFrame, + dialect: SqlCteLayoutDialect, + text: string, + token: Lexeme, + statementFrom: number, + declarationAttempts: { value: number }, +): boolean { + const identifier = normalizeIdentifier( + dialect, + text, + token, + "cte-name", + ); + if (!identifier) { + return false; + } + declarationAttempts.value += 1; + if (declarationAttempts.value > MAX_CTE_DECLARATIONS) { + return false; + } + frame.current = { + bodyFrom: -1, + bodyLead: null, + bodyLeadFrameIndex: null, + component: identifier.component, + nameFrom: token.from - statementFrom, + nameTo: token.to - statementFrom, + sourceSpelling: text.slice(token.from, token.to), + }; + frame.state = "after-name"; + return true; +} + +function commitDeclaration( + frame: MutableFrame, + current: DraftDeclaration, + declarations: MutableDeclaration[], + dialect: SqlCteLayoutDialect, + frames: readonly MutableFrame[], + relations: IdentifierRelations, + bodyTo: number, +): void { + const declarationIndex = declarations.length; + const identityIndex = registerIdentifier( + dialect, + frames, + frame, + current.component, + relations, + ); + const declaration: MutableDeclaration = { + ambiguous: false, + bodyFrom: current.bodyFrom, + bodyTo, + frameIndex: frame.index, + identityIndex, + name: current.component, + nameFrom: current.nameFrom, + nameTo: current.nameTo, + ordinal: frame.declarationIndexes.length, + sourceSpelling: current.sourceSpelling, + }; + const root = relationRoot(relations, identityIndex); + for (const priorIndex of frame.declarationIndexes) { + const priorDeclaration = declarations[priorIndex]; + if ( + priorDeclaration && + relationRoot(relations, priorDeclaration.identityIndex) === root + ) { + declaration.ambiguous = true; + priorDeclaration.ambiguous = true; + frame.issues.add("duplicate-cte-name"); + } + } + declarations.push(declaration); + frame.declarationIndexes.push(declarationIndex); + frame.current = null; + frame.state = "after-body"; +} + +function collectActiveBodyEvidence( + frames: readonly MutableFrame[], + declarations: MutableDeclaration[], + dialect: SqlCteLayoutDialect, + relations: IdentifierRelations, + exactThrough: number, +): MutableDraftDeclaration[] { + const drafts: MutableDraftDeclaration[] = []; + for (const frame of frames) { + const current = frame.current; + if ( + current && + current.bodyFrom >= 0 && + current.bodyFrom <= exactThrough + ) { + const identityIndex = registerIdentifier( + dialect, + frames, + frame, + current.component, + relations, + ); + let ambiguous = false; + const root = relationRoot(relations, identityIndex); + for (const priorIndex of frame.declarationIndexes) { + const priorDeclaration = declarations[priorIndex]; + if ( + priorDeclaration && + relationRoot( + relations, + priorDeclaration.identityIndex, + ) === root + ) { + ambiguous = true; + priorDeclaration.ambiguous = true; + frame.issues.add("duplicate-cte-name"); + } + } + drafts.push({ + ambiguous, + bodyFrom: current.bodyFrom, + bodyTo: exactThrough, + frameIndex: frame.index, + identityIndex, + name: current.component, + nameFrom: current.nameFrom, + nameTo: current.nameTo, + ordinal: frame.declarationIndexes.length, + sourceSpelling: current.sourceSpelling, + }); + } + } + return drafts; +} + +function closeNestedFrame( + frames: MutableFrame[], + builders: Map, + depth: number, + at: number, + issues: Set, +): boolean { + const builder = builders.get(depth); + if (!builder) { + return true; + } + if (!builder.frame && frames.length >= MAX_CTE_FRAMES) { + return false; + } + const frame = + builder.frame ?? + createFrame( + frames, + depth, + builder.withStart, + builder.leadingParent, + ); + if (frame.baseDepth !== depth) { + return true; + } + if (!builder.frame) { + builder.frame = frame; + markPartial(frame, issues, "ambiguous-cte-header"); + } + frame.scopeTo = at; + builders.delete(depth); + return true; +} + +function freezeLayout( + frames: readonly MutableFrame[], + declarations: readonly MutableDeclaration[], + draftDeclarations: readonly MutableDraftDeclaration[], + relations: IdentifierRelations, + statementLength: number, + exactThrough: number, + issues: Set, + resource?: SqlCteLayoutResource, +): SqlCteLayout { + const unknownClasses = (identityIndex: number): readonly number[] => + Object.freeze( + [ + ...new Set( + [...(relations.unknownIndexes.get(identityIndex) ?? [])].map( + (index) => relationRoot(relations, index), + ), + ), + ].sort((left, right) => left - right), + ); + const frozenDeclarations = Object.freeze( + declarations.map((declaration) => + Object.freeze({ + ambiguous: declaration.ambiguous, + bodyRange: createRange( + declaration.bodyFrom, + declaration.bodyTo, + ), + equivalenceClass: relationRoot( + relations, + declaration.identityIndex, + ), + frameIndex: declaration.frameIndex, + name: declaration.name, + nameRange: createRange( + declaration.nameFrom, + declaration.nameTo, + ), + ordinal: declaration.ordinal, + sourceSpelling: declaration.sourceSpelling, + unknownEquivalenceClasses: unknownClasses( + declaration.identityIndex, + ), + }), + ), + ); + const frozenDraftDeclarations = Object.freeze( + draftDeclarations.map((declaration) => + Object.freeze({ + ambiguous: declaration.ambiguous, + bodyRange: createRange( + declaration.bodyFrom, + declaration.bodyTo, + ), + equivalenceClass: relationRoot( + relations, + declaration.identityIndex, + ), + frameIndex: declaration.frameIndex, + name: declaration.name, + nameRange: createRange( + declaration.nameFrom, + declaration.nameTo, + ), + ordinal: declaration.ordinal, + sourceSpelling: declaration.sourceSpelling, + unknownEquivalenceClasses: unknownClasses( + declaration.identityIndex, + ), + }), + ), + ); + const frozenFrames = Object.freeze( + frames.map((frame) => { + const frameIssues = freezeIssues(frame.issues); + return Object.freeze({ + baseDepth: frame.baseDepth, + declarationIndexes: Object.freeze([ + ...frame.declarationIndexes, + ]), + issues: frameIssues, + mainQueryStart: frame.mainQueryStart, + parentFrameIndex: frame.parentFrameIndex, + recursive: frame.recursive, + scopeRange: createRange( + frame.scopeFrom, + frame.scopeTo ?? exactThrough, + ), + withStart: frame.withStart, + }); + }), + ); + const mainQueryEntrypoints = Object.freeze( + frozenFrames + .map((frame, frameIndex) => + frame.mainQueryStart === null + ? null + : Object.freeze({ + depth: frame.baseDepth, + frameIndex, + from: frame.mainQueryStart, + }), + ) + .filter( + ( + entrypoint, + ): entrypoint is SqlCteMainQueryEntrypoint => + entrypoint !== null, + ) + .sort((left, right) => left.from - right.from), + ); + for (const frame of frozenFrames) { + for (const issue of frame.issues) { + issues.add(issue); + } + } + const frozenIssues = freezeIssues(issues); + const base = { + declarations: frozenDeclarations, + draftDeclarations: frozenDraftDeclarations, + exactThrough, + frames: frozenFrames, + issues: frozenIssues, + mainQueryEntrypoints, + statementLength, + }; + if (frozenIssues.length === 0 && resource === undefined) { + const noIssues: readonly [] = Object.freeze([]); + return Object.freeze({ + ...base, + issues: noIssues, + status: "ready", + }); + } + const partial = { + ...base, + issues: frozenIssues as readonly [ + SqlCteLayoutIssue, + ...SqlCteLayoutIssue[], + ], + status: "partial" as const, + }; + return Object.freeze( + resource === undefined ? partial : { ...partial, resource }, + ); +} + +export function analyzeSqlCteLayout( + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, +): SqlCteLayout { + const statementLength = slot.source.to - slot.source.from; + if (statementLength > MAX_CTE_STATEMENT_LENGTH) { + return layoutUnavailable("resource-limit", "active-statement"); + } + const validatedDialect = validateDialect(dialect); + if (!validatedDialect) { + return layoutUnavailable("resource-limit"); + } + const { grammar, lexicalProfile } = validatedDialect; + const text = source.analysisText; + const statementFrom = slot.source.from; + const lexer = new BoundedSqlLexer( + source, + statementFrom, + slot.source.to, + lexicalProfile, + ); + const declarations: MutableDeclaration[] = []; + const relations: IdentifierRelations = { + components: [], + frameIndexes: [], + unknownIndexes: new Map(), + parents: [], + }; + const frames: MutableFrame[] = []; + const builders = new Map(); + const bodyOwners = new Map(); + const columnOwners = new Map(); + const queryCandidates = new Set([0]); + const issues = new Set(); + const declarationAttempts = { value: 0 }; + let depth = 0; + let exactThrough = statementLength; + let resource: SqlCteLayoutResource | undefined; + + scan: while (true) { + const token = lexer.next(); + if (lexer.resource) { + exactThrough = Math.max( + 0, + (lexer.resourceAt ?? statementFrom) - statementFrom, + ); + resource = LEXER_RESOURCES[lexer.resource]; + issues.add("ambiguous-cte-header"); + break; + } + if (!token) { + break; + } + if (isComment(token)) { + continue; + } + if (token.kind === "barrier") { + exactThrough = token.from - statementFrom; + issues.add("opaque-template-context"); + for (const frame of frames) { + if (frame.scopeTo === null) { + frame.issues.add("opaque-template-context"); + } + } + break; + } + const code = punctuation(text, token); + + if (code === 41) { + const columnFrame = columnOwners.get(depth); + if (columnFrame) { + if ( + columnFrame.columnExpectIdentifier || + columnFrame.columnCount === 0 + ) { + exactThrough = token.from - statementFrom; + markPartial( + columnFrame, + issues, + "ambiguous-cte-header", + ); + break; + } + columnFrame.state = "expect-as"; + columnOwners.delete(depth); + depth -= 1; + queryCandidates.delete(depth + 1); + continue; + } + + if ( + !closeNestedFrame( + frames, + builders, + depth, + token.from - statementFrom, + issues, + ) + ) { + exactThrough = + builders.get(depth)?.withStart ?? + token.from - statementFrom; + resource = "cte-frame"; + issues.add("ambiguous-cte-header"); + break; + } + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner) { + const current = bodyOwner.current; + if ( + !current || + current.bodyLead === null || + (current.bodyLead === "with" && + (current.bodyLeadFrameIndex === null || + frames[current.bodyLeadFrameIndex]?.mainQueryStart === + null)) + ) { + exactThrough = token.from - statementFrom; + markPartial( + bodyOwner, + issues, + "ambiguous-cte-header", + ); + break; + } + commitDeclaration( + bodyOwner, + current, + declarations, + validatedDialect, + frames, + relations, + token.from - statementFrom, + ); + bodyOwners.delete(depth); + depth -= 1; + queryCandidates.delete(depth + 1); + continue; + } + queryCandidates.delete(depth); + depth = Math.max(0, depth - 1); + continue; + } + + const queryCandidate = queryCandidates.has(depth); + if (queryCandidate && token.kind === "word") { + if (wordEquals(text, token, "with")) { + const leadingParent = bodyOwners.get(depth) ?? null; + builders.set(depth, { + frame: null, + leadingParent, + withStart: token.from - statementFrom, + }); + if (leadingParent?.current) { + leadingParent.current.bodyLead = "with"; + } + queryCandidates.delete(depth); + continue; + } + if (wordEquals(text, token, "select")) { + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner?.current) { + bodyOwner.current.bodyLead = "select"; + } + queryCandidates.delete(depth); + } else { + queryCandidates.delete(depth); + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner) { + exactThrough = token.from - statementFrom; + markPartial( + bodyOwner, + issues, + "unsupported-cte-extension", + ); + break; + } + } + } else if (queryCandidate && code !== 40) { + queryCandidates.delete(depth); + const bodyOwner = bodyOwners.get(depth); + if (bodyOwner) { + exactThrough = token.from - statementFrom; + markPartial( + bodyOwner, + issues, + "unsupported-cte-extension", + ); + break; + } + } + + const builder = builders.get(depth); + let frame = builder?.frame ?? null; + if (builder && !frame) { + if (frames.length >= MAX_CTE_FRAMES) { + exactThrough = builder.withStart; + resource = "cte-frame"; + issues.add("ambiguous-cte-header"); + break; + } + frame = createFrame( + frames, + depth, + builder.withStart, + builder.leadingParent, + ); + builder.frame = frame; + } + + if (frame && depth === frame.baseDepth) { + if (frame.state === "modifier-or-name") { + if ( + token.kind === "word" && + wordEquals(text, token, "recursive") + ) { + if (!grammar.recursive) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + frame.recursive = true; + frame.state = "expect-name"; + continue; + } + if ( + !processName( + frame, + validatedDialect, + text, + token, + statementFrom, + declarationAttempts, + ) + ) { + exactThrough = token.from - statementFrom; + if ( + declarationAttempts.value > MAX_CTE_DECLARATIONS + ) { + resource = "cte-declaration"; + } + markPartial( + frame, + issues, + "ambiguous-cte-header", + ); + break; + } + continue; + } + if (frame.state === "expect-name") { + if ( + frame.declarationIndexes.length >= + grammar.maximumDeclarationsPerFrame + ) { + exactThrough = token.from - statementFrom; + if ( + grammar.maximumDeclarationsPerFrame === + MAX_CTE_DECLARATIONS + ) { + resource = "cte-declaration"; + } + markPartial( + frame, + issues, + resource === "cte-declaration" + ? "ambiguous-cte-header" + : "unsupported-cte-extension", + ); + break; + } + if ( + !processName( + frame, + validatedDialect, + text, + token, + statementFrom, + declarationAttempts, + ) + ) { + exactThrough = token.from - statementFrom; + if ( + declarationAttempts.value > MAX_CTE_DECLARATIONS + ) { + resource = "cte-declaration"; + } + markPartial( + frame, + issues, + "ambiguous-cte-header", + ); + break; + } + continue; + } + if (frame.state === "after-name") { + if (code === 40) { + if (!grammar.declaredColumns) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + markPartial( + frame, + issues, + "ambiguous-cte-header", + ); + break; + } + frame.columnCount = 0; + frame.columnExpectIdentifier = true; + frame.state = "columns"; + columnOwners.set(depth, frame); + continue; + } + if ( + token.kind === "word" && + wordEquals(text, token, "as") + ) { + frame.state = "after-as"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + if (frame.state === "expect-as") { + if ( + token.kind === "word" && + wordEquals(text, token, "as") + ) { + frame.state = "after-as"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + if (frame.state === "after-as") { + if ( + token.kind === "word" && + wordEquals(text, token, "not") + ) { + if (!grammar.materialization) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + frame.state = "expect-materialized"; + continue; + } + if ( + token.kind === "word" && + wordEquals(text, token, "materialized") + ) { + if (!grammar.materialization) { + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + frame.state = "expect-body"; + continue; + } + if (code !== 40 || !frame.current) { + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + frame.current.bodyFrom = token.to - statementFrom; + frame.state = "waiting-body"; + bodyOwners.set(depth, frame); + queryCandidates.add(depth); + continue; + } + if (frame.state === "expect-body") { + if (code !== 40 || !frame.current) { + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + frame.current.bodyFrom = token.to - statementFrom; + frame.state = "waiting-body"; + bodyOwners.set(depth, frame); + queryCandidates.add(depth); + continue; + } + if (frame.state === "expect-materialized") { + if ( + token.kind === "word" && + wordEquals(text, token, "materialized") + ) { + frame.state = "expect-body"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial(frame, issues, "ambiguous-cte-header"); + break; + } + if (frame.state === "after-body") { + if (code === 44) { + frame.state = "expect-name"; + continue; + } + if ( + token.kind === "word" && + wordEquals(text, token, "select") + ) { + frame.mainQueryStart = token.from - statementFrom; + frame.state = "main"; + continue; + } + exactThrough = token.from - statementFrom; + markPartial( + frame, + issues, + "unsupported-cte-extension", + ); + break; + } + } + + const columnOwner = columnOwners.get(depth); + if (columnOwner) { + if (columnOwner.columnExpectIdentifier) { + if ( + !normalizeIdentifier( + validatedDialect, + text, + token, + "cte-column", + ) + ) { + exactThrough = token.from - statementFrom; + markPartial( + columnOwner, + issues, + "ambiguous-cte-header", + ); + break; + } + columnOwner.columnCount += 1; + columnOwner.columnExpectIdentifier = false; + continue; + } + if (code === 44) { + columnOwner.columnExpectIdentifier = true; + continue; + } + exactThrough = token.from - statementFrom; + markPartial( + columnOwner, + issues, + "ambiguous-cte-header", + ); + break; + } + + if (code === 40) { + depth += 1; + if (depth > MAX_CTE_DEPTH) { + exactThrough = token.from - statementFrom; + resource = "parenthesis-depth"; + issues.add("ambiguous-cte-header"); + break scan; + } + queryCandidates.add(depth); + } + } + + if (exactThrough === statementLength) { + for (const [builderDepth, builder] of builders) { + if (!builder.frame) { + if (frames.length >= MAX_CTE_FRAMES) { + exactThrough = builder.withStart; + resource = "cte-frame"; + issues.add("ambiguous-cte-header"); + break; + } + builder.frame = createFrame( + frames, + builderDepth, + builder.withStart, + builder.leadingParent, + ); + } + } + } + for (const frame of frames) { + if (frame.scopeTo === null) { + frame.scopeTo = exactThrough; + } + if ( + frame.state !== "main" && + exactThrough === statementLength + ) { + markPartial(frame, issues, "ambiguous-cte-header"); + } + } + const draftDeclarations = collectActiveBodyEvidence( + frames, + declarations, + validatedDialect, + relations, + exactThrough, + ); + return freezeLayout( + frames, + declarations, + draftDeclarations, + relations, + statementLength, + exactThrough, + issues, + resource, + ); +} + +function framePhase( + frame: SqlCteFrame, + frameIndex: number, + declarations: readonly SqlCteDeclaration[], + draftDeclarations: readonly SqlCteDraftDeclaration[], + position: number, +): { readonly kind: "body"; readonly ordinal: number } | { + readonly kind: "main"; +} | null { + for (const declarationIndex of frame.declarationIndexes) { + const declaration = declarations[declarationIndex]; + if ( + declaration && + declaration.bodyRange.from <= position && + position <= declaration.bodyRange.to + ) { + return { kind: "body", ordinal: declaration.ordinal }; + } + } + for (const declaration of draftDeclarations) { + if ( + declaration.frameIndex === frameIndex && + declaration.bodyRange.from <= position && + position <= declaration.bodyRange.to + ) { + return { kind: "body", ordinal: declaration.ordinal }; + } + } + if ( + frame.mainQueryStart !== null && + frame.mainQueryStart <= position + ) { + return { kind: "main" }; + } + return null; +} + +export function visibleSqlCtesAt( + layout: Exclude, + position: number, +): SqlCteVisibility { + const namespace = new Map< + number, + SqlCteDeclaration | null + >(); + const shadowNames = new Map(); + const issues = new Set(); + const beyondExactCoverage = + position > layout.exactThrough || + (position === layout.exactThrough && + layout.exactThrough < layout.statementLength); + let shadowingUnknown = + !Number.isSafeInteger(position) || + position < 0 || + position > layout.statementLength || + beyondExactCoverage; + + if (beyondExactCoverage) { + for (const issue of layout.issues) { + issues.add(issue); + } + } + for ( + let frameIndex = 0; + frameIndex < layout.frames.length; + frameIndex += 1 + ) { + const frame = layout.frames[frameIndex]; + if (!frame) { + continue; + } + if ( + position < frame.scopeRange.from || + position > frame.scopeRange.to + ) { + continue; + } + const phase = framePhase( + frame, + frameIndex, + layout.declarations, + layout.draftDeclarations, + position, + ); + if (!phase) { + if ( + frame.mainQueryStart === null && + frame.issues.length > 0 + ) { + for (const issue of frame.issues) { + issues.add(issue); + } + shadowingUnknown = true; + } + continue; + } + for (const issue of frame.issues) { + issues.add(issue); + if (issue === "unknown-cte-identifier-equivalence") { + shadowingUnknown = true; + } + } + const eligibleOrdinal = + phase.kind === "main" + ? Number.POSITIVE_INFINITY + : phase.ordinal; + const frameDeclarations = frame.declarationIndexes + .map((index) => layout.declarations[index]) + .filter( + (declaration): declaration is SqlCteDeclaration => + declaration !== undefined, + ); + const frameDrafts = layout.draftDeclarations.filter( + (declaration) => declaration.frameIndex === frameIndex, + ); + for (const declaration of frameDrafts) { + for (const unknownClass of declaration.unknownEquivalenceClasses) { + if (namespace.has(unknownClass)) { + namespace.set(unknownClass, null); + shadowingUnknown = true; + } + } + } + + if (frame.recursive && phase.kind === "body") { + issues.add("recursive-cte-position"); + if (frame.mainQueryStart === null) { + shadowingUnknown = true; + } + for (const declaration of frameDeclarations) { + for (const unknownClass of declaration.unknownEquivalenceClasses) { + if (namespace.has(unknownClass)) { + namespace.set(unknownClass, null); + shadowingUnknown = true; + } + } + namespace.set(declaration.equivalenceClass, null); + shadowNames.set( + declaration.equivalenceClass, + declaration.name, + ); + } + for (const declaration of frameDrafts) { + namespace.set(declaration.equivalenceClass, null); + shadowNames.set( + declaration.equivalenceClass, + declaration.name, + ); + } + } + + for (const declaration of frameDeclarations) { + if (declaration.ordinal >= eligibleOrdinal) { + continue; + } + const key = declaration.equivalenceClass; + let uncertainCandidate = + declaration.unknownEquivalenceClasses.includes(key); + for (const unknownClass of declaration.unknownEquivalenceClasses) { + if (namespace.has(unknownClass)) { + namespace.set(unknownClass, null); + uncertainCandidate = true; + } + } + shadowNames.set(key, declaration.name); + namespace.set( + key, + declaration.ambiguous || uncertainCandidate + ? null + : declaration, + ); + } + } + + const ctes = Object.freeze( + [...namespace.values()] + .filter( + (declaration): declaration is SqlCteDeclaration => + declaration !== null, + ) + .sort( + (left, right) => + left.nameRange.from - right.nameRange.from, + ) + .map((declaration) => + Object.freeze({ + declarationPosition: declaration.nameRange.from, + name: declaration.name, + sourceSpelling: declaration.sourceSpelling, + }), + ), + ); + const frozenIssues = freezeIssues(issues); + return Object.freeze({ + ctes, + issues: frozenIssues, + quality: + shadowingUnknown || frozenIssues.length > 0 + ? "recovered" + : "exact", + shadowing: shadowingUnknown + ? Object.freeze({ coverage: "unknown" as const }) + : Object.freeze({ + coverage: "complete" as const, + names: Object.freeze([...shadowNames.values()]), + }), + }); +} diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index 7178557..0c8ded8 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -150,6 +150,16 @@ export type SqlRenderedRelationPath = readonly reason: "illegal-role-sequence"; }; +export type SqlCteIdentifierComparison = + | "distinct" + | "equal" + | "unknown"; + +export type SqlCteIdentifierPrefixMatch = + | "match" + | "no-match" + | "unknown"; + export interface SqlRelationCompletionDialectRuntime { readonly decodeIdentifier: ( token: string, @@ -158,10 +168,14 @@ export interface SqlRelationCompletionDialectRuntime { readonly renderRelationPath: ( path: SqlCanonicalRelationPath, ) => SqlRenderedRelationPath; - readonly cteIdentifiersEqual: ( + readonly compareCteIdentifiers: ( left: SqlIdentifierComponent, right: SqlIdentifierComponent, - ) => boolean; + ) => SqlCteIdentifierComparison; + readonly cteIdentifierMatchesPrefix: ( + candidate: SqlIdentifierComponent, + prefix: SqlIdentifierComponent, + ) => SqlCteIdentifierPrefixMatch; } export type SqlSessionChangeReason = @@ -230,6 +244,7 @@ export type SqlCompletionIssue = | "catalog-overloaded" | "catalog-queue-timeout" | "catalog-timeout" + | "cte-scope-uncertainty" | "query-site-recovery" | "opaque-template-context" | "recursive-cte-uncertainty" diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index a9fc51c..9847138 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -146,8 +146,12 @@ const relation: SqlCatalogRelation = { void relation; const dialectRuntime: SqlRelationCompletionDialectRuntime = { - cteIdentifiersEqual: (left, right) => - left.quoted === right.quoted && left.value === right.value, + compareCteIdentifiers: (left, right) => + left.quoted === right.quoted && left.value === right.value + ? "equal" + : "distinct", + cteIdentifierMatchesPrefix: (candidate, prefix) => + candidate.value.startsWith(prefix.value) ? "match" : "no-match", decodeIdentifier: (token) => ({ component: { quoted: false, value: token }, quality: "exact", From 7302de43ed34692dc79ee3eff5027ce2c9be7e4c Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 09:31:14 +0800 Subject: [PATCH 2/2] fix(vnext): reject invalid CTE cursor positions --- src/vnext/__tests__/cte-layout.test.ts | 10 +++++++++- src/vnext/cte-layout.ts | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/vnext/__tests__/cte-layout.test.ts b/src/vnext/__tests__/cte-layout.test.ts index ccf8172..a232c34 100644 --- a/src/vnext/__tests__/cte-layout.test.ts +++ b/src/vnext/__tests__/cte-layout.test.ts @@ -1180,9 +1180,17 @@ describe("bounded CTE layout", () => { names: [{ quoted: false, value: "outer_cte" }], }, }); - for (const position of [-1, Number.NaN, text.length + 1]) { + for (const position of [ + -1, + 0.5, + text.length - 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + text.length + 1, + ]) { expect(visibleSqlCtesAt(layout, position)).toMatchObject({ ctes: [], + issues: [], quality: "recovered", shadowing: { coverage: "unknown" }, }); diff --git a/src/vnext/cte-layout.ts b/src/vnext/cte-layout.ts index 01491dd..cb3b6a1 100644 --- a/src/vnext/cte-layout.ts +++ b/src/vnext/cte-layout.ts @@ -1602,6 +1602,18 @@ export function visibleSqlCtesAt( layout: Exclude, position: number, ): SqlCteVisibility { + if ( + !Number.isSafeInteger(position) || + position < 0 || + position > layout.statementLength + ) { + return Object.freeze({ + ctes: Object.freeze([]), + issues: Object.freeze([]), + quality: "recovered", + shadowing: Object.freeze({ coverage: "unknown" }), + }); + } const namespace = new Map< number, SqlCteDeclaration | null @@ -1612,11 +1624,7 @@ export function visibleSqlCtesAt( position > layout.exactThrough || (position === layout.exactThrough && layout.exactThrough < layout.statementLength); - let shadowingUnknown = - !Number.isSafeInteger(position) || - position < 0 || - position > layout.statementLength || - beyondExactCoverage; + let shadowingUnknown = beyondExactCoverage; if (beyondExactCoverage) { for (const issue of layout.issues) {