Skip to content

Commit d6ed03a

Browse files
authored
feat(vnext): index bounded CTE visibility (#188)
## Summary - add a private, cursor-independent bounded CTE layout and visibility index for PostgreSQL, DuckDB, BigQuery, and Dremio grammar subsets - preserve committed declarations separately from unfinished-body drafts, with correct nonrecursive order, nested shadowing, recursive withholding, duplicate blocking, and source-faithful insertion evidence - use dialect-owned symmetric tri-state identifier comparison rather than generic folding or a second nullable comparison-key model - preserve exact proven prefixes across lexer exhaustion and model cursor positions correctly at EOF, closing delimiters, barriers, and scope boundaries - fail closed on hostile dialect callbacks/data, embedded regions, malformed syntax, unknown equivalence, and every checked resource boundary - extend ADR 0005 and the provisional public relation-completion type contract for tri-state equality/prefix behavior and CTE-scope uncertainty ## Bounds and performance - active statement: 65,536 UTF-16 units - shared lexical tokens: 16,384 - parenthesis/query depth: 128 - CTE frames: 256 - CTE declarations: 256 - identifier segment: 256 UTF-16 units Representative local means: - ordinary 10 KiB statement: ~0.37 ms - 256 declarations with pairwise equivalence validation: ~3.0 ms - 128 nested CTE frames: ~1.0 ms - 256 sequential incomplete frames: ~0.15 ms - cached 256-declaration projection: ~0.022 ms ## Verification - 1,587 tests passed, plus 1 governed expected failure - changed runtime coverage: 96.96% statements, 95.88% branches, 100% functions, 96.94% lines - all source, test, vNext exact/loose-optional, and demo typechecks pass - oxlint and test-integrity gates pass - package smoke, production build, demo build, and 7 Playwright browser tests pass - three independent exact-head adversarial reviewers approved `8a1572369a171f49d3e23aeed4993aceb88a2cb2` ## Deferred follow-up The bounded grammar currently authenticates `SELECT` query leaders. Dialect-owned support for PostgreSQL `VALUES`/data-modifying CTE bodies, DuckDB `FROM`-first queries, and additionally parenthesized BigQuery recursive terms remains an explicit follow-up before relation completion is feature-complete. Part of #169. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a bounded CTE layout and visibility index for vNext to improve relation completion across PostgreSQL, DuckDB, BigQuery, and Dremio, and now rejects invalid CTE cursor positions to avoid bogus suggestions. Introduces tri-state CTE identifier comparison and strict, fail-closed resource limits; part of #169. - **New Features** - Private, cursor‑independent CTE index with correct order, shadowing, duplicate blocking, and recursive withholding. - Closed, dialect‑owned grammar per engine with a documented acceptance matrix; no cross‑dialect borrowing. - Tri‑state equality and prefix matching through the dialect runtime; preserves exact prefixes at EOF and scope boundaries. - Fast, bounded visibility via `visibleSqlCtesAt`; now rejects invalid cursor positions and the lexer reports limit hits with `resourceAt`, failing closed on malformed input. - **Migration** - In `SqlRelationCompletionDialectRuntime`, replace `cteIdentifiersEqual` with `compareCteIdentifiers` returning `"equal" | "distinct" | "unknown"`. - Implement `cteIdentifierMatchesPrefix` returning `"match" | "no-match" | "unknown"`. <sup>Written for commit 7302de4. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/marimo-team/codemirror-sql/pull/188?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent 7397e60 commit d6ed03a

8 files changed

Lines changed: 3291 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)