Skip to content

Commit b42f4b7

Browse files
committed
Added single-table UPDATE table SET col = expr [, ...] [WHERE pred] and DELETE [FROM] table [WHERE pred] with two-phase constraint re-validation, page-slot tombstones, and literal-only OUTPUT support (enough for EF Core 8's OUTPUT 1 rows-affected detector).
1 parent 8256dad commit b42f4b7

12 files changed

Lines changed: 1139 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,14 @@ Top-level OFFSET/FETCH (post-set-op chain) attaches alongside the top-level ORDE
160160
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate.
161161
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree backing.
162162

163+
### UPDATE / DELETE
164+
- `UPDATE table SET col = expr [, col = expr]* [WHERE pred]` and `DELETE [FROM] table [WHERE pred]`. Single-table only.
165+
- **Multi-column SET evaluates RHS against the pre-update row snapshot** — verified: `UPDATE t SET a = 100, b = a + 1` over `(a=10, b=20)` produces `(a=100, b=11)` (b read pre-update a). Scalar subquery RHS sees the pre-update table state.
166+
- Identity-column update → **Msg 8102** `"Cannot update identity column 'X'."`. Computed-column update → Msg 271 (existing factory).
167+
- Per-row constraint re-validation: NOT NULL → **Msg 515** with `"UPDATE fails."` verb; CHECK → **Msg 547** with `"UPDATE statement"` verb. PK / UNIQUE → Msg 2627 (same wording as INSERT — verbatim SQL Server quirk: "Cannot insert duplicate key" wording even on UPDATE).
168+
- Two-phase execution: phase 1 picks affected rows + computes new values + per-row validation; phase 2 validates PK / UNIQUE against the post-update virtual state (other affected rows' new keys + non-affected heap rows' existing keys); phase 3 mutates (tombstone old, insert new).
169+
- Literal-only `OUTPUT` clause on UPDATE / DELETE: `OUTPUT 1` and similar literal / parameter projections supported (one yielded row per affected row). EF Core 8's SaveChanges flow emits `OUTPUT 1` as a rows-affected detector on every modify and remove — that's the case this enables. Storage uses page-slot tombstones (high bit on slot directory entry; row payload bytes not reclaimed) and orphaned LOB chains stay in `Heap.LobPages` (see Quirks below).
170+
163171
### MERGE / OUTPUT (EF Core SaveChanges shape only)
164172
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
165173
- `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch).
@@ -188,7 +196,9 @@ Comparison (Msg 402), ORDER BY / DISTINCT (Msg 306), and aggregates (Msg 8117 fr
188196
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121` for date-like → string. Other styles raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date parsing not modeled.
189197
- Cross-category `Promote` for integer ↔ string. Only CAST works that pair.
190198
- `LEN(ntext)` raising Msg 8116 (function-level text/ntext/image restrictions); legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
191-
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*`, OUTPUT on UPDATE/DELETE, `INSERTED.*` star expansion.
199+
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*`, `INSERTED.*` star expansion. Full `OUTPUT INSERTED.<col>` / `OUTPUT DELETED.<col>` support on UPDATE / DELETE (literal-only OUTPUT *is* supported — see "What's modeled / UPDATE / DELETE"). The INSERTED / DELETED resolver work pairs with the rowversion + MERGE WHEN MATCHED bundle that closes the optimistic-concurrency story; trigger support also depends on it.
200+
- Multi-table UPDATE / DELETE (`UPDATE alias SET ... FROM table AS alias`, `DELETE alias FROM ...`). EF7+ `ExecuteUpdate` / `ExecuteDelete` emit these and won't work without the bundle.
201+
- `rowversion` type (paired with full UPDATE / DELETE OUTPUT for `[Timestamp]` concurrency tracking).
192202
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`.
193203
- Msg 8141 (inline CHECK referencing a peer column — SQL Server rejects at CREATE TABLE; simulator allows).
194204
- Msg 8133 (CASE where every branch is bare `NULL`; simulator returns NULL of `int`).
@@ -207,3 +217,6 @@ Comparison (Msg 402), ORDER BY / DISTINCT (Msg 306), and aggregates (Msg 8117 fr
207217
- `float` text formatting: .NET `G15` / `G7` rather than SQL Server's `1e+015`-style scientific.
208218
- CAST to a smaller `varchar` / `nvarchar` than the value renders: SQL Server silently truncates; simulator returns the full string.
209219
- Auto-generated PK / UNIQUE / CHECK constraint names: structurally `PK__<table>__<hex>` / `UQ__...` / `CK__<table>__[col__]<hex>` matching SQL Server's shape; the 16-hex suffix is a deterministic FNV-1a hash, not SQL Server's object-id-derived hex (stable across runs but won't byte-match a real-server reproduction).
220+
- **DELETE / UPDATE leak page space**: deleted (or UPDATE-relocated) row payload bytes stay in their original page until process exit; only the slot is tombstoned. Slot directory entries are also never reused. SQL Server has ghost-cleanup background work that the simulator doesn't model.
221+
- **DELETE / UPDATE leak LOB chains**: when a row is removed or its LOB-pointed value replaced, the orphaned LOB chain stays in `Heap.LobPages`. Other rows reference LOB pages by stable index, so list compaction would corrupt them; full LOB lifecycle (free-list / tombstones) is a follow-up bundle.
222+
- **Mass-shift UPDATE on a unique key**: `UPDATE t SET k = k + 1` where `k` is PK / UNIQUE produces a per-row collision check that may spuriously raise Msg 2627 — the simulator's two-phase validator compares each affected row's new key against other affected rows' new keys, so a "shift" pattern where post-shift values overlap pre-shift values triggers a false positive. SQL Server uses a temp store that staging-applies all updates before validation. Real EF Core SaveChanges patterns don't hit this.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end tests for the UPDATE / DELETE shapes EF Core's SqlServer
5+
/// provider emits during <c>SaveChanges</c> on modified or removed
6+
/// entities. Without this bundle the simulator couldn't faithfully stand
7+
/// in for any real EF Core app — the canonical "load, modify, save"
8+
/// flow ended at SaveChanges. Concurrency tokens (rowversion + UPDATE
9+
/// OUTPUT) and the EF7+ bulk operations (ExecuteUpdate / ExecuteDelete)
10+
/// are both deferred to follow-up bundles; see CLAUDE.md.
11+
/// </summary>
12+
[TestClass]
13+
public class EFCoreUpdateDelete
14+
{
15+
public TestContext TestContext { get; set; } = null!;
16+
17+
private static TestDbContext SeededContext()
18+
{
19+
var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
20+
context.People.AddRange(
21+
new Person { Id = 1, Name = "alice", Code = "A" },
22+
new Person { Id = 2, Name = "bob", Code = "B" },
23+
new Person { Id = 3, Name = "carol", Code = "C" },
24+
new Person { Id = 4, Name = "dave", Code = "D" });
25+
_ = context.SaveChanges();
26+
return context;
27+
}
28+
29+
[TestMethod]
30+
public async Task SaveChanges_ModifyExisting_EmitsUpdate()
31+
{
32+
// The most basic real-app workflow: load an entity, change a
33+
// property, save. EF Core emits UPDATE [People] SET [Name] = @p0
34+
// WHERE [Id] = @p1. Pre-this-bundle, this would throw at
35+
// SaveChanges with "Incorrect syntax near 'update'".
36+
using var context = SeededContext();
37+
var alice = context.People.Single(p => p.Name == "alice");
38+
alice.Name = "ALICE";
39+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
40+
41+
// Read back through a fresh context (different connection) to
42+
// confirm the update persisted at the simulation level, not
43+
// just in the change tracker.
44+
using var fresh = new TestDbContext(context.Simulation);
45+
var reloaded = fresh.People.Single(p => p.Id == alice.Id);
46+
Assert.AreEqual("ALICE", reloaded.Name);
47+
}
48+
49+
[TestMethod]
50+
public async Task SaveChanges_ModifyMultipleProperties_OneUpdate()
51+
{
52+
using var context = SeededContext();
53+
var bob = context.People.Single(p => p.Name == "bob");
54+
bob.Name = "BOB";
55+
bob.Code = "BB";
56+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
57+
58+
using var fresh = new TestDbContext(context.Simulation);
59+
var reloaded = fresh.People.Single(p => p.Id == bob.Id);
60+
Assert.AreEqual("BOB", reloaded.Name);
61+
Assert.AreEqual("BB", reloaded.Code);
62+
}
63+
64+
[TestMethod]
65+
public async Task SaveChanges_RemoveEntity_EmitsDelete()
66+
{
67+
using var context = SeededContext();
68+
var carol = context.People.Single(p => p.Name == "carol");
69+
_ = context.People.Remove(carol);
70+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
71+
72+
using var fresh = new TestDbContext(context.Simulation);
73+
var names = fresh.People.OrderBy(p => p.Id).Select(p => p.Name).ToArray();
74+
CollectionAssert.AreEqual(new[] { "alice", "bob", "dave" }, names);
75+
}
76+
77+
[TestMethod]
78+
public async Task SaveChanges_ModifyAndRemove_BothApplied()
79+
{
80+
using var context = SeededContext();
81+
var alice = context.People.Single(p => p.Name == "alice");
82+
alice.Name = "AAA";
83+
var bob = context.People.Single(p => p.Name == "bob");
84+
_ = context.People.Remove(bob);
85+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
86+
87+
using var fresh = new TestDbContext(context.Simulation);
88+
var rows = fresh.People.OrderBy(p => p.Id).Select(p => p.Name).ToArray();
89+
CollectionAssert.AreEqual(new[] { "AAA", "carol", "dave" }, rows);
90+
}
91+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for <c>DELETE FROM table [WHERE pred]</c> (the
8+
/// <c>FROM</c> keyword is optional in T-SQL). Covers row-count return,
9+
/// no-WHERE bulk delete, no-match zero-affected, post-delete enumeration
10+
/// (tombstoned slots invisible), and post-delete INSERT (slot directory
11+
/// continues from current count). LOB chain reclamation isn't part of
12+
/// this bundle — see CLAUDE.md for the leak quirk.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class DeleteTests
16+
{
17+
private static List<int> ReadInts(DbCommand command)
18+
{
19+
using var reader = command.ExecuteReader();
20+
var values = new List<int>();
21+
while (reader.Read())
22+
values.Add(reader.GetInt32(0));
23+
return values;
24+
}
25+
26+
[TestMethod]
27+
public void Delete_BasicWhere_RemovesOneRow()
28+
{
29+
var simulation = new Simulation();
30+
_ = simulation.ExecuteNonQuery("create table t (id int, v int)");
31+
_ = simulation.ExecuteNonQuery("insert into t values (1, 10), (2, 20), (3, 30)");
32+
33+
var affected = simulation.ExecuteNonQuery("delete from t where id = 2");
34+
AreEqual(1, affected);
35+
36+
var values = ReadInts(simulation.CreateCommand("select id from t order by id"));
37+
CollectionAssert.AreEqual(new[] { 1, 3 }, values);
38+
}
39+
40+
[TestMethod]
41+
public void Delete_NoWhere_RemovesAllRows()
42+
{
43+
var simulation = new Simulation();
44+
_ = simulation.ExecuteNonQuery("create table t (id int)");
45+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2), (3), (4)");
46+
47+
var affected = simulation.ExecuteNonQuery("delete from t");
48+
AreEqual(4, affected);
49+
50+
var values = ReadInts(simulation.CreateCommand("select id from t"));
51+
IsEmpty(values);
52+
}
53+
54+
[TestMethod]
55+
public void Delete_WhereNoMatch_ZeroAffected()
56+
{
57+
var simulation = new Simulation();
58+
_ = simulation.ExecuteNonQuery("create table t (id int)");
59+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2)");
60+
61+
var affected = simulation.ExecuteNonQuery("delete from t where id = 999");
62+
AreEqual(0, affected);
63+
64+
var values = ReadInts(simulation.CreateCommand("select id from t order by id"));
65+
CollectionAssert.AreEqual(new[] { 1, 2 }, values);
66+
}
67+
68+
[TestMethod]
69+
public void Delete_OptionalFromKeyword_StillWorks()
70+
{
71+
// T-SQL allows DELETE without FROM (FROM is optional in single-table form).
72+
var simulation = new Simulation();
73+
_ = simulation.ExecuteNonQuery("create table t (id int)");
74+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2)");
75+
76+
var affected = simulation.ExecuteNonQuery("delete t where id = 1");
77+
AreEqual(1, affected);
78+
}
79+
80+
[TestMethod]
81+
public void Delete_NonexistentTable_RaisesMsg208()
82+
{
83+
var ex = Throws<DbException>(() => _ = new Simulation().ExecuteNonQuery("delete from no_such"));
84+
AreEqual("208", ex.Data["HelpLink.EvtID"]);
85+
}
86+
87+
[TestMethod]
88+
public void Delete_ThenInsert_NewRowVisibleAfterTombstone()
89+
{
90+
// Verifies tombstoned slots don't block subsequent INSERTs from
91+
// creating new visible rows in the heap.
92+
var simulation = new Simulation();
93+
_ = simulation.ExecuteNonQuery("create table t (id int, v varchar(20))");
94+
_ = simulation.ExecuteNonQuery("insert into t values (1, 'a'), (2, 'b')");
95+
_ = simulation.ExecuteNonQuery("delete from t where id = 1");
96+
_ = simulation.ExecuteNonQuery("insert into t values (3, 'c')");
97+
98+
var values = ReadInts(simulation.CreateCommand("select id from t order by id"));
99+
CollectionAssert.AreEqual(new[] { 2, 3 }, values);
100+
}
101+
102+
[TestMethod]
103+
public void Delete_AllThenInsert_HeapStillFunctional()
104+
{
105+
var simulation = new Simulation();
106+
_ = simulation.ExecuteNonQuery("create table t (id int)");
107+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2), (3)");
108+
_ = simulation.ExecuteNonQuery("delete from t");
109+
_ = simulation.ExecuteNonQuery("insert into t values (4), (5)");
110+
111+
var values = ReadInts(simulation.CreateCommand("select id from t order by id"));
112+
CollectionAssert.AreEqual(new[] { 4, 5 }, values);
113+
}
114+
115+
// === OUTPUT clause (literal-only) ===
116+
117+
[TestMethod]
118+
public void Delete_OutputLiteralOne_YieldsOneRowPerAffected()
119+
{
120+
// EF Core emits `DELETE FROM ... OUTPUT 1 WHERE ...` on
121+
// SaveChanges-Remove. Verifies the OUTPUT projection runs once per
122+
// deleted row.
123+
var simulation = new Simulation();
124+
_ = simulation.ExecuteNonQuery("create table t (id int)");
125+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2), (3)");
126+
127+
using var connection = simulation.CreateOpenConnection();
128+
using var reader = connection.CreateCommand("delete from t output 1 where id <= 2").ExecuteReader();
129+
var ones = new List<int>();
130+
while (reader.Read())
131+
ones.Add(reader.GetInt32(0));
132+
CollectionAssert.AreEqual(new[] { 1, 1 }, ones);
133+
}
134+
135+
[TestMethod]
136+
public void Delete_OutputDeletedColumn_RaisesNotSupported()
137+
{
138+
var simulation = new Simulation();
139+
_ = simulation.ExecuteNonQuery("create table t (id int)");
140+
_ = simulation.ExecuteNonQuery("insert into t values (1)");
141+
142+
_ = Throws<NotSupportedException>(() =>
143+
_ = simulation.ExecuteScalar("delete from t output deleted.id where id = 1"));
144+
}
145+
146+
[TestMethod]
147+
public void Delete_PrimaryKeyTable_AllowsReinsertAfterDelete()
148+
{
149+
// After DELETE removes the row with k=1, a new INSERT of k=1
150+
// must succeed (no PK collision against tombstoned data).
151+
var simulation = new Simulation();
152+
_ = simulation.ExecuteNonQuery("create table t (k int primary key, v int)");
153+
_ = simulation.ExecuteNonQuery("insert into t values (1, 10)");
154+
_ = simulation.ExecuteNonQuery("delete from t where k = 1");
155+
_ = simulation.ExecuteNonQuery("insert into t values (1, 20)");
156+
157+
var values = ReadInts(simulation.CreateCommand("select v from t"));
158+
CollectionAssert.AreEqual(new[] { 20 }, values);
159+
}
160+
}

0 commit comments

Comments
 (0)