Skip to content

Commit 38ba889

Browse files
committed
Added statement-level atomicity (Bundle 1 of the transactions arc): a per-statement undo log restores heap state when an INSERT / UPDATE / DELETE / MERGE throws mid-execution, matching SQL Server's auto-commit-mode rollback behavior.
1 parent 6bb4b81 commit 38ba889

12 files changed

Lines changed: 372 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,15 @@ Per-type keyword compatibility mirrors SQL Server (verified against Kardax7 2026
196196
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate.
197197
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree backing.
198198

199+
### Transactions (statement-level atomicity, Bundle 1)
200+
Auto-commit-mode statement atomicity: a single INSERT / UPDATE / DELETE / MERGE that throws mid-execution rolls back its partial writes. A multi-row INSERT whose third row violates a constraint leaves zero rows behind, not two. Probe-confirmed against SQL Server 2025 (2026-05-08).
201+
202+
`Storage/UndoLog.cs` records heap mutations as a row-level LIFO list (`(Heap, UndoKind, pageIdx, slotIdx)` tuples). `Heap.Insert` / `Heap.DeleteAt` take an optional log; on success they append. `Simulation.RunMutation` wraps each mutation statement: install a fresh log on `ParserContext.CurrentUndoLog`, run the body, on exception walk the log backwards (insert → tombstone slot, delete → clear tombstone) before re-raising.
203+
204+
Identity counters and the database-scoped rowversion counter intentionally bypass the log — probe-confirmed: both keep advancing even when their consuming inserts are rolled back. LOB chains allocated for rolled-back inserts also bypass the log; they leak the same way committed deletes leak (existing quirk).
205+
206+
Explicit transactions (`Database.BeginTransaction()`, `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` SQL) aren't yet wired through. `BeginTransaction` returns a `SimulatedDbTransaction` whose `Commit()` is a no-op and whose `Rollback()` still throws `NotImplementedException`. Bundle 2 will lift the log to a per-connection lifetime spanning multiple statements; Bundle 3 will add SQL-text BEGIN / COMMIT / ROLLBACK and TRANCOUNT semantics.
207+
199208
### UPDATE / DELETE
200209
- `UPDATE table SET col = expr [, col = expr]* [WHERE pred]` and `DELETE [FROM] table [WHERE pred]`.
201210
- Multi-table-syntax form (`UPDATE alias SET alias.col = expr FROM table AS alias [WHERE pred]`, `DELETE FROM alias FROM table AS alias [WHERE pred]`) is the EF7+ `ExecuteUpdate` / `ExecuteDelete` shape. Single-source-only — additional sources or joins on the FROM clause raise `NotSupportedException`. Two-pass parsing: collect raw `(columnName, expr)` pairs without resolving ordinals, then bind to the FROM-clause table once known. SET LHS supports both bare `col = expr` and alias-qualified `[a].[col] = expr`; the alias prefix is accepted verbatim and not cross-checked against the FROM-clause's alias since the simulator's row resolvers use `name.Leaf` (the alias is moot for single-source). OUTPUT is only supported on the single-table form (EF doesn't combine OUTPUT with multi-table-syntax) — see `Simulation.Update.cs` / `Simulation.Delete.cs` for the deferred-table-binding pattern.
@@ -246,7 +255,7 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
246255

247256
## Not modeled
248257

249-
- Transactions / locks / MVCC.
258+
- Explicit transactions across statements (Bundle 2): `Database.BeginTransaction()` returns a `SimulatedDbTransaction` whose `Commit()` is a no-op and whose `Rollback()` throws `NotImplementedException`. SQL-text `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` (Bundle 3) and TRANCOUNT semantics. Savepoints (`SAVE TRANSACTION` / `ROLLBACK TRANSACTION sp`). Locks / MVCC / isolation levels.
250259
- `RIGHT JOIN` (rewrite as LEFT with sources swapped); `FULL OUTER JOIN`. Both raise `NotSupportedException` at parse.
251260
- Comma-separated FROM (legacy ANSI-89 join syntax).
252261
- Plain derived tables in FROM (without APPLY) don't see outer scope — they execute eagerly at parse time. Lateral access requires `CROSS APPLY` / `OUTER APPLY`. SQL Server actually allows derived-table-sees-outer in any FROM subquery; the gap shows up in compound shapes like `(SELECT … FROM (SELECT TOP(N) … WHERE outer.col = …) AS t)` where the inner correlation isn't expressed via APPLY.

SqlServerSimulator.Tests.EFCore/EFCorePrimaryKey.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,30 @@ public void SaveChanges_DuplicateKey_RaisesDbUpdateException()
4242
Assert.Contains("WIDGET-A", ex.InnerException.Message);
4343
}
4444

45+
[TestMethod]
46+
public void SaveChanges_BatchWithMidBatchPkViolation_RollsBackEntireBatch()
47+
{
48+
// EF Core 10 batches multiple Add()s of the same entity type into a
49+
// single multi-row INSERT statement. SQL Server's auto-commit-mode
50+
// statement atomicity (Bundle 1) means a mid-batch PK collision rolls
51+
// back the entire INSERT — neither the valid rows before nor after
52+
// the collision land in the table.
53+
using var context = new TestDbContext(TestDbContext.CreateInventorySimulation());
54+
_ = context.Inventory.Add(new Inventory { Sku = "EXISTING", Quantity = 1 });
55+
_ = context.SaveChanges();
56+
57+
using var context2 = new TestDbContext(context.Simulation);
58+
_ = context2.Inventory.Add(new Inventory { Sku = "NEW-1", Quantity = 10 });
59+
_ = context2.Inventory.Add(new Inventory { Sku = "EXISTING", Quantity = 99 });
60+
_ = context2.Inventory.Add(new Inventory { Sku = "NEW-2", Quantity = 30 });
61+
62+
_ = Assert.Throws<DbUpdateException>(() => context2.SaveChanges());
63+
64+
using var context3 = new TestDbContext(context.Simulation);
65+
var skus = context3.Inventory.AsNoTracking().Select(i => i.Sku).OrderBy(s => s).ToArray();
66+
CollectionAssert.AreEqual(new[] { "EXISTING" }, skus);
67+
}
68+
4569
[TestMethod]
4670
public async Task SaveChangesAsync_DuplicateKey_RaisesDbUpdateException()
4771
{
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for SQL Server's statement-level atomicity in auto-commit mode:
8+
/// when a single statement (multi-row INSERT, multi-row UPDATE, MERGE)
9+
/// fails mid-execution, the partial writes are rolled back. Probe-confirmed
10+
/// against SQL Server 2025 (2026-05-08): a multi-row INSERT whose third row
11+
/// violates a constraint leaves zero rows behind, not two. Identity and
12+
/// rowversion counters intentionally stay outside the rollback — they keep
13+
/// advancing even when the writes that consumed them are undone.
14+
/// </summary>
15+
[TestClass]
16+
public sealed class StatementAtomicityTests
17+
{
18+
private static int CountRows(DbConnection conn, string table) =>
19+
(int)conn.CreateCommand($"select count(*) from {table}").ExecuteScalar()!;
20+
21+
[TestMethod]
22+
public void MultiRowInsert_PrimaryKeyViolation_RollsBackEntireStatement()
23+
{
24+
using var connection = new Simulation().CreateOpenConnection();
25+
_ = connection.CreateCommand("create table t (id int primary key, val int)").ExecuteNonQuery();
26+
27+
// First row inserts (id=1); second row violates PK. SQL Server rolls
28+
// back the entire statement → 0 rows. Without statement atomicity the
29+
// simulator would have left (1, 100) behind.
30+
_ = Throws<DbException>(() =>
31+
_ = connection.CreateCommand("insert into t values (1, 100), (1, 200), (3, 300)").ExecuteNonQuery());
32+
AreEqual(0, CountRows(connection, "t"));
33+
}
34+
35+
[TestMethod]
36+
public void MultiRowInsert_CheckViolation_RollsBackEntireStatement()
37+
{
38+
using var connection = new Simulation().CreateOpenConnection();
39+
_ = connection.CreateCommand("create table t (id int, val int check (val < 100))").ExecuteNonQuery();
40+
41+
_ = Throws<DbException>(() =>
42+
_ = connection.CreateCommand("insert into t values (1, 50), (2, 150), (3, 80)").ExecuteNonQuery());
43+
AreEqual(0, CountRows(connection, "t"));
44+
}
45+
46+
[TestMethod]
47+
public void MultiRowInsert_NotNullViolation_RollsBackEntireStatement()
48+
{
49+
using var connection = new Simulation().CreateOpenConnection();
50+
_ = connection.CreateCommand("create table t (id int, val int not null)").ExecuteNonQuery();
51+
52+
_ = Throws<DbException>(() =>
53+
_ = connection.CreateCommand("insert into t values (1, 10), (2, null), (3, 30)").ExecuteNonQuery());
54+
AreEqual(0, CountRows(connection, "t"));
55+
}
56+
57+
[TestMethod]
58+
public void MultiRowUpdate_KeyCollisionMidBatch_RollsBackEntireStatement()
59+
{
60+
using var connection = new Simulation().CreateOpenConnection();
61+
_ = connection.CreateCommand("create table t (id int primary key, val int)").ExecuteNonQuery();
62+
_ = connection.CreateCommand("insert into t values (1, 10), (2, 20), (3, 30)").ExecuteNonQuery();
63+
64+
// Setting id = id + 100 to one row at a time would fail if id=1 collides with id=101 etc.
65+
// Bulk update: set id = 99 for both rows where id < 3 — both target the same key.
66+
_ = Throws<DbException>(() =>
67+
_ = connection.CreateCommand("update t set id = 99 where id < 3").ExecuteNonQuery());
68+
69+
// All three pre-update rows visible; no row got the new id=99.
70+
var rows = new List<(int, int)>();
71+
using var reader = connection.CreateCommand("select id, val from t order by id").ExecuteReader();
72+
while (reader.Read()) rows.Add((reader.GetInt32(0), reader.GetInt32(1)));
73+
CollectionAssert.AreEqual(new[] { (1, 10), (2, 20), (3, 30) }, rows);
74+
}
75+
76+
[TestMethod]
77+
public void MultiRowDelete_NeverFails_AllRowsRemoved()
78+
{
79+
// DELETE doesn't have constraint failures — it just tombstones rows.
80+
// This test confirms the undo log doesn't accidentally undo successful
81+
// deletes (the log discards on success).
82+
using var connection = new Simulation().CreateOpenConnection();
83+
_ = connection.CreateCommand("create table t (id int)").ExecuteNonQuery();
84+
_ = connection.CreateCommand("insert into t values (1), (2), (3)").ExecuteNonQuery();
85+
_ = connection.CreateCommand("delete from t where id < 3").ExecuteNonQuery();
86+
AreEqual(1, CountRows(connection, "t"));
87+
}
88+
89+
[TestMethod]
90+
public void Merge_WhenNotMatchedInsert_PartialFailure_RollsBackBatch()
91+
{
92+
// EF SaveChanges multi-row shape: MERGE INTO target USING (VALUES …) ...
93+
// WHEN NOT MATCHED THEN INSERT. A constraint violation on the second
94+
// VALUES row should leave zero rows in the target.
95+
using var connection = new Simulation().CreateOpenConnection();
96+
_ = connection.CreateCommand("create table t (id int primary key, val int)").ExecuteNonQuery();
97+
_ = connection.CreateCommand("insert into t values (5, 500)").ExecuteNonQuery();
98+
99+
_ = Throws<DbException>(() =>
100+
_ = connection.CreateCommand(
101+
"merge into t using (values (1, 10), (5, 50), (3, 30)) as src(id, val) " +
102+
"on t.id = src.id " +
103+
"when not matched then insert (id, val) values (src.id, src.val);").ExecuteNonQuery());
104+
105+
// The PK collision on (5, 50) should roll back the (1, 10) insert too.
106+
var rows = new List<(int, int)>();
107+
using var reader = connection.CreateCommand("select id, val from t order by id").ExecuteReader();
108+
while (reader.Read()) rows.Add((reader.GetInt32(0), reader.GetInt32(1)));
109+
CollectionAssert.AreEqual(new[] { (5, 500) }, rows);
110+
}
111+
112+
[TestMethod]
113+
public void IdentityCounter_AdvancesEvenOnRollback()
114+
{
115+
// Probe-confirmed against SQL Server 2025: identity advances even when
116+
// the inserts that consumed values are rolled back.
117+
using var connection = new Simulation().CreateOpenConnection();
118+
_ = connection.CreateCommand(
119+
"create table t (id int identity(1,1) primary key, val int check (val < 100))").ExecuteNonQuery();
120+
_ = connection.CreateCommand("insert into t (val) values (50)").ExecuteNonQuery();
121+
122+
// Second insert violates CHECK; statement rolls back. Identity should
123+
// still have advanced (the value 2 is "consumed" and gapped).
124+
_ = Throws<DbException>(() =>
125+
_ = connection.CreateCommand("insert into t (val) values (200)").ExecuteNonQuery());
126+
127+
// Third insert: id should be 3, not 2 (matching SQL Server's gap behavior).
128+
_ = connection.CreateCommand("insert into t (val) values (60)").ExecuteNonQuery();
129+
var ids = new List<int>();
130+
using var reader = connection.CreateCommand("select id from t order by id").ExecuteReader();
131+
while (reader.Read()) ids.Add(reader.GetInt32(0));
132+
CollectionAssert.AreEqual(new[] { 1, 3 }, ids);
133+
}
134+
135+
[TestMethod]
136+
public void RowVersion_AdvancesEvenOnRollback()
137+
{
138+
// Same probe-confirmed semantic for rowversion: the database-scoped
139+
// counter advances even when the inserts are rolled back.
140+
using var connection = new Simulation().CreateOpenConnection();
141+
_ = connection.CreateCommand("create table t (id int primary key, rv rowversion)").ExecuteNonQuery();
142+
_ = connection.CreateCommand("insert into t (id) values (1)").ExecuteNonQuery();
143+
var rv1 = (byte[])connection.CreateCommand("select rv from t where id = 1").ExecuteScalar()!;
144+
145+
_ = Throws<DbException>(() =>
146+
_ = connection.CreateCommand("insert into t values (1, 0x0000000000000000)").ExecuteNonQuery());
147+
148+
_ = connection.CreateCommand("insert into t (id) values (3)").ExecuteNonQuery();
149+
var rv3 = (byte[])connection.CreateCommand("select rv from t where id = 3").ExecuteScalar()!;
150+
151+
// Big-endian 8-byte counter; rv3 should be > rv1 + 1 (rolled-back insert consumed a value).
152+
var v1 = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(rv1);
153+
var v3 = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(rv3);
154+
IsGreaterThan(v1 + 1, v3, $"Expected gap in rowversion (v1={v1}, v3={v3}); rolled-back insert should consume a value.");
155+
}
156+
157+
[TestMethod]
158+
public void RolledBackInsert_DoesNotPersist_ButTableRemainsUsable()
159+
{
160+
// After a rolled-back statement, subsequent statements still work
161+
// — the heap is in a consistent state.
162+
using var connection = new Simulation().CreateOpenConnection();
163+
_ = connection.CreateCommand("create table t (id int primary key)").ExecuteNonQuery();
164+
_ = connection.CreateCommand("insert into t values (1)").ExecuteNonQuery();
165+
166+
_ = Throws<DbException>(() =>
167+
_ = connection.CreateCommand("insert into t values (2), (1), (3)").ExecuteNonQuery());
168+
169+
// A subsequent legitimate insert succeeds.
170+
_ = connection.CreateCommand("insert into t values (4)").ExecuteNonQuery();
171+
var ids = new List<int>();
172+
using var reader = connection.CreateCommand("select id from t order by id").ExecuteReader();
173+
while (reader.Read()) ids.Add(reader.GetInt32(0));
174+
CollectionAssert.AreEqual(new[] { 1, 4 }, ids);
175+
}
176+
}

SqlServerSimulator/Parser/ParserContext.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ internal sealed class ParserContext(SimulatedDbCommand command)
5252
/// </summary>
5353
public Token? Token;
5454

55+
/// <summary>
56+
/// Heap-mutation undo log scoped to the current top-level statement. Set
57+
/// by <see cref="Simulation.CreateResultSetsForCommand"/>'s mutation
58+
/// dispatch around each INSERT / UPDATE / DELETE / MERGE; the
59+
/// <see cref="Heap.Insert"/> / <see cref="Heap.DeleteAt"/>
60+
/// call sites read it from here and append entries on success. A
61+
/// statement that throws mid-execution (e.g. a multi-row INSERT whose
62+
/// fourth row violates a constraint) walks the log backwards before
63+
/// the exception propagates, restoring the heap to its pre-statement
64+
/// state. Bundle 2 will reuse the same log shape, lifetime extended
65+
/// across statements when an explicit <c>BeginTransaction</c> is open.
66+
/// </summary>
67+
public UndoLog? CurrentUndoLog;
68+
5569
/// <summary>
5670
/// True while an <c>Expression.Parse</c> call is running for a
5771
/// <c>CREATE TABLE</c> column's <c>DEFAULT</c> clause. Set by the

SqlServerSimulator/Simulation.Delete.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ SqlValue Resolve(MultiPartName name)
9898
}
9999

100100
foreach (var (pageIndex, slotIndex, _) in deleted)
101-
table.Heap.DeleteAt(pageIndex, slotIndex);
101+
table.Heap.DeleteAt(pageIndex, slotIndex, context.CurrentUndoLog);
102102

103103
if (output is not null)
104104
{

SqlServerSimulator/Simulation.Insert.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ private static SimulatedStatementOutcome ProcessHeapInsert(HeapTable destination
217217

218218
var storedValues = ProjectStoredValues(destinationTable, rowValues);
219219
EnforceKeyConstraints(destinationTable, storedValues);
220-
destinationTable.Heap.Insert(RowEncoder.EncodeRow(destinationTable.StoredColumns, storedValues, destinationTable.Heap));
220+
destinationTable.Heap.Insert(RowEncoder.EncodeRow(destinationTable.StoredColumns, storedValues, destinationTable.Heap), context.CurrentUndoLog);
221221

222222
if (output is { } o)
223223
outputRows!.Add(o.ProjectRow(rowValues, sourceRowValues: null));

SqlServerSimulator/Simulation.Merge.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ SqlValue ResolveSource(MultiPartName name)
311311

312312
var storedValues = ProjectStoredValues(destinationTable, rowValues);
313313
EnforceKeyConstraints(destinationTable, storedValues);
314-
destinationTable.Heap.Insert(RowEncoder.EncodeRow(destinationTable.StoredColumns, storedValues, destinationTable.Heap));
314+
destinationTable.Heap.Insert(RowEncoder.EncodeRow(destinationTable.StoredColumns, storedValues, destinationTable.Heap), context.CurrentUndoLog);
315315
insertedCount++;
316316

317317
if (output is { } o)

SqlServerSimulator/Simulation.Update.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,16 @@ SqlValue ResolveOriginal(MultiPartName name)
216216
// Phase 2: PK / UNIQUE validation against the post-update virtual state.
217217
EnforceKeyConstraintsForUpdate(table, affected);
218218

219-
// Phase 3: tombstone old, insert new. Validation has already passed,
220-
// so no rollback is required.
219+
// Phase 3: tombstone old, insert new. Validation has already passed —
220+
// no statement-level rollback gets triggered for these writes, but
221+
// pass the undo log anyway so an explicit transaction (Bundle 2) can
222+
// unwind them later. Each pair (delete-old, insert-new) records two
223+
// log entries; LIFO walk pops the insert first, then the delete,
224+
// restoring the original row.
221225
foreach (var (pageIndex, slotIndex, _, _) in affected)
222-
table.Heap.DeleteAt(pageIndex, slotIndex);
226+
table.Heap.DeleteAt(pageIndex, slotIndex, context.CurrentUndoLog);
223227
foreach (var (_, _, fullNew, _) in affected)
224-
table.Heap.Insert(RowEncoder.EncodeRow(table.StoredColumns, ProjectStoredValues(table, fullNew), table.Heap));
228+
table.Heap.Insert(RowEncoder.EncodeRow(table.StoredColumns, ProjectStoredValues(table, fullNew), table.Heap), context.CurrentUndoLog);
225229

226230
if (output is not null)
227231
{

0 commit comments

Comments
 (0)