Skip to content

Commit b9583c3

Browse files
committed
Table variables: DECLARE @t TABLE (cols + DEFAULTs + NOT NULL + anonymous PK, inline or table-level) backed by HeapTable.IsTableVariable on per-batch BatchContext.TableVariables (shared namespace with scalar Variables); @t accepted in INSERT/UPDATE/DELETE/MERGE/FROM via ParseObjectName(acceptTableVariable: true), Msg 1087 on missing, Msg 102 in CREATE/ALTER/DROP/TRUNCATE/SELECT INTO; non-transactional (undo log bypassed); OUTPUT INTO @t ships for INSERT/UPDATE/DELETE/MERGE with positional + explicit column-list mapping, suppresses result set; IDENTITY/UNIQUE/CHECK/computed/rowversion → NotSupportedException (deferred v2), named constraints + FK → Msg 102.
1 parent 6f154e9 commit b9583c3

16 files changed

Lines changed: 1156 additions & 36 deletions

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Five scopes, one home each. **Add new state to whichever class matches its true
6464
- **`Database`** (internal) = one database hosted by the server instance. `Schemas` (named-schema dict, pre-seeded with `dbo`), `CompatibilityLevel`, `VerboseTruncationWarnings`, `rowVersionCounter` (per-DB `@@DBTS`). Every `Simulation` ships with one entry named `Simulation.DefaultDatabaseName` (`"simulated"`); a future `USE <db>` adds entries to the dictionary.
6565
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`), `Views` (per-schema view dict), `Procedures` (per-schema stored-procedure dict). Future sequences / triggers land here too. Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`, `FROM audit.view1`, `EXEC audit.proc1`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
6666
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `LastErrorNumber` (`@@ERROR`), `NestingLevel` (UDF / proc / trigger / view recursion depth, capped at 32), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()`, `TempTables` (per-session `#foo` dictionary, cleared on `Dispose`).
67-
- **`BatchContext`** (internal, in `Parser/`) = one command execution. Owns the `ParserContext` (parse-time-only scratch — `Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`, `InDefaultClause`, `AllowsWindowExpressions`) and holds batch-lifetime runtime state: `Variables`, `CurrentUndoLog`, `UdfFrame` (non-null when this batch is a scalar-UDF body being dispatched — gates value-form `RETURN` and lands the return value for the caller), `ProcFrame` (non-null when this batch is a stored-procedure body — same gate for value-form `RETURN`, plus a return-code slot the caller reads), plus the per-statement frame `CurrentStatement`. Exposes `TryResolveTable(MultiPartName)` — the routing rule that dispatches `#foo` leaves to `Connection.TempTables` regardless of qualifier; everything else routes through the named schema (or `dbo` for an unqualified reference), with `SystemHeapTables` reachable only as a flat 1-part fallback. `TryResolveFunction(MultiPartName)` resolves 2-/3-part dotted names against the named schema's `Functions` dict; 1-part names return false (real SQL Server rejects bare UDF calls with Msg 195). `TryResolveProcedure(MultiPartName)` resolves through the same schema-lookup path but accepts 1-part names (probe-confirmed: `EXEC p1` finds `dbo.p1`). `TryResolveSchema(MultiPartName)` exposes the dict-bearing schema for CREATE / DROP / TRUNCATE / SELECT INTO. `ParseObjectName(ParserContext)` parses the 1–4-segment dotted form, leaves cursor on the last name segment (standard parser contract), and compresses empty middle segments (so `tempdb..#foo` returns a 2-part name). Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch`. Scalar-UDF and procedure invocation each allocate a child `BatchContext` via the corresponding body constructor: parameters pre-seed `Variables`, the matching frame is set, the body source text (captured at CREATE FUNCTION / CREATE PROCEDURE time) is re-tokenized through a synthesized `SimulatedDbCommand`, and the same dispatch loop runs the body. UDF bodies discard yielded result sets at the call site (Msg 444 territory); procedure bodies forward them through to the outer caller's iterator.
67+
- **`BatchContext`** (internal, in `Parser/`) = one command execution. Owns the `ParserContext` (parse-time-only scratch — `Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`, `InDefaultClause`, `AllowsWindowExpressions`) and holds batch-lifetime runtime state: `Variables`, `TableVariables` (per-batch `DECLARE @t TABLE`–backed `HeapTable` dict, shared namespace with scalar `Variables` for the Msg-134 uniqueness check), `CurrentUndoLog`, `UdfFrame` (non-null when this batch is a scalar-UDF body being dispatched — gates value-form `RETURN` and lands the return value for the caller), `ProcFrame` (non-null when this batch is a stored-procedure body — same gate for value-form `RETURN`, plus a return-code slot the caller reads), plus the per-statement frame `CurrentStatement`. Exposes `TryResolveTable(MultiPartName)` — the routing rule that dispatches `#foo` leaves to `Connection.TempTables` regardless of qualifier and `@t` leaves to `TableVariables` (1-part only — `dbo.@t` returns false; the caller raises Msg 102 at parse via `acceptTableVariable` gating); everything else routes through the named schema (or `dbo` for an unqualified reference), with `SystemHeapTables` reachable only as a flat 1-part fallback. `TryResolveFunction(MultiPartName)` resolves 2-/3-part dotted names against the named schema's `Functions` dict; 1-part names return false (real SQL Server rejects bare UDF calls with Msg 195). `TryResolveProcedure(MultiPartName)` resolves through the same schema-lookup path but accepts 1-part names (probe-confirmed: `EXEC p1` finds `dbo.p1`). `TryResolveSchema(MultiPartName)` exposes the dict-bearing schema for CREATE / DROP / TRUNCATE / SELECT INTO. `ParseObjectName(ParserContext, bool acceptTableVariable = false)` parses the 1–4-segment dotted form, leaves cursor on the last name segment (standard parser contract), and compresses empty middle segments (so `tempdb..#foo` returns a 2-part name); the `acceptTableVariable` opt-in routes `@t` to a 1-part-with-`@`-prefix leaf for DML / FROM-source sites, and rejects `@t` everywhere else (so ALTER TABLE / DROP TABLE / TRUNCATE / CREATE / SELECT INTO surface Msg 102 matching probe). Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch`. Scalar-UDF and procedure invocation each allocate a child `BatchContext` via the corresponding body constructor: parameters pre-seed `Variables`, the matching frame is set, the body source text (captured at CREATE FUNCTION / CREATE PROCEDURE time) is re-tokenized through a synthesized `SimulatedDbCommand`, and the same dispatch loop runs the body. UDF bodies discard yielded result sets at the call site (Msg 444 territory); procedure bodies forward them through to the outer caller's iterator.
6868
- **`StatementContext`** (internal, in `Parser/`) = the dispatch loop's per-statement frame. Allocated once per batch and overwritten in place at the top of each iteration; holds `UtcNow` (the per-statement-freeze the time scalars read). Stored-proc EXEC / TRY-CATCH frames slot in here when added.
6969

