Skip to content

Commit bf53fdf

Browse files
committed
Add T-SQL WHILE / BREAK / CONTINUE with flag-based loop control (BatchContext.LoopControl + LoopDepth), per-iteration cursor save/restore, @@rowcount=0 at exit, and Msg 135/136 compile-time scope check.
1 parent 321d45b commit bf53fdf

8 files changed

Lines changed: 595 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,8 @@ Creates a destination table from the projection's inferred schema, then copies r
337337
- **INTO without FROM** works (`SELECT 1 AS x INTO #t`) — synthesized-row path threads `IntoTarget` through.
338338
- **Quirk**: CTE-wrapped single-heap source drops identity and nullability — the simulator's CTE bindings synthesize `HeapColumn` entries with `nullable: true` and no identity, so the analyzer can't peer through. Real SQL Server propagates both. Fix would require propagating column metadata through CTE bindings; future bundle.
339339

340-
### T-SQL control flow: `IF` / `BEGIN…END`
341-
`IF <boolean-expr> <stmt> [ELSE <stmt>]` and `BEGIN <stmt>+ END` compound blocks. WHILE/BREAK/CONTINUE/GOTO/labels and TRY/CATCH still aren't modeled. Probed against SQL Server 2025 (2026-05-11).
340+
### T-SQL control flow: `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE`
341+
`IF <boolean-expr> <stmt> [ELSE <stmt>]`, `BEGIN <stmt>+ END` compound blocks, `WHILE <boolean-expr> <stmt>` loops with `BREAK` / `CONTINUE`. GOTO/labels and TRY/CATCH still aren't modeled. Probed against SQL Server 2025 (2026-05-11).
342342

343343
- **Body grammar**: exactly one statement. The famous T-SQL footgun `IF cond SELECT 'a' SELECT 'b'` runs *both* SELECTs — only the first is the IF body; the second escapes the IF as a subsequent batch-level statement. Replicated.
344344
- **Dangling-else binds to the inner IF** (standard rule). `IF 1=0 IF 1=1 stmt ELSE stmt` → outer skips the entire inner-IF including its ELSE; no output.
@@ -348,11 +348,21 @@ Creates a destination table from the projection's inferred schema, then copies r
348348
- **Empty `BEGIN END`** (and `BEGIN ; END` with only separators) → **Msg 102** near `'end'`. Variables declared inside a block are batch-scoped, not block-scoped (visible after `END`) — matches existing batch-scope model on `BatchContext.Variables`.
349349
- **`@@ROWCOUNT`**: an IF that ran no branch (cond false, no ELSE) resets `@@ROWCOUNT` to 0. An IF whose body ran lets the body's last statement set `@@ROWCOUNT` normally. Probe-confirmed.
350350

