Skip to content

Commit cfae73b

Browse files
committed
Implemented minimum INSERT ... OUTPUT INSERTED and MERGE INTO target USING (VALUES) to unblock EF Core features.
1 parent 4f8c9ef commit cfae73b

5 files changed

Lines changed: 794 additions & 40 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7676
- Pattern matching (`LIKE`) and `CONVERT`.
7777
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
7878
- EF Core compatibility: the SqlServer provider downcasts `DbParameter` to `SqlParameter` for some mappings — `DateTime → date`, `DateTime → smalldatetime`, `DateOnly`, `TimeOnly`, `TimeSpan`, and `decimal → money` / `decimal → smallmoney` (via `SqlServerDecimalTypeMapping`) all break at SaveChanges. See `SimulatedDbParameter` for the matrix; a `SqlServerSimulator.EFCore` adapter package is planned to close the gap.
79-
- `IDENTITY` columns work end-to-end in raw SQL (auto-generation, `SET IDENTITY_INSERT`, `SCOPE_IDENTITY()` / `@@IDENTITY` / `IDENT_CURRENT()`), but EF Core SaveChanges with an identity entity emits `INSERT ... OUTPUT INSERTED.Id` for single rows and `MERGE INTO ... OUTPUT` for multi-row writes — neither statement form is parsed yet, so EF Core identity inserts fail. `EFCoreIdentity` pins the gap as a regression test until OUTPUT/MERGE land.
79+
- `OUTPUT` and `MERGE` are scoped to the EF Core SaveChanges shape: `INSERT ... OUTPUT INSERTED.<col>` (single-row) and `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch). `OUTPUT INTO @table_var`, `OUTPUT DELETED.*`, OUTPUT on UPDATE/DELETE, `INSERTED.*` star expansion, MERGE source subqueries, MERGE target with column refs in `ON`, the `WHEN MATCHED` UPDATE/DELETE branches, and `$action` aren't supported. The `WHEN MATCHED` branch parses syntactically but throws `NotSupportedException` if the per-row predicate ever evaluates true.
8080
- Session-scoped state. `SCOPE_IDENTITY()` / `@@IDENTITY`, `SET IDENTITY_INSERT`'s active table, and `DBCC TRACEON(N)` flags all live on `Simulation` rather than per-connection. The simulator collapses session vs global scope until a real connection-scoped state model exists; revisit when transactions/locks/MVCC bring the per-session shape with them.
8181
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
8282
- Heap allocation tracking (IAM/PFS): the page list is flat.

SqlServerSimulator.Tests.EFCore/EFCoreIdentity.cs

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,60 +3,88 @@
33
namespace SqlServerSimulator;
44

55
/// <summary>
6-
/// Exercises the simulator's <c>IDENTITY</c> column support through EF Core.
7-
/// SaveChanges on an EF-managed identity entity emits
8-
/// <c>INSERT ... OUTPUT INSERTED.Id</c> (single row) or <c>MERGE INTO ... OUTPUT</c>
9-
/// (multi-row) — neither is parsed yet, so that path is pinned as a gap by
10-
/// <see cref="Insert_IdentityKey_AwaitsOutputClauseSupport"/>. The other
11-
/// tests exercise three EF Core paths that <i>don't</i> require
12-
/// <c>OUTPUT</c>: read-only LINQ, <c>SqlQueryRaw</c> against the identity
13-
/// scalars, and <c>SaveChanges</c> with <c>DatabaseGenerated.None</c> + <c>SET IDENTITY_INSERT ON</c>.
6+
/// Exercises the simulator's <c>IDENTITY</c> column support through EF Core's
7+
/// SaveChanges path. EF emits <c>INSERT ... OUTPUT INSERTED.Id</c> for a
8+
/// single new entity and <c>MERGE INTO ... USING (VALUES ...) ... OUTPUT</c>
9+
/// for a multi-entity batch; both forms now round-trip end-to-end. Coverage
10+
/// also includes raw <c>SqlQueryRaw</c> against the identity scalars and the
11+
/// <c>DatabaseGenerated.None</c> + <c>SET IDENTITY_INSERT</c> escape hatch.
1412
/// </summary>
1513
[TestClass]
1614
public class EFCoreIdentity
1715
{
1816
public TestContext TestContext { get; set; } = null!;
1917

2018
[TestMethod]
21-
public void Insert_IdentityKey_AwaitsOutputClauseSupport()
19+
public void SaveChanges_SingleEntity_AssignsGeneratedIdentity()
2220
{
2321
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
2422

2523
var widget = new Widget { Name = "First" };
2624
_ = context.Widgets.Add(widget);
25+
_ = context.SaveChanges();
26+
27+
Assert.AreEqual(1, widget.Id);
28+
Assert.AreEqual("First", context.Widgets.Where(w => w.Id == 1).Select(w => w.Name).Single());
29+
}
30+
31+
[TestMethod]
32+
public void SaveChanges_MultipleEntities_AssignsContiguousIdentities()
33+
{
34+
// EF emits MERGE for >1 row inserts; the OUTPUT clause carries the
35+
// generated Ids back through a positional source column so EF can
36+
// rehydrate the in-memory entities.
37+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
38+
39+
var a = new Widget { Name = "A" };
40+
var b = new Widget { Name = "B" };
41+
var c = new Widget { Name = "C" };
42+
context.Widgets.AddRange(a, b, c);
43+
_ = context.SaveChanges();
44+
45+
Assert.AreEqual(1, a.Id);
46+
Assert.AreEqual(2, b.Id);
47+
Assert.AreEqual(3, c.Id);
48+
}
2749

28-
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
29-
StringAssert.Contains(ex.InnerException?.Message ?? "", "OUTPUT");
50+
[TestMethod]
51+
public void SaveChanges_AcrossBatches_ReseedsThroughHighWaterMark()
52+
{
53+
// Each SaveChanges produces a fresh INSERT/MERGE. Identity continues
54+
// counting from the table's high-water mark across calls.
55+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
56+
57+
_ = context.Widgets.Add(new Widget { Name = "first" });
58+
_ = context.SaveChanges();
59+
60+
var pair = new[] { new Widget { Name = "second" }, new Widget { Name = "third" } };
61+
context.Widgets.AddRange(pair);
62+
_ = context.SaveChanges();
63+
64+
Assert.AreEqual(2, pair[0].Id);
65+
Assert.AreEqual(3, pair[1].Id);
3066
}
3167

3268
[TestMethod]
3369
public void Query_IdentityRows_ReturnsAutoGeneratedIds()
3470
{
35-
var simulation = TestDbContext.CreateWidgetsSimulation();
36-
_ = simulation
37-
.CreateOpenConnection()
38-
.CreateCommand("insert into Widgets (Name) values ('A'),('B'),('C')")
39-
.ExecuteNonQuery();
71+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
72+
context.Widgets.AddRange(new Widget { Name = "A" }, new Widget { Name = "B" }, new Widget { Name = "C" });
73+
_ = context.SaveChanges();
4074

41-
using var context = new TestDbContext(simulation);
4275
var rows = context.Widgets.OrderBy(w => w.Id).ToArray();
43-
4476
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, rows.Select(w => w.Id).ToArray());
4577
CollectionAssert.AreEqual(new[] { "A", "B", "C" }, rows.Select(w => w.Name).ToArray());
4678
}
4779

4880
[TestMethod]
4981
public void Where_FiltersByAutoGeneratedId()
5082
{
51-
var simulation = TestDbContext.CreateWidgetsSimulation();
52-
_ = simulation
53-
.CreateOpenConnection()
54-
.CreateCommand("insert into Widgets (Name) values ('A'),('B'),('C')")
55-
.ExecuteNonQuery();
83+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
84+
context.Widgets.AddRange(new Widget { Name = "A" }, new Widget { Name = "B" }, new Widget { Name = "C" });
85+
_ = context.SaveChanges();
5686

57-
using var context = new TestDbContext(simulation);
5887
var name = context.Widgets.Where(w => w.Id == 2).Select(w => w.Name).Single();
59-
6088
Assert.AreEqual("B", name);
6189
}
6290

@@ -83,15 +111,11 @@ public void SqlQueryRaw_ScopeIdentity_ReturnsLastInsertedValue()
83111
[TestMethod]
84112
public void SqlQueryRaw_IdentCurrent_ReturnsHighWaterMark()
85113
{
86-
var simulation = TestDbContext.CreateWidgetsSimulation();
87-
_ = simulation
88-
.CreateOpenConnection()
89-
.CreateCommand("insert into Widgets (Name) values ('A'),('B'),('C')")
90-
.ExecuteNonQuery();
114+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
115+
context.Widgets.AddRange(new Widget { Name = "A" }, new Widget { Name = "B" }, new Widget { Name = "C" });
116+
_ = context.SaveChanges();
91117

92-
using var context = new TestDbContext(simulation);
93118
var ic = context.Database.SqlQueryRaw<decimal>("select IDENT_CURRENT('Widgets') as Value").AsEnumerable().Single();
94-
95119
Assert.AreEqual(3m, ic);
96120
}
97121

0 commit comments

Comments
 (0)