Skip to content

Commit 1dfc045

Browse files
committed
Add bare T-SQL RETURN for batch-level early-exit (propagates through IF / BEGIN…END / WHILE via BatchContext.ReturnSignaled; value-form raises Msg 178 at compile time, deferred until stored-proc / function scope lands).
1 parent bf53fdf commit 1dfc045

7 files changed

Lines changed: 289 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 6 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` / `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).
340+
### T-SQL control flow: `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN`
341+
`IF <boolean-expr> <stmt> [ELSE <stmt>]`, `BEGIN <stmt>+ END` compound blocks, `WHILE <boolean-expr> <stmt>` loops with `BREAK` / `CONTINUE`, bare `RETURN` for batch-level early-exit. GOTO/labels, TRY/CATCH, and the value-form `RETURN N` (stored-proc / function scope) 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.
@@ -358,11 +358,15 @@ Creates a destination table from the projection's inferred schema, then copies r
358358

359359
**`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.
360360

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.
361+
**`RETURN` — bare-form only, batch-exit propagation.** Set `BatchContext.ReturnSignaled = true` (gated on `!IsSkipping`); `IsSkipping` OR's it in, and the dispatch loop's `DispatchStatementsUntil` checks the flag at the top of every iteration to `yield break`. The WHILE iteration loop checks after every body dispatch (RETURN propagates *through* WHILE — only `BREAK` / `CONTINUE` are caught by the innermost loop). `ParseBeginBlock` short-circuits its "expect END" check when the flag is set, since RETURN may fire mid-block before the cursor reaches END. End result: bare RETURN exits the entire batch — through any nesting of IF / BEGIN…END / WHILE — and any code after it (including `SELECT 'after'` follow-ups or unreached `END` terminators) never executes.
362+
363+
**`RETURN <value>` raises Msg 178** verbatim (`"A RETURN statement with a return value cannot be used in this context."`) at parse time, regardless of skip mode — the value form is reserved for stored procedures and scalar functions, neither of which is modeled yet. Compile-time check (same pattern as BREAK Msg 135): `IF 1=0 RETURN 5` raises Msg 178 even though the branch is un-taken. The simulator detects "value follows" via `IsStatementBoundary(context.Token)` — any non-boundary token after RETURN (operators, variables, literals, parens, non-statement-start keywords) triggers Msg 178; boundary tokens (`;`, EOB, statement-start keywords like SELECT/INSERT/IF/etc.) leave RETURN bare.
364+
365+
**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 || ReturnSignaled` 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.
362366

