Skip to content

Commit 5780770

Browse files
committed
Implemented PRIMARY KEY and UNIQUE constraints.
1 parent 9192b41 commit 5780770

11 files changed

Lines changed: 867 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,6 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
8181
- `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.
8282
- 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.
8383
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
84+
- `PRIMARY KEY` / `UNIQUE` on a computed column. SQL Server allows it (silently persisting the values for the underlying index); the simulator throws `NotSupportedException` because the enforcement loop would need to evaluate the computed expression against every existing row at insert time. Auto-generated constraint names use a deterministic FNV-1a hash for the 16-hex suffix, not SQL Server's object-id-derived hex — same shape, stable across runs but won't byte-match a real-server reproduction. PK/UNIQUE enforcement is a linear scan over the heap (O(N) per insert); no B-tree backing yet.
85+
- `NOT NULL` enforcement at INSERT. The column-level flag exists and CREATE TABLE rejects misuse (e.g. `IDENTITY` on a nullable column, `PRIMARY KEY` on an explicitly nullable column), but a plain `insert into t (notnullcol) values (null)` doesn't raise Msg 515 today.
8486
- Heap allocation tracking (IAM/PFS): the page list is flat.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Exercises the simulator's PRIMARY KEY constraint enforcement through EF
7+
/// Core. The duplicate-key path surfaces as <see cref="DbUpdateException"/>
8+
/// whose inner is the simulator's Msg 2627 — the same shape EF Core's
9+
/// SqlServer provider produces against a real database.
10+
/// </summary>
11+
[TestClass]
12+
public class EFCorePrimaryKey
13+
{
14+
public TestContext TestContext { get; set; } = null!;
15+
16+
[TestMethod]
17+
public void SaveChanges_FirstRow_RoundTripsThroughPrimaryKey()
18+
{
19+
using var context = new TestDbContext(TestDbContext.CreateInventorySimulation());
20+
21+
_ = context.Inventory.Add(new Inventory { Sku = "WIDGET-A", Quantity = 7 });
22+
_ = context.SaveChanges();
23+
24+
var fetched = context.Inventory.AsNoTracking().Single();
25+
Assert.AreEqual("WIDGET-A", fetched.Sku);
26+
Assert.AreEqual(7, fetched.Quantity);
27+
}
28+
29+
[TestMethod]
30+
public void SaveChanges_DuplicateKey_RaisesDbUpdateException()
31+
{
32+
using var context = new TestDbContext(TestDbContext.CreateInventorySimulation());
33+
34+
_ = context.Inventory.Add(new Inventory { Sku = "WIDGET-A", Quantity = 1 });
35+
_ = context.SaveChanges();
36+
37+
using var context2 = new TestDbContext(context.Simulation);
38+
_ = context2.Inventory.Add(new Inventory { Sku = "WIDGET-A", Quantity = 99 });
39+
var ex = Assert.Throws<DbUpdateException>(() => context2.SaveChanges());
40+
Assert.IsNotNull(ex.InnerException);
41+
Assert.Contains("PRIMARY KEY constraint 'pk_inventory'", ex.InnerException.Message);
42+
Assert.Contains("WIDGET-A", ex.InnerException.Message);
43+
}
44+
45+
[TestMethod]
46+
public async Task SaveChangesAsync_DuplicateKey_RaisesDbUpdateException()
47+
{
48+
await using var context = new TestDbContext(TestDbContext.CreateInventorySimulation());
49+
50+
_ = context.Inventory.Add(new Inventory { Sku = "BOLT-7", Quantity = 1 });
51+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
52+
53+
await using var context2 = new TestDbContext(context.Simulation);
54+
_ = context2.Inventory.Add(new Inventory { Sku = "BOLT-7", Quantity = 2 });
55+
var ex = await Assert.ThrowsAsync<DbUpdateException>(() => context2.SaveChangesAsync(this.TestContext.CancellationToken));
56+
Assert.IsNotNull(ex.InnerException);
57+
Assert.Contains("PRIMARY KEY constraint 'pk_inventory'", ex.InnerException.Message);
58+
}
59+
}

SqlServerSimulator.Tests.EFCore/TestDbContext.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,21 @@ internal class Receipt
183183
public decimal Total { get; set; }
184184
}
185185

186+
/// <summary>
187+
/// Exercises the simulator's PRIMARY KEY constraint through EF Core.
188+
/// <see cref="Sku"/> is the caller-supplied PK; SaveChanges round-trips
189+
/// through a normal INSERT. A duplicate-key insert surfaces as a wrapped
190+
/// <see cref="DbUpdateException"/> whose
191+
/// inner exception is the simulator's Msg 2627.
192+
/// </summary>
193+
internal class Inventory
194+
{
195+
[Column(TypeName = "nvarchar(50)")]
196+
public string Sku { get; set; } = null!;
197+
198+
public int Quantity { get; set; }
199+
}
200+
186201
/// <summary>
187202
/// Counterpart to <see cref="Widget"/> with <see cref="DatabaseGeneratedOption.None"/>
188203
/// — EF Core treats <see cref="Id"/> as caller-supplied, so SaveChanges
@@ -230,6 +245,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
230245
_ = modelBuilder.Entity<Receipt>()
231246
.Property(r => r.Total)
232247
.HasComputedColumnSql("[Subtotal] + [Tax]", stored: false);
248+
249+
// Pin Inventory's caller-supplied string PK so EF Core doesn't try
250+
// to generate values for it.
251+
_ = modelBuilder.Entity<Inventory>().HasKey(i => i.Sku);
233252
}
234253

235254
public DbSet<TestRow> Rows => Set<TestRow>();
@@ -252,6 +271,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
252271

253272
public DbSet<Receipt> Receipts => Set<Receipt>();
254273

274+
public DbSet<Inventory> Inventory => Set<Inventory>();
275+
255276
public static Simulation CreateDefaultSimulation(params ReadOnlySpan<int> values)
256277
{
257278
var simulation = new Simulation();
@@ -390,6 +411,21 @@ Total as Subtotal + Tax
390411
return simulation;
391412
}
392413

414+
public static Simulation CreateInventorySimulation()
415+
{
416+
var simulation = new Simulation();
417+
_ = simulation
418+
.CreateOpenConnection()
419+
.CreateCommand("""
420+
create table Inventory (
421+
Sku nvarchar(50) not null constraint pk_inventory primary key,
422+
Quantity int not null
423+
)
424+
""")
425+
.ExecuteNonQuery();
426+
return simulation;
427+
}
428+
393429
public static Simulation CreateStickersSimulation()
394430
{
395431
var simulation = new Simulation();

0 commit comments

Comments
 (0)