7070
**Don't stack misfit state into these buckets unthinkingly**: when adding fields, ask which scope it actually belongs to. If none fits, that's the signal to introduce the missing scope rather than squat on a neighbor. Multi-database is structurally supported but exercised only by the default entry; `USE <db>` is the trigger to populate the dictionary properly.
@@ -144,6 +144,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
144144
- **Adding or changing system metadata surfaces** (sys.* / INFORMATION_SCHEMA.*) → [`docs/claude/catalog-views.md`](docs/claude/catalog-views.md).
145145
- **Extending scalar UDFs, inline TVFs, views, updatable-view DML routing, stored procedures (CREATE/ALTER/DROP/EXEC), or dynamic SQL (`EXEC (@sql)` / `sp_executesql`)**[`docs/claude/programmable.md`](docs/claude/programmable.md).
146146
- **Touching `#foo` routing, DROP TABLE, TRUNCATE TABLE**[`docs/claude/temp-tables.md`](docs/claude/temp-tables.md).
147+
- **Touching `DECLARE @t TABLE`, table-variable DML routing, `OUTPUT … INTO @t`**[`docs/claude/table-variables.md`](docs/claude/table-variables.md).
147148
- **Adding a new top-level statement parser or changing the dispatch loop's statement-separator rules**[`docs/claude/grammar.md`](docs/claude/grammar.md) + [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
148149