363367
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.
364368

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.
369+
**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` / `Return` 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.
366370

367371
**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.
368372

@@ -409,8 +413,8 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
409413
- **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.
410414
- **`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.
411415
- **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).
412-
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` ship; unconditional jumps don't.
413-
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, `RETURN`, `PRINT`, stored procs / UDFs. `BEGIN TRY` / `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch (peeked after `BEGIN`).
416+
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare) ship; unconditional jumps don't.
417+
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, `PRINT`, stored procs / UDFs. `BEGIN TRY` / `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch (peeked after `BEGIN`). Value-form `RETURN N` raises Msg 178 (reserved for the stored-proc / function scope, neither modeled yet).
414418
- `hierarchyid`, `geography`, `geometry`.
415419

416420
## Quirks (modeled, not byte-identical to SQL Server)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Tests for the bare <c>RETURN</c> statement (the value-form
7+
/// <c>RETURN N</c> is reserved for stored procedures / functions, neither
8+
/// of which is modeled — value form always raises Msg 178). Covers
9+
/// batch-exit semantics, propagation through IF / BEGIN…END / WHILE,
10+
/// the compile-time Msg 178 check (fires even in un-taken IF, same
11+
/// pattern as BREAK Msg 135), and skip-mode interactions. Behavior
12+
/// probed against SQL Server 2025 (2026-05-11).
13+
/// </summary>
14+
[TestClass]
15+
public sealed class ReturnStatementTests
16+
{
17+
[TestMethod]
18+
public void BareReturn_ExitsBatch()
19+
{
20+
using var reader = new Simulation().ExecuteReader("select 'before'; return; select 'after'");
21+
IsTrue(reader.Read());
22+
AreEqual("before", reader.GetString(0));
23+
IsFalse(reader.Read());
24+
IsFalse(reader.NextResult());
25+
}
26+
27+
[TestMethod]
28+
public void BareReturn_AtEndOfBatch_Clean()
29+
{
30+
// ExecuteNonQuery returns -1 when no DML / DDL ran; the assertion
31+
// is just that the batch completed without throwing.
32+
_ = new Simulation().ExecuteNonQuery("return");
33+
}
34+
35+
[TestMethod]
36+
public void ReturnInTakenIf_ExitsBatch()
37+
{
38+
using var reader = new Simulation().ExecuteReader("if 1=1 return; select 'after'");
39+
IsFalse(reader.Read());
40+
IsFalse(reader.NextResult());
41+
}
42+
43+
[TestMethod]
44+
public void ReturnInUntakenIf_StatementAfterRuns()
45+
=> AreEqual("after", new Simulation().ExecuteScalar(
46+
"if 1=0 return; select 'after'"));
47+
48+
[TestMethod]
49+
public void ReturnInElseBranch_ExitsBatch()
50+
{
51+
using var reader = new Simulation().ExecuteReader(
52+
"if 1=0 select 'then' else return; select 'after'");
53+
IsFalse(reader.Read());
54+
IsFalse(reader.NextResult());
55+
}
56+
57+
/// <summary>
58+
/// Probe-confirmed: RETURN inside a WHILE exits the entire batch, not
59+
/// just the loop (unlike BREAK).
60+
/// </summary>
61+
[TestMethod]
62+
public void ReturnInWhile_ExitsBatchNotJustLoop()
63+
{
64+
using var reader = new Simulation().ExecuteReader("""
65+
declare @i int = 0;
66+
while @i < 100
67+
begin
68+
set @i = @i + 1;
69+
if @i = 3 return;
70+
end;
71+
select 'after'
72+
""");
73+
IsFalse(reader.Read());
74+
IsFalse(reader.NextResult());
75+
}
76+
77+
[TestMethod]
78+
public void ReturnInNestedWhile_ExitsAllLoops()
79+
{
80+
using var reader = new Simulation().ExecuteReader("""
81+
while 1=1
82+
begin
83+
while 1=1
84+
begin
85+
return;
86+
end;
87+
end;
88+
select 'after'
89+
""");
90+
IsFalse(reader.Read());
91+
IsFalse(reader.NextResult());
92+
}
93+
94+
[TestMethod]
95+
public void ReturnInBlock_SkipsSiblings()
96+
{
97+
using var reader = new Simulation().ExecuteReader("""
98+
begin
99+
select 'a';
100+
return;
101+
select 'b';
102+
end;
103+
select 'after'
104+
""");
105+
IsTrue(reader.Read());
106+
AreEqual("a", reader.GetString(0));
107+
IsFalse(reader.Read());
108+
IsFalse(reader.NextResult());
109+
}
110+
111+
[TestMethod]
112+
public void MultipleReturn_FirstWins()
113+
{
114+
_ = new Simulation().ExecuteNonQuery("return; return; return");
115+
}
116+
117+
/// <summary>
118+
/// Bare RETURN followed by a statement-starting keyword is bare RETURN
119+
/// — the SELECT begins a new statement (which gets abandoned because
120+
/// the batch is exiting). Probe-confirmed against SQL Server 2025.
121+
/// </summary>
122+
[TestMethod]
123+
public void ReturnFollowedByStatementKeyword_IsBareReturn()
124+
{
125+
// SELECT 1 never runs (batch exits via RETURN); we just verify the
126+
// batch parses + completes without throwing Msg 178.
127+
using var reader = new Simulation().ExecuteReader("return select 1");
128+
IsFalse(reader.Read());
129+
IsFalse(reader.NextResult());
130+
}
131+
132+
// ---- Msg 178: value-form RETURN ----
133+
134+
[TestMethod]
135+
public void ReturnInteger_Msg178()
136+
=> new Simulation().AssertSqlError("return 5", 178,
137+
"A RETURN statement with a return value cannot be used in this context.");
138+
139+
[TestMethod]
140+
public void ReturnZero_Msg178()
141+
=> new Simulation().AssertSqlError("return 0", 178);
142+
143+
[TestMethod]
144+
public void ReturnNull_Msg178()
145+
=> new Simulation().AssertSqlError("return null", 178);
146+
147+
[TestMethod]
148+
public void ReturnString_Msg178()
149+
=> new Simulation().AssertSqlError("return 'abc'", 178);
150+
151+
[TestMethod]
152+
public void ReturnVariable_Msg178()
153+
=> new Simulation().AssertSqlError("declare @v int = 7; return @v", 178);
154+
155+
[TestMethod]
156+
public void ReturnExpression_Msg178()
157+
=> new Simulation().AssertSqlError("return (1+2)", 178);
158+
159+
[TestMethod]
160+
public void ReturnParenWrapped_Msg178()
161+
=> new Simulation().AssertSqlError("return(1)", 178);
162+
163+
/// <summary>
164+
/// Compile-time check: Msg 178 fires even when the RETURN is in an
165+
/// un-taken IF branch. Probe-confirmed — same semantics as BREAK Msg 135.
166+
/// </summary>
167+
[TestMethod]
168+
public void ReturnValueInUntakenIf_StillMsg178()
169+
=> new Simulation().AssertSqlError("if 1=0 return 5", 178);
170+
171+
[TestMethod]
172+
public void ReturnValueInUntakenIf_BeforeFollowingStmt_StillMsg178()
173+
=> new Simulation().AssertSqlError("if 1=0 return 5; select 'ok'", 178);
174+
}

SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,16 @@ internal static SimulatedSqlException BreakOutsideLoop() =>
115115
/// </summary>
116116
internal static SimulatedSqlException ContinueOutsideLoop() =>
117117
new("Cannot use a CONTINUE statement outside the scope of a WHILE statement.", 136, 15, 1);
118+
119+
/// <summary>
120+
/// Mimics SQL Server error 178: a <c>RETURN</c> statement carries a
121+
/// value (e.g. <c>RETURN 5</c>) in a context where the value form isn't
122+
/// allowed — at batch level, only the bare <c>RETURN</c> form is legal.
123+
/// The value form is reserved for stored procedures and scalar functions.
124+
/// Probe-confirmed against SQL Server 2025 (2026-05-11): Class 15,
125+
/// State 1, exact wording verbatim. Fires at compile time — even from
126+
/// un-taken IF branches, same pattern as Msg 135 (BREAK).
127+
/// </summary>
128+
internal static SimulatedSqlException ReturnWithValueNotAllowed() =>
129+
new("A RETURN statement with a return value cannot be used in this context.", 178, 15, 1);
118130
}

SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ internal sealed class BatchContext
8181
/// <summary>Per-batch ceiling on total WHILE iterations.</summary>
8282
public const long LoopIterationLimit = 100_000;
8383

84+
/// <summary>
85+
/// True after a <c>RETURN</c> statement has fired in this batch. Drives
86+
/// early-exit propagation: the dispatch loop (and every enclosing
87+
/// construct — WHILE, BEGIN…END block) checks this and stops as soon as
88+
/// the current statement's dispatch completes. <see cref="IsSkipping"/>
89+
/// also OR's this in so any statements still parsed after RETURN in the
90+
/// same scope no-op via the skip-mode gates.
91+
/// </summary>
92+
/// <remarks>
93+
/// RETURN propagates through WHILE (unlike BREAK / CONTINUE, which the
94+
/// innermost WHILE catches). Batch-level only for now; once stored
95+
/// procedures and functions land, the proc-call boundary will consume
96+
/// the signal (and the value-form <c>RETURN N</c> will start being legal
97+
/// inside those scopes, ungating the Msg 178 check).
98+
/// </remarks>
99+
public bool ReturnSignaled;
100+
84101
/// <summary>
85102
/// True while the dispatch loop should treat each statement parser as
86103
/// "parse only" — advance the cursor and resolve names but skip the
@@ -110,7 +127,10 @@ internal sealed class BatchContext
110127
/// compile-time check on those statements.
111128
/// </para>
112129
/// </remarks>
113-
public bool IsSkipping => this.SkipModeFlag || this.LoopControl != LoopControl.None;
130+
public bool IsSkipping =>
131+
this.SkipModeFlag
132+
|| this.LoopControl != LoopControl.None
133+
|| this.ReturnSignaled;
114134

115135
/// <summary>The connection executing this batch.</summary>
116136
public SimulatedDbConnection Connection => this.Parser.Connection;

SqlServerSimulator/Parser/Selection.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ private static Selection ParseInner(ParserContext context, uint depth, List<Aggr
422422
or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
423423
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
424424
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
425-
or Keyword.While or Keyword.Break or Keyword.Continue
425+
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
426426
} when depth == 0:
427427
goto ExitWhileTokenLoop;
428428

@@ -565,7 +565,7 @@ or Keyword.While or Keyword.Break or Keyword.Continue
565565
or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
566566
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
567567
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
568-
or Keyword.While or Keyword.Break or Keyword.Continue
568+
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
569569
} when depth == 0:
570570
goto ExitWhileTokenLoop;
571571

0 commit comments

Comments
 (0)