Skip to content

Commit 4f8c9ef

Browse files
committed
Added support for various IDENTITY features.
1 parent 10d7acd commit 4f8c9ef

13 files changed

Lines changed: 958 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,11 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7272
- Transactions / locks / MVCC.
7373
- Row-overflow / LOB pages and the `varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` types they enable.
7474
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout. `money` / `smallmoney` are wired in raw SQL but unreachable through EF Core for the same `SqlParameter`-downcast reason as the date-only / time-only mappings — adding a money column to an EF entity breaks that entity's whole save path.
75-
- Identity columns.
7675
- `NEWSEQUENTIALID()` (deferred until `DEFAULT`-clause support exists in `CREATE TABLE`; the function is only valid in that context).
7776
- Pattern matching (`LIKE`) and `CONVERT`.
7877
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
7978
- 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.
80+
- 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.
8081
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
8182
- Heap allocation tracking (IAM/PFS): the page list is flat.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <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>.
14+
/// </summary>
15+
[TestClass]
16+
public class EFCoreIdentity
17+
{
18+
public TestContext TestContext { get; set; } = null!;
19+
20+
[TestMethod]
21+
public void Insert_IdentityKey_AwaitsOutputClauseSupport()
22+
{
23+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
24+
25+
var widget = new Widget { Name = "First" };
26+
_ = context.Widgets.Add(widget);
27+
28+
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
29+
StringAssert.Contains(ex.InnerException?.Message ?? "", "OUTPUT");
30+
}
31+
32+
[TestMethod]
33+
public void Query_IdentityRows_ReturnsAutoGeneratedIds()
34+
{
35+
var simulation = TestDbContext.CreateWidgetsSimulation();
36+
_ = simulation
37+
.CreateOpenConnection()
38+
.CreateCommand("insert into Widgets (Name) values ('A'),('B'),('C')")
39+
.ExecuteNonQuery();
40+
41+
using var context = new TestDbContext(simulation);
42+
var rows = context.Widgets.OrderBy(w => w.Id).ToArray();
43+
44+
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, rows.Select(w => w.Id).ToArray());
45+
CollectionAssert.AreEqual(new[] { "A", "B", "C" }, rows.Select(w => w.Name).ToArray());
46+
}
47+
48+
[TestMethod]
49+
public void Where_FiltersByAutoGeneratedId()
50+
{
51+
var simulation = TestDbContext.CreateWidgetsSimulation();
52+
_ = simulation
53+
.CreateOpenConnection()
54+
.CreateCommand("insert into Widgets (Name) values ('A'),('B'),('C')")
55+
.ExecuteNonQuery();
56+
57+
using var context = new TestDbContext(simulation);
58+
var name = context.Widgets.Where(w => w.Id == 2).Select(w => w.Name).Single();
59+
60+
Assert.AreEqual("B", name);
61+
}
62+
63+
[TestMethod]
64+
public void SqlQueryRaw_ScopeIdentity_ReturnsLastInsertedValue()
65+
{
66+
// SCOPE_IDENTITY/@@IDENTITY are simulation-wide here (not per-session) —
67+
// see CLAUDE.md's "session-scoped state" gap. That collapse means the
68+
// raw INSERT on a separate connection still updates the value EF reads.
69+
var simulation = TestDbContext.CreateWidgetsSimulation();
70+
_ = simulation
71+
.CreateOpenConnection()
72+
.CreateCommand("insert into Widgets (Name) values ('First'),('Second')")
73+
.ExecuteNonQuery();
74+
75+
using var context = new TestDbContext(simulation);
76+
var sid = context.Database.SqlQueryRaw<decimal>("select SCOPE_IDENTITY() as Value").AsEnumerable().Single();
77+
var atid = context.Database.SqlQueryRaw<decimal>("select @@IDENTITY as Value").AsEnumerable().Single();
78+
79+
Assert.AreEqual(2m, sid);
80+
Assert.AreEqual(2m, atid);
81+
}
82+
83+
[TestMethod]
84+
public void SqlQueryRaw_IdentCurrent_ReturnsHighWaterMark()
85+
{
86+
var simulation = TestDbContext.CreateWidgetsSimulation();
87+
_ = simulation
88+
.CreateOpenConnection()
89+
.CreateCommand("insert into Widgets (Name) values ('A'),('B'),('C')")
90+
.ExecuteNonQuery();
91+
92+
using var context = new TestDbContext(simulation);
93+
var ic = context.Database.SqlQueryRaw<decimal>("select IDENT_CURRENT('Widgets') as Value").AsEnumerable().Single();
94+
95+
Assert.AreEqual(3m, ic);
96+
}
97+
98+
[TestMethod]
99+
public void SqlQueryRaw_ScopeIdentity_BeforeAnyInsertIsNull()
100+
{
101+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
102+
var sid = context.Database.SqlQueryRaw<decimal?>("select SCOPE_IDENTITY() as Value").AsEnumerable().Single();
103+
104+
Assert.IsNull(sid);
105+
}
106+
107+
[TestMethod]
108+
public void SaveChanges_WithIdentityInsertOn_PersistsExplicitId()
109+
{
110+
var simulation = TestDbContext.CreateStickersSimulation();
111+
_ = simulation
112+
.CreateOpenConnection()
113+
.CreateCommand("set identity_insert Stickers on")
114+
.ExecuteNonQuery();
115+
116+
using var context = new TestDbContext(simulation);
117+
_ = context.Stickers.Add(new Sticker { Id = 100, Tag = "explicit" });
118+
_ = context.SaveChanges();
119+
120+
Assert.AreEqual("explicit", context.Stickers.Where(s => s.Id == 100).Select(s => s.Tag).Single());
121+
}
122+
123+
[TestMethod]
124+
public void SaveChanges_WithIdentityInsertOn_AdvancesSeed()
125+
{
126+
// Inserting an explicit Id past the high-water mark via EF should
127+
// reseed the same way raw SQL does.
128+
var simulation = TestDbContext.CreateStickersSimulation();
129+
_ = simulation
130+
.CreateOpenConnection()
131+
.CreateCommand("set identity_insert Stickers on")
132+
.ExecuteNonQuery();
133+
134+
using (var context = new TestDbContext(simulation))
135+
{
136+
_ = context.Stickers.Add(new Sticker { Id = 50, Tag = "jump" });
137+
_ = context.SaveChanges();
138+
}
139+
140+
var ic = simulation
141+
.CreateOpenConnection()
142+
.CreateCommand("select IDENT_CURRENT('Stickers')")
143+
.ExecuteScalar();
144+
Assert.AreEqual(50m, ic);
145+
}
146+
}

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,36 @@ internal class Product
111111
public decimal? Discount { get; set; }
112112
}
113113

