Skip to content

Commit 321d45b

Browse files
committed
Add T-SQL IF / BEGIN…END (cond Msg 4145, three-valued UNKNOWN→ELSE, dangling-else binds inner, BEGIN disambiguation, un-taken-branch IsSkipping skip-mode through every statement parser; sys.objects bundle next).
1 parent 74d58db commit 321d45b

19 files changed

Lines changed: 883 additions & 124 deletions

CLAUDE.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,25 @@ 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).
342+
343+
- **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.
344+
- **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.
345+
- **Cond must be a Boolean predicate** (`BooleanExpression`). Bare values raise **Msg 4145** (`"An expression of non-boolean type specified in a context where a condition is expected, near 'X'"`): `IF 1`, `IF NULL`, `IF 'abc'`, `IF (cast(null as bit))` — bit is *not* boolean in SQL Server's static type check. Implemented by changing `BooleanExpression.ParseComparison`'s default (atom-without-comparison-op) case from Msg 102 to Msg 4145 (cross-cuts WHERE / HAVING / ON / CHECK too; probe-confirmed they share the wording). Slight positional gap on paren-wrapped value cases (`IF (1) select` — simulator reports "near ')'" where SQL Server reports "near 'select'") — wording correct, near-token off by one.
346+
- **Three-valued cond**: only an explicit `true` takes THEN; both `false` and UNKNOWN go to ELSE (`IF 1 = null …` → ELSE).
347+
- **`BEGIN` disambiguation**: peek the token after `BEGIN`. `TRAN`/`TRANSACTION` → existing `TryParseBeginTransaction`. `DISTRIBUTED``NotSupportedException` (no DTC). `TRY`/`ATOMIC``NotSupportedException` (TRY/CATCH and natively-compiled SP atomic blocks). Everything else → compound block. Implemented via `ParserContext.SaveCheckpoint`/`RestoreCheckpoint` so the transaction-start case re-parses through the unchanged `TryParseBeginTransaction` path.
348+
- **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`.
349+
- **`@@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.
350+
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.
352+
353+
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.
354+
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.
356+
357+
**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.
358+
340359
### Local temp tables (`#foo`)
341360
Per-connection `Dictionary<string, HeapTable> TempTables` on `SimulatedDbConnection`; routed by `BatchContext.TryResolveTable` (`#`-prefix → connection dict, else current DB + system tables). Auto-cleared on `Dispose`, matching real SQL Server's session-close drop. Lifecycle, cross-conn isolation, and Msg 208 from other sessions all probe-confirmed against SQL Server 2025.
342361

@@ -380,8 +399,8 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
380399
- **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.
381400
- **`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.
382401
- **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).
383-
- T-SQL control flow (`IF` / `WHILE` / `BEGIN ... END` / `BREAK` / `CONTINUE`) — Bundle 2 of scripting.
384-
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, `RETURN`, `PRINT`, stored procs / UDFs.
402+
- T-SQL `WHILE` / `BREAK` / `CONTINUE` / `GOTO` / labels — `IF` / `BEGIN…END` ship; loops and unconditional jumps don't.
403+
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, `RETURN`, `PRINT`, stored procs / UDFs. `BEGIN TRY` / `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch (peeked after `BEGIN`).
385404
- `hierarchyid`, `geography`, `geometry`.
386405

387406
## Quirks (modeled, not byte-identical to SQL Server)
@@ -398,3 +417,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
398417
- **`SELECT INTO` string `+` reads as nullable**: real SQL Server projects `cs + 'x'` (both NOT NULL) as NOT NULL; the simulator can't statically distinguish string-concat from integer-add at projection-schema time (the dispatch happens runtime on operand types), so all `Add` results read as nullable. Conservative; no test reliance on string-`+`-non-null.
399418
- **`SELECT INTO` from a CTE drops identity + nullability**: CTE bindings synthesize their wrapper `HeapColumn` entries with `nullable: true` and no identity, so the analyzer treats CTE sources as derived plans. Real SQL Server preserves both through simple single-source CTEs. Fix requires propagating column metadata through CTE bindings.
400419
- **Temp-table DDL is transactional, regular-table DDL isn't**: `CREATE TABLE #foo` / `DROP TABLE #foo` inside `BEGIN TRAN` participate in the undo log (matching real SQL Server); the same statements on a regular table commit immediately regardless of an active transaction. Asymmetric, but no real workload depends on transactional regular-DDL (EF doesn't do schema changes through SaveChanges, and migrations run outside transactions on real SQL Server too).
420+
- **Un-taken IF branches resolve names eagerly**: real SQL Server defers name resolution for un-taken branches, so `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 that reference non-existent tables/columns still raise `Msg 208` / `Msg 207`. The common idioms (`IF NOT EXISTS (…) CREATE TABLE foo (…)`, `IF OBJECT_ID('foo','U') IS NOT NULL DROP TABLE foo`, `IF cond INSERT t VALUES (…)` against pre-existing `t`) work end-to-end because referenced names exist when the branch is skipped. State mutations inside the un-taken branch are correctly suppressed (skip-mode gate); the gap is name resolution only.
421+
- **`IF` cond divide-by-zero**: real SQL Server surfaces `IF 1/0 = 0 …` as Msg 8134; the simulator surfaces the raw `DivideByZeroException` from .NET decimal arithmetic. Same pre-existing gap as documented for `TRY_CAST(1/0 AS INT)`.
422+
- **`IF (1) select` paren-wrapped non-boolean cond — slight positional gap**: simulator raises Msg 4145 near `')'`; real SQL Server reports `'select'` (the post-paren token). Wording is correct (Msg 4145, non-boolean type), only the "near 'X'" suffix differs. Same gap applies to any `IF (value-expr) …` shape.

0 commit comments

Comments
 (0)