351-
**Un-taken-branch skip mode** (`BatchContext.IsSkipping`). The IF parser sets the flag around dispatch of the un-taken branch (THEN if cond false, ELSE if cond true), then restores in a `finally`. Skip-mode propagates through nested IF/BEGIN automatically — a nested IF inside a skipped block reads `IsSkipping=true`, short-circuits cond eval entirely (so a divide-by-zero inside un-evaluated cond doesn't fire), and dispatches both its branches in skip mode.
351+
**WHILE specifics**: `BooleanExpression.Parse` for cond (Msg 4145 on non-boolean, same path as IF). `ParserContext.SaveCheckpoint` captures the body-start; `RestoreCheckpoint` before each iteration so the body re-parses from scratch (variable references hold live `VariableSlot` references, so cond / body mutations between iterations are visible). After every exit path (cond initially false, cond goes false mid-loop, BREAK) `@@ROWCOUNT` resets to 0 — probe-confirmed, independent of what the body's last statement produced. Empty `BEGIN END` body raises Msg 102 (same rule as IF). One-statement-body footgun: `WHILE @i<2 set @i=@i+1 select @i` — the `SELECT` is *not* part of the body; it runs once after the loop exits.
352+
353+
**BREAK / CONTINUE — flag-based, not exception-based.** `BatchContext.LoopControl` enum (`None` / `Break` / `Continue`). The BREAK parser sets it to `Break`; the CONTINUE parser sets it to `Continue`. The innermost WHILE consumes and clears the flag. The `IsSkipping` property OR's the flag into the skip predicate, so subsequent statements in the body block naturally no-op (`set @sum = @sum + 100;` after a `BREAK` doesn't run — probe-confirmed). Nested loops work because each WHILE clears its own flag before returning to its caller, so the outer never sees the inner's break/continue. This composes cleanly with iterator-based dispatch in a way exception-based signaling doesn't — see `feedback_no_exceptions_for_control_flow.md`.
354+
355+
**BREAK / CONTINUE outside a loop** raises **Msg 135** / **Msg 136** verbatim: `"Cannot use a BREAK statement outside the scope of a WHILE statement."` / `"Cannot use a CONTINUE statement outside the scope of a WHILE statement."`. The check on `BatchContext.LoopDepth == 0` fires *unconditionally* — real SQL Server applies the loop-scope check at compile time, so the simulator does too. **This is distinct from the Q15 un-taken-branch fidelity gap**: `IF 1=0 BREAK` at batch top level fires Msg 135 even though the branch is un-taken. Inside a real WHILE, `LoopDepth > 0` lets BREAK in an un-taken IF body just no-op (because the `!IsSkipping` gate on the flag *write* prevents the actual control transfer).
356+
357+
**Iteration cap** — simulator-only safety net at `BatchContext.LoopIterationLimit = 100_000` total iterations per batch. Real SQL Server has no such cap (timeouts handle runaway loops). The simulator throws `InvalidOperationException` so a buggy test doesn't hang CI.
358+
359+
**`LoopDepth` is bumped unconditionally** (even when the WHILE itself is in skip mode) so BREAK / CONTINUE inside the body — including inside un-taken IF branches — never see Msg 135 / 136 fire incorrectly. The flag-write gate (`!IsSkipping`) handles the runtime "BREAK in skipped-IF inside WHILE" case.
360+
361+
**Un-taken-branch skip mode** (`BatchContext.SkipModeFlag` + `IsSkipping` computed property). The IF parser sets `SkipModeFlag` around dispatch of the un-taken branch (THEN if cond false, ELSE if cond true), then restores in a `finally`. `IsSkipping = SkipModeFlag || LoopControl != None` is the combined predicate every statement parser reads. Skip-mode propagates through nested IF/BEGIN/WHILE automatically — a nested IF inside a skipped block reads `IsSkipping=true`, short-circuits cond eval entirely (so a divide-by-zero inside un-evaluated cond doesn't fire), and dispatches both its branches in skip mode. A WHILE in skip mode never iterates; it skip-dispatches its body once to advance the cursor and exits.
352362

353363
Each statement parser still runs its full parse — the cursor advances normally, names resolve, expressions parse — but gates its state mutation on `!batch.IsSkipping`. Touchpoints: SELECT `Execute` call in the dispatch, `ProcessHeapInsert`'s heap insert + `LastIdentity` update, `CommitUpdate`'s heap delete+insert, `CommitDelete`'s heap delete, MERGE's INSERT branch, `TryParseCreate`'s dict add + Msg 2714 existence check, `DropOneTable`'s lookup + Msg 3701 + dict remove, `ExecuteSelectInto`'s create + bulk-insert, `TryParseSetVariable`'s `slot.Value =` (plus its RHS evaluation), `TryParseSetIdentityInsert`'s state change, `TryParseDeclare`'s dict add + Msg 134 duplicate check + initializer evaluation, `TryParseBeginTransaction` / `TryParseCommit` / `TryParseRollbackTransaction` / `TryParseSavepoint`'s state changes (including their no-active-tx error checks), `TryParseAlter`'s database property write, `TryParseDbcc`'s trace-flag mutation. The dispatch loop also suppresses `yield return` for SELECT results and the `LastStatementRowCount` update on skipped statements.
354364

355-
**Dispatch refactor**: extracted `DispatchOneStatement(batch, requireSemicolonBeforeCte)` and `DispatchStatementsUntil(batch, endKeyword)` from `CreateResultSetsForCommand`. The top-level loop calls `DispatchStatementsUntil(null)`; `ParseBeginBlock` calls `DispatchStatementsUntil(Keyword.End)`; `ParseIfStatement` calls `DispatchOneStatement` directly (IF's body is exactly one statement). `IsStatementBoundary` now also includes `If` / `Else` / `End` so the cursor-normalization at the end of each dispatch correctly recognizes nested-control terminators. `Selection.ParseInner`'s projection-list terminator switch also lists `If` / `Else` / `End` (and `Drop`) so `SELECT ... ELSE` / `SELECT ... END` correctly stop at the keyword instead of throwing Msg 102.
365+
**Dispatch refactor**: extracted `DispatchOneStatement(batch, requireSemicolonBeforeCte)` and `DispatchStatementsUntil(batch, endKeyword)` from `CreateResultSetsForCommand`. The top-level loop calls `DispatchStatementsUntil(null)`; `ParseBeginBlock` calls `DispatchStatementsUntil(Keyword.End)`; `ParseIfStatement` and `ParseWhileStatement` call `DispatchOneStatement` directly (the body of each is exactly one statement). `IsStatementBoundary` includes `If` / `Else` / `End` / `While` / `Break` / `Continue` so the cursor-normalization at the end of each dispatch correctly recognizes nested-control terminators. `Selection.ParseInner`'s projection-list terminator switch lists the same set plus `Drop` so `SELECT ... ELSE` / `SELECT ... END` / `SELECT ... BREAK` / etc. correctly stop at the keyword instead of throwing Msg 102.
356366

357367
**Fidelity gap** (Q15): real SQL Server defers name resolution for un-taken branches — `IF 1=0 SELECT bad_col FROM bad_table` runs silently. The simulator's parsers do name resolution inline with parsing, so un-taken branches with non-existent table/column refs still raise Msg 208 / Msg 207. Common idioms (safe-CREATE / safe-DROP / safe-INSERT against pre-existing tables) work end-to-end because referenced names exist when the branch is skipped.
358368

@@ -399,7 +409,7 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
399409
- **Global temp tables (`##foo`)**`NotSupportedException` at parse. Local `#foo` works; the lifecycle for global temps (drops when creator session closes, visible across sessions) is the deferred scope.
400410
- **`ALTER TABLE #foo`**, **`TRUNCATE TABLE #foo`**, **`OBJECT_ID('tempdb..#foo')`** — none modeled (none of those exist for regular tables either yet). The common `IF OBJECT_ID(...) IS NOT NULL DROP TABLE` cleanup pattern works via `DROP TABLE IF EXISTS #foo` instead.
401411
- **Three-part name resolution outside DROP TABLE**: `tempdb..#foo` in FROM / INSERT / UPDATE / DELETE / MERGE / SET IDENTITY_INSERT raises `InvalidObjectName` (Msg 208) on the qualifier; use bare `#foo`. DROP TABLE alone tolerates the qualifier (probe pattern).
402-
- T-SQL `WHILE` / `BREAK` / `CONTINUE` / `GOTO` / labels — `IF` / `BEGIN…END` ship; loops and unconditional jumps don't.
412+
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` ship; unconditional jumps don't.
403413
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, `RETURN`, `PRINT`, stored procs / UDFs. `BEGIN TRY` / `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch (peeked after `BEGIN`).
404414
- `hierarchyid`, `geography`, `geometry`.
405415

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Tests for <c>WHILE cond stmt</c>, <c>BREAK</c>, and <c>CONTINUE</c>.
7+
/// Covers iteration with mutated cond, the one-statement-body footgun,
8+
/// nested loops (BREAK exits innermost), BREAK / CONTINUE scope check
9+
/// (Msg 135 / Msg 136 fire even from un-taken IF branches — real SQL
10+
/// Server's compile-time check), @@ROWCOUNT=0 at every exit path,
11+
/// non-boolean cond (Msg 4145), WHILE in un-taken IF (skip-mode), and
12+
/// the simulator's iteration cap. Behavior probed against SQL Server
13+
/// 2025 (2026-05-11).
14+
/// </summary>
15+
[TestClass]
16+
public sealed class WhileLoopTests
17+
{
18+
[TestMethod]
19+
public void BasicCounter_RunsToCompletion()
20+
=> AreEqual(3, new Simulation().ExecuteScalar<int>("""
21+
declare @i int = 0;
22+
while @i < 3 set @i = @i + 1;
23+
select @i
24+
"""));
25+
26+
/// <summary>
27+
/// Body is exactly one statement — same footgun as IF. The second
28+
/// statement after the body runs once *after* the loop, not per
29+
/// iteration. Probe-confirmed.
30+
/// </summary>
31+
[TestMethod]
32+
public void BodyOneStatement_SecondStatementEscapesLoop()
33+
=> AreEqual(2, new Simulation().ExecuteScalar<int>("""
34+
declare @i int = 0;
35+
while @i < 2 set @i = @i + 1 select @i
36+
"""));
37+
38+
[TestMethod]
39+
public void CondInitiallyFalse_NoIteration()
40+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
41+
declare @i int = 0;
42+
while 1 = 0 set @i = 99;
43+
select @i
44+
"""));
45+
46+
[TestMethod]
47+
public void NonBooleanCond_Msg4145()
48+
=> new Simulation().AssertSqlError("while 1 select 1", 4145);
49+
50+
[TestMethod]
51+
public void NullCond_Msg4145()
52+
=> new Simulation().AssertSqlError("while null select 1", 4145);
53+
54+
[TestMethod]
55+
public void EmptyBeginEnd_Msg102()
56+
=> new Simulation().AssertSqlError("while 1=0 begin end", 102);
57+
58+
// ---- BREAK ----
59+
60+
[TestMethod]
61+
public void Break_ExitsLoop()
62+
=> AreEqual(2, new Simulation().ExecuteScalar<int>("""
63+
declare @i int = 0;
64+
while 1 = 1
65+
begin
66+
set @i = @i + 1;
67+
if @i >= 2 break;
68+
end;
69+
select @i
70+
"""));
71+
72+
/// <summary>
73+
/// BREAK skips remaining statements in the same block — subsequent
74+
/// statements after BREAK in the body never execute. Probe-confirmed.
75+
/// </summary>
76+
[TestMethod]
77+
public void Break_SkipsRemainingStatementsInBlock()
78+
=> AreEqual(1, new Simulation().ExecuteScalar<int>("""
79+
declare @sum int = 0;
80+
while 1 = 1
81+
begin
82+
set @sum = @sum + 1;
83+
break;
84+
set @sum = @sum + 100;
85+
end;
86+
select @sum
87+
"""));
88+
89+
// ---- CONTINUE ----
90+
91+
/// <summary>
92+
/// CONTINUE skips the remainder of the current iteration and re-
93+
/// evaluates the cond. @hits counts only odd iterations because
94+
/// even iterations continue before incrementing @hits.
95+
/// </summary>
96+
[TestMethod]
97+
public void Continue_SkipsRemainderAndReevaluates()
98+
=> AreEqual(3, new Simulation().ExecuteScalar<int>("""
99+
declare @i int = 0, @hits int = 0;
100+
while @i < 5
101+
begin
102+
set @i = @i + 1;
103+
if @i % 2 = 0 continue;
104+
set @hits = @hits + 1;
105+
end;
106+
select @hits
107+
"""));
108+
109+
// ---- Scope check (Msg 135 / 136) ----
110+
111+
[TestMethod]
112+
public void BreakOutsideLoop_Msg135()
113+
=> new Simulation().AssertSqlError("break", 135, "Cannot use a BREAK statement outside the scope of a WHILE statement.");
114+
115+
[TestMethod]
116+
public void ContinueOutsideLoop_Msg136()
117+
=> new Simulation().AssertSqlError("continue", 136, "Cannot use a CONTINUE statement outside the scope of a WHILE statement.");
118+
119+
/// <summary>
120+
/// BREAK inside an IF body (not in a WHILE) → Msg 135. The check is
121+
/// compile-time in real SQL Server, so it fires even though the IF
122+
/// might be un-taken at runtime. The simulator parses the IF body
123+
/// statement-by-statement; BREAK's scope check fires at parse time
124+
/// regardless of skip mode (probe-confirmed against SQL Server 2025).
125+
/// </summary>
126+
[TestMethod]
127+
public void BreakInsideIfNoLoop_StillMsg135()
128+
=> new Simulation().AssertSqlError("if 1=1 break", 135);
129+
130+
[TestMethod]
131+
public void BreakInsideUntakenIf_StillMsg135()
132+
=> new Simulation().AssertSqlError("if 1=0 break", 135);
133+
134+
[TestMethod]
135+
public void BreakInsideBlock_NoLoop_StillMsg135()
136+
=> new Simulation().AssertSqlError("begin break end", 135);
137+
138+
/// <summary>
139+
/// BREAK inside an IF inside a WHILE: the LoopDepth check passes
140+
/// (we're in a WHILE), so no Msg 135. Iteration completes; outer
141+
/// WHILE keeps iterating.
142+
/// </summary>
143+
[TestMethod]
144+
public void BreakInsideIfInsideLoop_NoError()
145+
=> AreEqual(5, new Simulation().ExecuteScalar<int>("""
146+
declare @i int = 0;
147+
while @i < 5
148+
begin
149+
set @i = @i + 1;
150+
if 1 = 0 break;
151+
end;
152+
select @i
153+
"""));
154+
155+
// ---- Nested loops ----
156+
157+
/// <summary>
158+
/// Inner BREAK exits inner WHILE only. Outer continues iterating.
159+
/// Reset <c>@inner</c> inside the outer body via SET (not DECLARE —
160+
/// re-declaring inside a loop body would fire Msg 134 on iteration 2).
161+
/// </summary>
162+
[TestMethod]
163+
public void NestedBreak_ExitsInnerOnly()
164+
=> AreEqual(3, new Simulation().ExecuteScalar<int>("""
165+
declare @outer int = 0, @inner int = 0;
166+
while @outer < 3
167+
begin
168+
set @outer = @outer + 1;
169+
set @inner = 0;
170+
while 1 = 1
171+
begin
172+
set @inner = @inner + 1;
173+
if @inner >= 2 break;
174+
end;
175+
end;
176+
select @outer
177+
"""));
178+
179+
/// <summary>
180+
/// Nested CONTINUE only re-iterates the inner loop. Outer iteration
181+
/// continues normally after inner WHILE finishes.
182+
/// </summary>
183+
[TestMethod]
184+
public void NestedContinue_ReiteratesInnerOnly()
185+
=> AreEqual(10, new Simulation().ExecuteScalar<int>("""
186+
declare @outer int = 0, @inner int = 0, @total int = 0;
187+
while @outer < 2
188+
begin
189+
set @outer = @outer + 1;
190+
set @inner = 0;
191+
while @inner < 4
192+
begin
193+
set @inner = @inner + 1;
194+
if @inner % 2 = 0 continue;
195+
set @total = @total + 1;
196+
end;
197+
end;
198+
select @total + (@outer * 3)
199+
"""));
200+
201+
// ---- WHILE in skipped IF ----
202+
203+
/// <summary>
204+
/// WHILE inside an un-taken IF branch: the WHILE never iterates.
205+
/// Critical because the body is <c>WHILE 1=1</c> (infinite loop) —
206+
/// if skip-mode failed, the test would hit the iteration cap.
207+
/// </summary>
208+
[TestMethod]
209+
public void WhileInsideSkippedIf_NeverIterates()
210+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
211+
declare @i int = 0;
212+
if 1 = 0 while 1 = 1 set @i = 99;
213+
select @i
214+
"""));
215+
216+
// ---- @@ROWCOUNT ----
217+
218+
[TestMethod]
219+
public void RowCount_AfterWhileNoIter_IsZero()
220+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
221+
declare @prime int = 1;
222+
while 1 = 0 set @prime = 99;
223+
select @@rowcount
224+
"""));
225+
226+
[TestMethod]
227+
public void RowCount_AfterWhileWithIters_IsZero()
228+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
229+
declare @i int = 0;
230+
while @i < 2 begin set @i = @i + 1 end;
231+
select @@rowcount
232+
"""));
233+
234+
[TestMethod]
235+
public void RowCount_AfterBreakExit_IsZero()
236+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
237+
declare @i int = 0;
238+
while 1 = 1
239+
begin
240+
set @i = @i + 1;
241+
if @i >= 1 break;
242+
end;
243+
select @@rowcount
244+
"""));
245+
246+
// ---- Cond mutation across iterations ----
247+
248+
[TestMethod]
249+
public void CondReferencesVariable_MutationVisibleAcrossIters()
250+
=> AreEqual(5, new Simulation().ExecuteScalar<int>("""
251+
declare @i int = 0;
252+
while @i < 5 set @i = @i + 1;
253+
select @i
254+
"""));
255+
256+
// ---- Iteration cap ----
257+
258+
/// <summary>
259+
/// Simulator-only safety: a runaway WHILE throws after the per-batch
260+
/// iteration cap is exceeded. Real SQL Server has no such cap — query
261+
/// timeouts handle this in production — but the simulator surfaces an
262+
/// explicit error so a buggy test doesn't hang CI.
263+
/// </summary>
264+
[TestMethod]
265+
public void IterationCap_ThrowsAfterLimit()
266+
{
267+
var ex = Throws<InvalidOperationException>(() => new Simulation().ExecuteNonQuery("""
268+
declare @i int = 0;
269+
while 1 = 1 set @i = @i + 1
270+
"""));
271+
Contains("iteration cap exceeded", ex.Message, StringComparison.Ordinal);
272+
}
273+
}

0 commit comments

Comments
 (0)