114+
/// <summary>
115+
/// Exercises the simulator's <c>IDENTITY</c> column support through EF Core.
116+
/// EF Core defaults int primary keys to <c>ValueGeneratedOnAdd</c>; with the
117+
/// SqlServer provider this means the column is created with <c>IDENTITY(1, 1)</c>
118+
/// and SaveChanges expects the database to generate the key on insert.
119+
/// </summary>
120+
internal class Widget
121+
{
122+
public int Id { get; set; }
123+
124+
[Column(TypeName = "nvarchar(50)")]
125+
public string Name { get; set; } = null!;
126+
}
127+
128+
/// <summary>
129+
/// Counterpart to <see cref="Widget"/> with <see cref="DatabaseGeneratedOption.None"/>
130+
/// — EF Core treats <see cref="Id"/> as caller-supplied, so SaveChanges
131+
/// emits a plain INSERT (no <c>OUTPUT INSERTED</c>). Combined with
132+
/// <c>SET IDENTITY_INSERT Stickers ON</c>, the path exercises the
133+
/// simulator's identity-column INSERT through EF Core.
134+
/// </summary>
135+
internal class Sticker
136+
{
137+
[DatabaseGenerated(DatabaseGeneratedOption.None)]
138+
public int Id { get; set; }
139+
140+
[Column(TypeName = "nvarchar(20)")]
141+
public string Tag { get; set; } = null!;
142+
}
143+
114144
internal class TestDbContext(Simulation simulation) : DbContext
115145
{
116146
public Simulation Simulation { get; set; } = simulation;
@@ -135,6 +165,10 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
135165

136166
public DbSet<Product> Products => Set<Product>();
137167

168+
public DbSet<Widget> Widgets => Set<Widget>();
169+
170+
public DbSet<Sticker> Stickers => Set<Sticker>();
171+
138172
public static Simulation CreateDefaultSimulation(params ReadOnlySpan<int> values)
139173
{
140174
var simulation = new Simulation();
@@ -209,6 +243,36 @@ Discount decimal(5, 4) null
209243
return simulation;
210244
}
211245

246+
public static Simulation CreateWidgetsSimulation()
247+
{
248+
var simulation = new Simulation();
249+
_ = simulation
250+
.CreateOpenConnection()
251+
.CreateCommand("""
252+
create table Widgets (
253+
Id int identity(1, 1) not null,
254+
Name nvarchar(50) not null
255+
)
256+
""")
257+
.ExecuteNonQuery();
258+
return simulation;
259+
}
260+
261+
public static Simulation CreateStickersSimulation()
262+
{
263+
var simulation = new Simulation();
264+
_ = simulation
265+
.CreateOpenConnection()
266+
.CreateCommand("""
267+
create table Stickers (
268+
Id int identity(1, 1) not null,
269+
Tag nvarchar(20) not null
270+
)
271+
""")
272+
.ExecuteNonQuery();
273+
return simulation;
274+
}
275+
212276
public static Simulation CreateEventsSimulation()
213277
{
214278
var simulation = new Simulation();

0 commit comments

Comments
 (0)