Skip to content

Commit 8a15723

Browse files
committed
feat(vnext): index bounded CTE visibility
1 parent 7397e60 commit 8a15723

8 files changed

Lines changed: 3275 additions & 10 deletions

docs/adr/0005-parser-independent-relation-completion.md

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,49 @@ WITH [RECURSIVE]
171171
main query
172172
```
173173

174-
It records only proven declaration names, body boundaries, declaration order,
175-
and visibility. It is not a miniature general SQL AST.
174+
The accepted grammar is dialect-owned and closed:
175+
176+
| Dialect | Declarations per frame | `RECURSIVE` | Declared columns | Materialization modifier |
177+
| --- | ---: | --- | --- | --- |
178+
| PostgreSQL | up to the global bound | accepted | accepted | `MATERIALIZED` and `NOT MATERIALIZED` |
179+
| DuckDB | up to the global bound | accepted | accepted | `MATERIALIZED` and `NOT MATERIALIZED` |
180+
| BigQuery | up to the global bound | accepted | rejected | rejected |
181+
| Dremio | one | rejected | accepted | rejected |
182+
183+
This matrix describes only syntax the bounded recognizer can prove. Rejection
184+
does not claim that a database engine rejects every extension or future
185+
version; it makes the current completion result explicitly incomplete or
186+
unavailable instead of borrowing another dialect's grammar.
187+
188+
The recognizer records only:
189+
190+
- flat query-block frames and their parent frame;
191+
- proven declaration names, exact source spelling and name ranges;
192+
- body ranges and declaration order;
193+
- authenticated main-query `SELECT` entrypoints; and
194+
- bounded candidate-name and uncertainty evidence for incomplete headers.
195+
196+
It stores no AST, SQL substring, token tape, inferred output columns, or
197+
recursive dependency graph. A declaration is committed only after its `AS`
198+
body has a proven matching close parenthesis. Seeing `WITH name`, `name AS`, or
199+
an opening body alone never invents a visible relation. Once a body opening is
200+
proven, a separate bounded draft record retains its name, ordinal, body range,
201+
and comparison evidence. Drafts can establish the current body phase and
202+
fail-closed shadow uncertainty, but never become completion candidates.
203+
204+
Candidate-name evidence, visible-candidate evidence, and shadow evidence are
205+
distinct. Before a body opens, an active incomplete header has no supported
206+
relation site; direct private projection still reports recovered unknown
207+
coverage rather than exact absence. Once a body opens, its draft name is not
208+
returned as a completion candidate, but it prevents the service from claiming
209+
exact scope or exact non-shadowing where recursive or uncertain equality could
210+
make it visible. Such a result is marked incomplete with
211+
`cte-scope-uncertainty`. A proven declaration shadows an equivalent outer CTE
212+
or unqualified catalog insertion only over its proven visibility range. A
213+
duplicate equivalence class produces no arbitrary first- or last-wins
214+
candidate and blocks an equivalent outer or catalog name wherever that class
215+
would be visible. Uncertainty in one nested frame does not erase independently
216+
proven candidates outside that frame.
176217

177218
For non-recursive CTEs:
178219

@@ -184,13 +225,69 @@ For non-recursive CTEs:
184225
- a nested declaration shadows an outer name only inside its query block; and
185226
- nested declarations never leak outward.
186227

187-
Identifier equality follows the dialect. Duplicate names and structurally
188-
ambiguous headers make the affected frame partial. `WITH RECURSIVE` may expose
189-
proven names in the main query, but self and mutual-recursive body visibility
190-
remain explicitly incomplete until implemented.
228+
Identifier comparison follows the dialect and is tri-state: `equal`,
229+
`distinct`, or `unknown`. PostgreSQL non-ASCII folding can depend on server
230+
encoding and locale, while BigQuery and Dremio document case-insensitivity
231+
without giving this package an authoritative general Unicode folding
232+
algorithm. `unknown` therefore degrades namespace coverage and never means
233+
`distinct`. The private layout builder consumes the same pairwise
234+
`compareCteIdentifiers` operation exposed by the relation-completion dialect
235+
runtime; it does not define a second nullable comparison key. Within the
236+
256-name bound it snapshots symmetric pairwise results into frozen
237+
equivalence classes and scoped uncertainty evidence. Throws, invalid values,
238+
asymmetry, non-reflexivity, or inconsistent equivalence results fail closed.
239+
Duplicate detection uses those classes rather than generic case folding.
240+
241+
A declaration retains both its decoded value and exact source token. The
242+
decoded value is the completion label; the exact token is the insertion text,
243+
so required quoting, case, and escapes are never reconstructed by the catalog
244+
renderer. CTEs are considered only for unqualified relation sites. Prefix
245+
eligibility is also a dialect-owned tri-state operation distinct from equality;
246+
generic locale-sensitive or Unicode case folding is never used.
247+
248+
`WITH RECURSIVE` may expose proven names in the main query. The initial
249+
recognizer also retains independently proven earlier-sibling and outer
250+
visibility inside a recursive body, but self and forward or mutual-recursive
251+
body candidates remain incomplete and add `recursive-cte-uncertainty`. Every
252+
known frame-local recursive name still contributes shadow evidence inside a
253+
recursive body, so withholding a self candidate cannot incorrectly reveal an
254+
equivalent outer or catalog relation. The recognizer does not infer a recursive
255+
dependency graph or output columns. DuckDB `USING KEY`, PostgreSQL
256+
`SEARCH`/`CYCLE`, and any Dremio recursive extension remain unsupported.
257+
258+
The index is cursor-independent and cached with the immutable source,
259+
statement, embedded-region, and dialect-runtime identities. Visibility is a
260+
pure projection over its flat frozen ranges. Building the index and recognizing
261+
the relation query site may initially make two independent linear streaming
262+
passes over the shared lexer; traversal fusion is allowed only after benchmark
263+
evidence. Neither cursor movement nor catalog invalidation rebuilds the index.
264+
265+
Stored source ranges remain half-open. Visibility projection accepts cursor
266+
positions, so a cursor exactly before a proven closing delimiter remains in
267+
the body or nested frame, and a top-level cursor at statement EOF remains in
268+
the main query. A cursor after the delimiter is outside. At the first
269+
untrusted boundary the enclosing draft phase may still contribute proven
270+
positive evidence, but quality and shadow coverage are recovered rather than
271+
exact.
272+
273+
An opaque region in a CTE header or body, an unterminated quoted token, an
274+
unsupported dialect modifier, a duplicate declaration, or a structurally
275+
plausible but unfinished declaration makes the affected frame partial. The
276+
first opaque region terminates exact structural coverage: visible punctuation
277+
after an untyped barrier never closes a body or frame that started before it.
278+
A resource limit records the same first-untrusted boundary. Partial artifacts
279+
may still contribute declarations, entrypoints, and visibility ranges proven
280+
entirely before that boundary, but they never produce exact absence or shadow
281+
claims across it.
191282

192283
An empty positive-only local-relation list never proves that no CTE is visible.
193284

285+
The first bounded grammar intentionally authenticates only `SELECT` query
286+
leaders. PostgreSQL `VALUES` and data-modifying CTE bodies, DuckDB `FROM`-first
287+
queries, and additionally parenthesized BigQuery recursive terms currently
288+
fail closed. Their query-leader sets will become dialect-owned in a follow-up
289+
before relation completion is declared feature-complete.
290+
194291
### Embedded regions
195292

196293
The first public template input is a complete set of length-preserving embedded
@@ -525,6 +622,7 @@ The initial checked limits are:
525622
| Active statement scanned | 65,536 UTF-16 units |
526623
| Lexical tokens | 16,384 |
527624
| Parenthesis/query depth | 128 |
625+
| CTE frames | 256 |
528626
| CTE declarations | 256 |
529627
| Identifier path segments | 32 global ceiling; dialect runtime sets the checked limit |
530628
| Identifier segment | 256 UTF-16 units |

src/vnext/__tests__/bounded-sql-lexer.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,16 +116,53 @@ describe("bounded SQL lexer", () => {
116116
});
117117
expect(lexer.next()).toBeNull();
118118
expect(lexer.resource).toBeNull();
119+
expect(lexer.resourceAt).toBeNull();
119120
});
120121

121122
it("fails closed immediately after the shared lexeme budget", () => {
123+
const acceptedWords = Array.from(
124+
{ length: MAX_BOUNDED_SQL_LEXEMES },
125+
() => "x",
126+
).join(" ");
127+
const accepted = lex(createIdentitySqlSource(acceptedWords));
128+
expect(accepted.lexemes).toHaveLength(MAX_BOUNDED_SQL_LEXEMES);
129+
expect(accepted.resource).toBeNull();
130+
122131
const words = Array.from(
123132
{ length: MAX_BOUNDED_SQL_LEXEMES + 1 },
124133
() => "x",
125134
).join(" ");
126135
const result = lex(createIdentitySqlSource(words));
127136
expect(result.lexemes).toHaveLength(MAX_BOUNDED_SQL_LEXEMES);
128137
expect(result.resource).toBe("lexical-token");
138+
const source = createIdentitySqlSource(words);
139+
const lexer = new BoundedSqlLexer(
140+
source,
141+
0,
142+
source.analysisText.length,
143+
POSTGRESQL_SQL_LEXICAL_PROFILE,
144+
);
145+
while (lexer.next()) {
146+
// Consume the bounded prefix.
147+
}
148+
expect(lexer.resourceAt).toBe(
149+
words.lastIndexOf("x"),
150+
);
151+
152+
const prefixed = ` ${words}`;
153+
const prefixedSource = createIdentitySqlSource(prefixed);
154+
const prefixedLexer = new BoundedSqlLexer(
155+
prefixedSource,
156+
2,
157+
prefixed.length,
158+
POSTGRESQL_SQL_LEXICAL_PROFILE,
159+
);
160+
while (prefixedLexer.next()) {
161+
// Consume the bounded prefix.
162+
}
163+
expect(prefixedLexer.resourceAt).toBe(
164+
prefixed.lastIndexOf("x"),
165+
);
129166
});
130167

131168
it("reports oversized dollar-quote delimiters without emitting a token", () => {
@@ -136,5 +173,13 @@ describe("bounded SQL lexer", () => {
136173
lexemes: [],
137174
resource: "dollar-quote-delimiter",
138175
});
176+
const lexer = new BoundedSqlLexer(
177+
source,
178+
0,
179+
source.analysisText.length,
180+
POSTGRESQL_SQL_LEXICAL_PROFILE,
181+
);
182+
expect(lexer.next()).toBeNull();
183+
expect(lexer.resourceAt).toBe(0);
139184
});
140185
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { bench, describe } from "vitest";
2+
import {
3+
analyzeSqlCteLayout,
4+
MAX_CTE_DECLARATIONS,
5+
MAX_CTE_DEPTH,
6+
MAX_CTE_FRAMES,
7+
type SqlCteLayoutDialect,
8+
visibleSqlCtesAt,
9+
} from "../cte-layout.js";
10+
import { DUCKDB_SQL_LEXICAL_PROFILE } from "../lexical.js";
11+
import { createIdentitySqlSource } from "../source.js";
12+
import {
13+
buildSqlStatementIndex,
14+
type ExactSqlStatementSlot,
15+
} from "../statement-index.js";
16+
17+
const dialect: SqlCteLayoutDialect = {
18+
classifyIdentifierToken: (rawIdentifier, quoted) => ({
19+
status: "identifier",
20+
value: {
21+
component: { quoted, value: rawIdentifier },
22+
},
23+
}),
24+
compareCteIdentifiers: (left, right) =>
25+
left.value.toLowerCase() === right.value.toLowerCase()
26+
? "equal"
27+
: "distinct",
28+
grammar: {
29+
declaredColumns: true,
30+
materialization: true,
31+
maximumDeclarationsPerFrame: MAX_CTE_DECLARATIONS,
32+
recursive: true,
33+
},
34+
lexicalProfile: DUCKDB_SQL_LEXICAL_PROFILE,
35+
};
36+
37+
function fixture(text: string): {
38+
readonly source: ReturnType<typeof createIdentitySqlSource>;
39+
readonly slot: ExactSqlStatementSlot;
40+
} {
41+
const source = createIdentitySqlSource(text);
42+
const slot = buildSqlStatementIndex(
43+
source.analysisText,
44+
dialect.lexicalProfile,
45+
).slots[0];
46+
if (!slot || slot.boundaryQuality !== "exact") {
47+
throw new Error("CTE benchmark fixture requires an exact statement");
48+
}
49+
return { slot, source };
50+
}
51+
52+
const tenKibibytes = 10 * 1_024;
53+
const ordinaryPrefix = "SELECT ";
54+
const ordinary = `${ordinaryPrefix}${"x,".repeat(
55+
Math.floor((tenKibibytes - ordinaryPrefix.length - 1) / 2),
56+
)}x`;
57+
const ordinaryText = `${ordinary}${" ".repeat(
58+
tenKibibytes - ordinary.length,
59+
)}`;
60+
const ordinaryFixture = fixture(ordinaryText);
61+
62+
const declarationHeavyText = `WITH ${Array.from(
63+
{ length: MAX_CTE_DECLARATIONS },
64+
(_, index) => `c${index} AS (SELECT ${index})`,
65+
).join(", ")} SELECT * FROM c255`;
66+
const declarationHeavyFixture = fixture(declarationHeavyText);
67+
68+
const depthHeavyText = Array.from(
69+
{ length: MAX_CTE_DEPTH },
70+
(_, index) => index,
71+
).reduce(
72+
(body, index) =>
73+
`WITH c${index} AS (${body}) SELECT * FROM c${index}`,
74+
"SELECT 1",
75+
);
76+
const depthHeavyFixture = fixture(depthHeavyText);
77+
const bareFrameHeavyText = `SELECT ${Array.from(
78+
{ length: MAX_CTE_FRAMES },
79+
() => "(WITH)",
80+
).join(",")}`;
81+
const bareFrameHeavyFixture = fixture(bareFrameHeavyText);
82+
const depthLayout = analyzeSqlCteLayout(
83+
depthHeavyFixture.source,
84+
depthHeavyFixture.slot,
85+
dialect,
86+
);
87+
if (depthLayout.status !== "ready") {
88+
throw new Error("Depth benchmark requires a ready CTE layout");
89+
}
90+
const projectedLayout = analyzeSqlCteLayout(
91+
declarationHeavyFixture.source,
92+
declarationHeavyFixture.slot,
93+
dialect,
94+
);
95+
if (projectedLayout.status === "unavailable") {
96+
throw new Error("CTE projection benchmark requires a layout");
97+
}
98+
99+
describe("CTE layout", () => {
100+
bench("ordinary 10 KiB statement", () => {
101+
analyzeSqlCteLayout(
102+
ordinaryFixture.source,
103+
ordinaryFixture.slot,
104+
dialect,
105+
);
106+
});
107+
108+
bench("256 declarations", () => {
109+
analyzeSqlCteLayout(
110+
declarationHeavyFixture.source,
111+
declarationHeavyFixture.slot,
112+
dialect,
113+
);
114+
});
115+
116+
bench("128-depth nested CTE", () => {
117+
analyzeSqlCteLayout(
118+
depthHeavyFixture.source,
119+
depthHeavyFixture.slot,
120+
dialect,
121+
);
122+
});
123+
124+
bench("256 sequential incomplete frames", () => {
125+
analyzeSqlCteLayout(
126+
bareFrameHeavyFixture.source,
127+
bareFrameHeavyFixture.slot,
128+
dialect,
129+
);
130+
});
131+
132+
bench("cached 256-declaration projection", () => {
133+
visibleSqlCtesAt(
134+
projectedLayout,
135+
declarationHeavyText.length - 1,
136+
);
137+
});
138+
});

0 commit comments

Comments
 (0)