Skip to content

Commit d99b13a

Browse files
committed
Added sp_getapplock / sp_releaseapplock / APPLOCK_MODE / APPLOCK_TEST. This unblocks EF Core Database.Migrate().
1 parent 9ca35fb commit d99b13a

16 files changed

Lines changed: 1491 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
166166
- **Table hints (`WITH (NOLOCK …)`) + statement `OPTION (…)` hints**[`query-hints.md`](docs/claude/query-hints.md).
167167
- **Per-`Simulation` plan cache** (single-SELECT `Selection` reuse keyed by text + db + parameter-type signature, `SchemaVersion`-stamped invalidation, inline-in-the-SELECT-arm promotion because the iterator's post-yield code is unreachable on a non-draining `ExecuteReader`; `VariableReference` Run-time slot lookup is the load-bearing co-fix) → [`plan-cache.md`](docs/claude/plan-cache.md).
168168
- **Locking, MVCC, SNAPSHOT/RCSI, deadlock/timeout, lock-related DMVs**[`locking.md`](docs/claude/locking.md).
169+
- **Application locks** (`sp_getapplock` / `sp_releaseapplock` / `APPLOCK_MODE` / `APPLOCK_TEST`, return-code-vs-raised-error asymmetry, EF Core 9/10 `Database.Migrate()`'s `__EFMigrationsLock`) → [`app-locks.md`](docs/claude/app-locks.md).
169170
- **`hierarchyid` data type** (incl. deferred byte-identical CAST research notes) → [`hierarchyid.md`](docs/claude/hierarchyid.md).
170171
- **`GRANT` / `REVOKE` / `DENY`, principal DDL, fixed-principal seed, principal scalars (`USER_ID` / `SUSER_ID` / `DATABASE_PRINCIPAL_ID` / `USER_NAME` / `SUSER_NAME` / `SUSER_SNAME` / `CURRENT_USER` / `SESSION_USER` / `SYSTEM_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER` / `IS_ROLEMEMBER` / `IS_SRVROLEMEMBER`)**[`permissions.md`](docs/claude/permissions.md).
171172
- **`CREATE FULLTEXT CATALOG`/`INDEX`, `CONTAINS`/`FREETEXT` rejection**[`full-text.md`](docs/claude/full-text.md).
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.EntityFrameworkCore.Diagnostics;
3+
using Microsoft.EntityFrameworkCore.Infrastructure;
4+
using Microsoft.EntityFrameworkCore.Metadata;
5+
using Microsoft.EntityFrameworkCore.Migrations;
6+
using System.ComponentModel.DataAnnotations.Schema;
7+
8+
namespace SqlServerSimulator;
9+
10+
/// <summary>
11+
/// Drives EF Core 10's <c>Database.Migrate()</c>
12+
/// end-to-end against the simulator. <c>Migrate()</c> internally wraps its
13+
/// work in <c>sp_getapplock '__EFMigrationsLock', 'Session', 'Exclusive'</c>
14+
/// / <c>sp_releaseapplock</c>, so a green run is the oracle that the
15+
/// application-lock surface satisfies EF Core's migration-locking contract —
16+
/// creating the history table, applying the pending migration, and recording
17+
/// it. Uses a hand-written <see cref="Migration"/> + <c>ModelSnapshot</c> pair
18+
/// (no dotnet-ef tooling).
19+
/// </summary>
20+
[TestClass]
21+
public class EFCoreMigrateTests
22+
{
23+
public TestContext TestContext { get; set; } = null!;
24+
25+
[TestMethod]
26+
public void Migrate_CreatesTable_AndSecondMigrateIsNoOp()
27+
{
28+
var simulation = new Simulation();
29+
30+
using (var context = new MigrateDbContext(simulation))
31+
{
32+
// First Migrate: acquires the __EFMigrationsLock applock, creates
33+
// the history table + the migration's Gadgets table, records the
34+
// applied migration, releases the applock.
35+
context.Database.Migrate();
36+
}
37+
38+
// The migrated table is real: insert and read a row back through EF.
39+
using (var context = new MigrateDbContext(simulation))
40+
{
41+
_ = context.Gadgets.Add(new Gadget { Name = "widget" });
42+
_ = context.SaveChanges();
43+
}
44+
45+
using (var context = new MigrateDbContext(simulation))
46+
{
47+
var name = context.Gadgets.Select(g => g.Name).Single();
48+
Assert.AreEqual("widget", name);
49+
}
50+
51+
// Second Migrate is a clean no-op: the migration id is already in the
52+
// history table, so nothing is re-applied and the row survives.
53+
using (var context = new MigrateDbContext(simulation))
54+
{
55+
context.Database.Migrate();
56+
}
57+
58+
using (var context = new MigrateDbContext(simulation))
59+
{
60+
Assert.AreEqual(1, context.Gadgets.Count());
61+
}
62+
}
63+
}
64+
65+
internal class Gadget
66+
{
67+
public int Id { get; set; }
68+
69+
[Column(TypeName = "nvarchar(50)")]
70+
public string Name { get; set; } = null!;
71+
}
72+
73+
internal class MigrateDbContext(Simulation simulation) : DbContext
74+
{
75+
private readonly Simulation simulation = simulation;
76+
77+
public DbSet<Gadget> Gadgets => Set<Gadget>();
78+
79+
// The hand-written ModelSnapshot isn't a byte-exact match for the
80+
// runtime model (EF's design-time tooling would normally keep them in
81+
// sync); the snapshot-vs-model diff is orthogonal to the applock path
82+
// Migrate() exercises, so suppress the resulting pending-changes gate.
83+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
84+
optionsBuilder
85+
.UseSqlServer(this.simulation.CreateDbConnection())
86+
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
87+
88+
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
89+
modelBuilder.Entity<Gadget>();
90+
}
91+
92+
[DbContext(typeof(MigrateDbContext))]
93+
[Migration("20240101000000_InitialCreate")]
94+
internal partial class InitialCreate : Migration
95+
{
96+
protected override void Up(MigrationBuilder migrationBuilder) =>
97+
migrationBuilder.CreateTable(
98+
name: "Gadgets",
99+
columns: table => new
100+
{
101+
Id = table.Column<int>(type: "int", nullable: false)
102+
.Annotation("SqlServer:Identity", "1, 1"),
103+
Name = table.Column<string>(type: "nvarchar(50)", nullable: false),
104+
},
105+
constraints: table => table.PrimaryKey("PK_Gadgets", x => x.Id));
106+
107+
protected override void Down(MigrationBuilder migrationBuilder) =>
108+
migrationBuilder.DropTable(name: "Gadgets");
109+
}
110+
111+
[DbContext(typeof(MigrateDbContext))]
112+
internal partial class MigrateDbContextModelSnapshot : ModelSnapshot
113+
{
114+
protected override void BuildModel(ModelBuilder modelBuilder)
115+
{
116+
_ = modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
117+
118+
_ = modelBuilder.Entity("SqlServerSimulator.Gadget", b =>
119+
{
120+
_ = b.Property<int>("Id")
121+
.ValueGeneratedOnAdd()
122+
.HasColumnType("int");
123+
124+
_ = b.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
125+
126+
_ = b.Property<string>("Name")
127+
.IsRequired()
128+
.HasColumnType("nvarchar(50)");
129+
130+
_ = b.HasKey("Id");
131+
132+
_ = b.ToTable("Gadgets");
133+
});
134+
}
135+
}

0 commit comments

Comments
 (0)