149150
## Not modeled
@@ -159,12 +160,12 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
159160
- `LIKE` with `COLLATE` override (default collation only).
160161
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121`.
161162
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
162-
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*` / `INSERTED.*` star expansion.
163+
- `OUTPUT DELETED.*` / `INSERTED.*` star expansion. (`OUTPUT INTO @t` ships — see Table variables below.)
163164
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`.
164165
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
165166
- Heap allocation tracking (flat page list, no IAM/PFS).
166167
- Compound assignment (`SET @v += expr` / `-=` / `*=` etc.) — rewrite as `SET @v = @v + expr`. The arithmetic-operator runtime is locked behind `protected` instance methods on `TwoSidedExpression`; exposing them as static helpers is the prerequisite refactor.
167-
- Table variables (`DECLARE @t TABLE (...)`) — separate feature with its own storage / scope / lifecycle.
168+
- **Table-variable constraints beyond v1 scope**`DECLARE @t TABLE (...)` ships with columns + `DEFAULT` + `NOT NULL` + inline anonymous PRIMARY KEY + table-level anonymous PRIMARY KEY (single table variable per DECLARE — multi-var DECLARE and mixed scalar+table raise Msg 102/156 matching probe). `IDENTITY`, `UNIQUE`, inline `CHECK`, computed columns, and `rowversion` in table variables raise `NotSupportedException` with the feature name (deferred follow-on). Named constraints (`CONSTRAINT pk1 PRIMARY KEY`) and `FOREIGN KEY` raise Msg 102 / Msg 156 (matches probe — real SQL Server rejects both shapes at parse). `OUTPUT … INTO @t` ships for INSERT / UPDATE / DELETE / MERGE; `OUTPUT … INTO <regular_table>` raises `NotSupportedException` (real SQL Server accepts both, but EF / app usage centers on table variables).
168169
- **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.
169170
- **`ALTER 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.
170171
- **`DROP SCHEMA`**, **`ALTER SCHEMA … TRANSFER`** — deferred. CREATE SCHEMA + schema-qualified resolution ships; lifecycle doesn't yet (catalog views — `sys.schemas`, `INFORMATION_SCHEMA.SCHEMATA` — do ship as of the catalog-view-expansion bundle).
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Exercises EF Core's <c>UseSqlOutputClause(false)</c> path, which apps
7+
/// typically opt into when the target table has an <c>INSTEAD OF</c> trigger
8+
/// (the simulator doesn't model triggers, but the opt-out is a legitimate
9+
/// configuration). With the SQL OUTPUT clause disabled for an entity, EF
10+
/// Core emits a table-variable-based workaround pattern instead of the bare
11+
/// <c>OUTPUT INSERTED.col</c> form — staging the returned identity through
12+
/// a <c>DECLARE @inserted TABLE (...)</c> + <c>OUTPUT INSERTED.col INTO @inserted</c>
13+
/// + <c>SELECT … FROM @inserted</c> sequence. This regression test locks in
14+
/// the simulator's coverage of that path now that <c>DECLARE @t TABLE</c>
15+
/// and <c>OUTPUT … INTO @t</c> both ship.
16+
/// </summary>
17+
[TestClass]
18+
public class EFCoreSqlReturningClauseOff
19+
{
20+
public TestContext TestContext { get; set; } = null!;
21+
22+
[TestMethod]
23+
public void SaveChanges_SingleEntity_RehydratesIdentityViaTableVariable()
24+
{
25+
using var context = new OutputOffDbContext(TestDbContext.CreateWidgetsSimulation());
26+
27+
var widget = new Widget { Name = "First" };
28+
_ = context.Widgets.Add(widget);
29+
_ = context.SaveChanges();
30+
31+
Assert.AreEqual(1, widget.Id);
32+
Assert.AreEqual("First", context.Widgets.Where(w => w.Id == 1).Select(w => w.Name).Single());
33+
}
34+
35+
[TestMethod]
36+
public void SaveChanges_MultipleEntities_RehydratesContiguousIdentities()
37+
{
38+
using var context = new OutputOffDbContext(TestDbContext.CreateWidgetsSimulation());
39+
40+
var a = new Widget { Name = "A" };
41+
var b = new Widget { Name = "B" };
42+
var c = new Widget { Name = "C" };
43+
context.Widgets.AddRange(a, b, c);
44+
_ = context.SaveChanges();
45+
46+
Assert.AreEqual(1, a.Id);
47+
Assert.AreEqual(2, b.Id);
48+
Assert.AreEqual(3, c.Id);
49+
}
50+
51+
/// <summary>
52+
/// Variant of <see cref="TestDbContext"/> that disables the SQL OUTPUT
53+
/// clause for the <see cref="Widget"/> entity via
54+
/// <c>ToTable(t =&gt; t.UseSqlOutputClause(false))</c>. EF Core then emits
55+
/// the table-variable-based workaround pattern for SaveChanges (the path
56+
/// apps with INSTEAD OF triggers configure into; the simulator doesn't
57+
/// model triggers but the configuration is still legitimate). Stays on
58+
/// the bare <c>UseSqlServer</c> code path (no adapter needed for
59+
/// <see cref="Widget"/>'s int identity column).
60+
/// </summary>
61+
private sealed class OutputOffDbContext(Simulation simulation) : TestDbContext(simulation)
62+
{
63+
protected override void OnModelCreating(ModelBuilder modelBuilder)
64+
{
65+
base.OnModelCreating(modelBuilder);
66+
_ = modelBuilder.Entity<Widget>()
67+
.ToTable("Widgets", t => t.UseSqlOutputClause(false));
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)