You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Recursive CTEs: WITH name AS (anchor UNION ALL recursive [UNION ALL …]) SELECT … with multi-anchor / multi-recursive support and OPTION (MAXRECURSION N) (default 100, 0 = unlimited). Branch-by-branch body parser via internalized Selection.ParseIntersectChain detects self-reference per branch through binding.IsRecursivePartParse + a per-branch counter; CteBinding gains Schema / ColumnNames captured from anchor plus a CurrentIterationRows runtime slot the recursive Selection rebinds between iterations. FromRecursiveCte runs anchors once into the seed, then iterates each recursive branch against the previous-iteration rowset until empty (or MaxRecursion trips Msg 530 with literal limit). Strict per-column type-equality between anchor and recursive (Msg 240) — diverges from regular UNION ALL's Promote. Errors: 240 / 247 anchor-after-recursive / 252 missing UNION ALL / 253 multiple self-refs / 530. Recursive-part feature restrictions (460 / 461 / 462 / 465 / 467) deferred — accepted with possibly-incorrect semantics.
Copy file name to clipboardExpand all lines: CLAUDE.md
+22-9Lines changed: 22 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -339,20 +339,33 @@ Three entry points share one per-connection undo log: implicit (statement-level
339
339
### `rowversion` (legacy synonym `timestamp`)
340
340
8-byte big-endian database-scoped monotonic counter; `Simulation.AllocateRowVersion` advances on every INSERT into a rowversion-bearing table and every UPDATE that affects a row in one. Storage type name surfaces as `timestamp` in `information_schema` regardless of declaration keyword. Explicit insert → Msg 273; explicit update → Msg 272; second column on a table → Msg 2738. Outbound CAST: `varbinary(N)` / `binary(N)` copy the 8 bytes; `bigint` reads big-endian. `Promote(RowVersion, Varbinary)` → `Varbinary` so EF Core's `WHERE [rv] = @originalRv` optimistic-concurrency parameter works directly. `EF Core [Timestamp]` SaveChanges round-trips end-to-end via `UPDATE ... OUTPUT INSERTED.[RowVersion] WHERE [Id] = @p AND [RowVersion] = @originalRv`.
341
341
342
-
### Common table expressions (non-recursive)
343
-
`WITH name [(col, …)] AS (SELECT …) [, …] {SELECT|INSERT|UPDATE|DELETE|MERGE} …`. The WITH prefix scopes to exactly one immediately-following statement; the binding is gone after that statement runs. Probe-confirmed against SQL Server 2025 on 2026-05-10.
342
+
### Common table expressions
343
+
`WITH name [(col, …)] AS (SELECT …) [, …] {SELECT|INSERT|UPDATE|DELETE|MERGE} …`. The WITH prefix scopes to exactly one immediately-following statement; the binding is gone after that statement runs. Both non-recursive and recursive forms are modeled. Probe-confirmed against SQL Server 2025 on 2026-05-10 / 2026-05-11.
344
344
345
-
`Simulation.ParseCteBindings` runs at the statement-loop top before dispatch, populating `ParserContext.CteBindings` (a `Dictionary<string, CteBinding>`). Each binding's body parses at `depth: 1` because the `(SELECT …)` brackets it; cleared at the top of the next iteration. `ParseSingleFromSource` consults the bindings before falling through to `Simulation.HeapTables` — a CTE name shadows a real table for the prefixed statement (probe-confirmed). Resolution builds a `FromSource` with `lateralPlan: cteBinding.Plan`, so each FROM-side reference re-runs the inner Selection (parallel to derived tables in FROM). Multiple comma-separated CTEs cascade: later ones see earlier ones because the prior bindings are already in the dictionary when the later body is parsed.
345
+
`Simulation.ParseCteBindings` runs at the statement-loop top before dispatch, populating `ParserContext.CteBindings` (a `Dictionary<string, CteBinding>`). Each body parses branch-by-branch via `Selection.ParseIntersectChain` (the higher-precedence set-op chain parser); the surrounding loop walks UNION / UNION ALL / EXCEPT operators and tracks per-branch self-reference. Bindings are cleared at the top of the next statement-loop iteration. `ParseSingleFromSource` consults the bindings before falling through to `Simulation.HeapTables` — a CTE name shadows a real table for the prefixed statement (probe-confirmed). Multiple comma-separated CTEs cascade: later ones see earlier ones because the prior bindings are already in the dictionary when the later body is parsed.
346
346
347
-
**Recursive CTEs aren't modeled.** Self-reference is detected via a sentinel: `ParseCteBindings` registers a `CteBinding` with `Plan = null`*before* parsing the body, so a body's `FROM cte_name` resolves to the sentinel and `ParseSingleFromSource` raises `NotSupportedException("Recursive common table expressions are not modeled.")`. The non-recursive sibling replaces the sentinel with the resolved plan once the body finishes parsing.
347
+
**Non-recursive form**: when the body has no self-references, branches are folded via the standard `Selection.CombineSetOps` (so type-promotion across UNION / UNION ALL / EXCEPT matches a regular set-op chain). Resolution builds a `FromSource` with `lateralPlan: binding.Plan`, so each FROM-side reference re-runs the inner Selection (parallel to derived tables in FROM).
348
348
349
-
**Errors modeled:****Msg 239** duplicate CTE name (`"Duplicate common table expression name 'a' was specified."`); **Msg 8158** rename list has fewer columns than body projection (`"'name' has more columns than were specified in the column list."`); **Msg 8159** rename list has more columns than body projection (`"'name' has fewer columns than were specified in the column list."`); **Msg 1033** ORDER BY in CTE body without TOP / OFFSET / FETCH (`"The ORDER BY clause is invalid in views, inline functions, derived tables, subqueries, and common table expressions, unless TOP, OFFSET or FOR XML is also specified."`).
349
+
**Recursive form**: when any branch self-references, the body parser splits branches into anchor (no self-ref) and recursive (one self-ref) lists, then builds a Selection via `Selection.FromRecursiveCte(anchors, recursives, binding)`. The recursive Selection runs anchors once into a seed rowset, then iterates: each iteration rebinds `binding.CurrentIterationRows` to the previous iteration's output and runs every recursive branch, accumulating all rows. Termination: an iteration produces zero rows, OR `binding.MaxRecursion` (default 100, override via `OPTION (MAXRECURSION N)` at the end of the outer SELECT) is exceeded → **Msg 530** with the literal limit value in the message. `OPTION (MAXRECURSION 0)` disables the cap.
350
350
351
-
The Msg 1033 enforcement requires knowing whether the body's plan has a TOP / OFFSET / FETCH at any layer — added a new `Selection.HasTopOrOffsetOrFetch` field threaded through all four `new Selection(...)` call sites (`BuildSynthesizedSqlRow` / `BuildSqlProjection` / `CombineSetOps` / `ApplyTopLevelOrderBy`). The set-op combiner inherits from its branches; the top-level ORDER-BY wrapper inherits from the inner plan plus its own OFFSET / FETCH counts. Derived tables face the same SQL Server rule but the simulator currently allows ORDER BY without TOP / OFFSET there — pre-existing divergence orthogonal to this bundle.
351
+
Self-reference resolution: during the recursive-part parse, `binding.IsRecursivePartParse` is set after the anchor branch completes (which captures the binding's `Schema` / `ColumnNames` from the anchor's projection). Subsequent branch parses route self-references through a FromSource backed by `SelfReferenceRows(binding)` — a closure that reads `binding.CurrentIterationRows` at iterator-start time. Each branch's `binding.SelfReferenceCountInCurrentBranch` is reset before parse; the body parser inspects the count after parse to classify the branch as anchor (count = 0) or recursive (count = 1) and to enforce one-self-ref-per-branch (Msg 253).
352
352
353
-
**Msg 319** (WITH after a non-terminated previous statement) isn't enforced. The simulator's statement loop consumes one statement per iteration and naturally treats WITH at iteration top as a fresh prefix regardless of prior `;` — more permissive than real SQL Server. Apps that idiomatically terminate statements (or use a single statement) won't notice.
353
+
**Recursive CTE errors modeled:**
354
+
-**Msg 240**: anchor and recursive parts produce different per-column types. Recursive CTEs require strict type-equality (no Promote-style widening, unlike regular UNION ALL); the user must explicitly cast.
355
+
-**Msg 247**: an anchor branch (no self-ref) appears after a recursive branch — anchors must precede recursives.
356
+
-**Msg 252**: the body has a self-reference but no top-level UNION ALL splitting it from an anchor (e.g. `WITH c AS (SELECT n+1 FROM c WHERE n < 5) …` — no anchor); also fires when UNION-without-ALL is used between branches.
357
+
-**Msg 253**: one recursive branch references the CTE more than once (e.g. `c CROSS JOIN c`).
358
+
-**Msg 530**: MAXRECURSION exceeded; the literal limit value appears in the message.
354
359
355
-
EF Core 10 emits non-recursive CTEs in some shapes (TPC inheritance, certain `Distinct/OrderBy/Skip` patterns); also reachable from raw SQL (`FromSqlInterpolated` / direct command text) including the `WITH … INSERT … SELECT` shape.
360
+
**Recursive part restrictions not enforced** (pre-existing CLAUDE.md gap, deferred to a follow-up bundle): DISTINCT / TOP / OFFSET / aggregate / GROUP BY / OUTER JOIN inside a recursive branch should raise Msg 460/461/467/462 respectively in real SQL Server. The simulator silently accepts those and produces possibly-incorrect semantics; users hitting these constructs in recursive parts are writing SQL that real SQL Server rejects anyway. Recursive references inside subqueries (Msg 465) likewise aren't enforced.
361
+
362
+
**Non-recursive errors modeled:****Msg 239** duplicate CTE name; **Msg 8158** / **Msg 8159** rename-list count mismatch; **Msg 1033** ORDER BY in CTE body without TOP / OFFSET / FETCH (gated via `Selection.HasTopOrOffsetOrFetch` threaded through all `new Selection(...)` call sites — set-op combiner inherits from branches; top-level ORDER-BY wrapper inherits from the inner plan plus its own OFFSET / FETCH).
363
+
364
+
**Msg 319** (WITH after a non-terminated previous statement) isn't enforced — the simulator's statement loop consumes one statement per iteration and treats WITH at iteration top as a fresh prefix regardless of prior `;`; apps that idiomatically terminate statements won't notice.
365
+
366
+
`OPTION (MAXRECURSION N)` parses inside `Selection.ParseQueryExpression` after the optional ORDER BY / OFFSET / FETCH; the parser walks `context.CteBindings.Values` and writes `MaxRecursion` to each, so recursive Selections see the override at execute time. `MAXRECURSION` is the only OPTION hint modeled; other hints (`OPTIMIZE FOR`, `RECOMPILE`, `MERGE JOIN`, etc.) raise `NotSupportedException`.
367
+
368
+
EF Core 10 emits non-recursive CTEs in some shapes (TPC inheritance, certain `Distinct/OrderBy/Skip` patterns); recursive CTEs are reachable only via raw SQL (`FromSqlInterpolated` / direct command text) — EF Core's LINQ surface doesn't compile to recursive CTEs from idiomatic queries.
356
369
357
370
### INSERT … SELECT
358
371
`INSERT [INTO] target [(cols)] SELECT …` accepts the same Selection grammar as a top-level SELECT — WHERE / JOIN / GROUP BY / aggregates / ORDER BY / TOP / OFFSET-FETCH / UNION / INTERSECT / EXCEPT all work on the source side. Probe-confirmed against SQL Server 2025 on 2026-05-10.
@@ -412,7 +425,7 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
412
425
-`UNION` / `UNION ALL` inside a subquery body.
413
426
- Row-constructor `IN ((1,2), (3,4))`.
414
427
- Window functions other than `ROW_NUMBER` and the aggregate-OVER family (see "Window functions" above).
415
-
- Recursive CTEs (`WITH name AS (… UNION ALL … FROM name …)`); self-reference detected and raises `NotSupportedException`. Non-recursive CTEs are modeled.
428
+
- Recursive-part feature restrictions (Msg 460 DISTINCT / Msg 461 TOP / Msg 462 OUTER JOIN / Msg 467 aggregate-or-GROUP-BY / Msg 465 ref-in-subquery) — the recursive iteration accepts these constructs and produces possibly-incorrect semantics rather than raising. Apps that exercise these in real SQL Server hit the rejection there too, so the simulator's behavior diverges from real-server errors but matches the broader "don't write that" guidance. Recursive CTEs themselves and the structural error paths (Msg 240 / 247 / 252 / 253 / 530) are modeled.
416
429
-`LIKE` with `COLLATE` override (default collation only — case-insensitive Latin1_General-shaped).
417
430
-`CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121` for date-like → string.
Copy file name to clipboardExpand all lines: SqlServerSimulator.Tests/CommonTableExpressionTests.cs
+138-6Lines changed: 138 additions & 6 deletions
Original file line number
Diff line number
Diff line change
@@ -161,14 +161,146 @@ public void Cte_OrderByWithOffsetFetch_Allowed()
161
161
=>AreEqual(2,WithSourceTable().ExecuteScalar("with c as (select id from src order by id desc offset 1 rows fetch next 1 rows only) select id from c"));
=>AreEqual(200,newSimulation().ExecuteScalar("with c as (select 1 as n union all select n+1 from c where n < 200) select count(*) from c option (maxrecursion 0)"));
=>newSimulation().AssertSqlError("with c as (select 1 as n union all select 2 union all select n+1 from c where n < 4 union all select 99) select n from c",247,
235
+
"An anchor member was found in the recursive part of recursive query \"c\".");
=>newSimulation().AssertSqlError("with c as (select 1 as n union all select c1.n + c2.n from c c1 cross join c c2 where c1.n < 5) select n from c",253,
240
+
"Recursive member of a common table expression 'c' has multiple recursive references.");
=>newSimulation().AssertSqlError("with c as (select cast(1 as smallint) as n union all select cast(n+1 as int) from c where n < 5) select n from c",240,
245
+
"Types don't match between the anchor and the recursive part in column \"n\" of recursive query \"c\".");
0 commit comments