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
Copy file name to clipboardExpand all lines: CLAUDE.md
+24-2Lines changed: 24 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -337,6 +337,25 @@ Creates a destination table from the projection's inferred schema, then copies r
337
337
-**INTO without FROM** works (`SELECT 1 AS x INTO #t`) — synthesized-row path threads `IntoTarget` through.
338
338
-**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.
339
339
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
+
340
359
### Local temp tables (`#foo`)
341
360
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.
342
361
@@ -380,8 +399,8 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
380
399
-**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.
381
400
-**`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.
382
401
-**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.
## Quirks (modeled, not byte-identical to SQL Server)
@@ -398,3 +417,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
398
417
-**`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.
399
418
-**`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.
400
419
-**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