Skip to content

Commit 5f61533

Browse files
committed
Added explicit-transaction support (Bundle 2 of the transactions arc): DbConnection.BeginTransaction() now spans statements with real Commit / Rollback / dispose-rollback semantics, plus SAVE TRANSACTION / ROLLBACK TRANSACTION <name> SQL parsing for EF Core 10's per-SaveChanges savepoint pattern.
1 parent 38ba889 commit 5f61533

7 files changed

Lines changed: 524 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,18 @@ 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).
199+
### Transactions
200+
Three layers, each landed in its own bundle:
201201

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.
202+
**Bundle 1 — statement-level atomicity** (auto-commit). 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).
203203

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).
204+
**Bundle 2 — explicit transactions via SqlClient API** + savepoints. `DbConnection.BeginTransaction()` returns a `SimulatedDbTransaction` with a real cross-statement undo log. `Commit()` discards the log; `Rollback()` walks it backwards. Disposing the transaction without an explicit Commit / Rollback auto-rolls-back (mirrors `SqlTransaction`). Parallel transactions on the same connection raise `InvalidOperationException` ("SqlConnection does not support parallel transactions.") matching SqlClient. `SAVE TRAN[SACTION] <name>` / `ROLLBACK TRAN[SACTION] <name>` SQL is parsed (the EF Core 10 SaveChanges-inside-tx path emits these around each save). Names are case-insensitive identifiers; re-saving a name overwrites the prior marker.
205205

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.
206+
`Storage/UndoLog.cs` is a row-level LIFO list (`(Heap, UndoKind, pageIdx, slotIdx)` tuples) with a `Position` / `RollbackTo(position)` marker pattern. Statement-level rollback uses position-at-statement-start as the marker; an explicit transaction's full rollback uses position 0; a savepoint rollback uses the position recorded when SAVE TRANSACTION ran. `Heap.Insert` / `Heap.DeleteAt` take an optional log; on success they append. `Simulation.RunMutation` wraps each mutation statement: route to the connection's active-transaction log when one exists (Bundle 2 path) or create a fresh per-statement log (Bundle 1 path), capture marker, on exception `RollbackTo(marker)` before re-raising.
207+
208+
A failed statement inside an explicit transaction leaves the surrounding tx alive — only the failed statement's own writes are unwound (probe-confirmed, EF Core 10 relies on this for its mid-batch SaveChanges-failure recovery path).
209+
210+
Identity counters and the database-scoped rowversion counter intentionally bypass the log — 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.
207211

208212
### UPDATE / DELETE
209213
- `UPDATE table SET col = expr [, col = expr]* [WHERE pred]` and `DELETE [FROM] table [WHERE pred]`.
@@ -255,7 +259,7 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
255259

256260
## Not modeled
257261

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.
262+
- SQL-text `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` (Bundle 3, optional — EF Core uses SqlClient's API instead, so this is hand-written-SQL fidelity only) and TRANCOUNT-based nesting semantics. `ROLLBACK TRANSACTION` without a savepoint name (full-tx rollback as SQL text). Locks / MVCC / isolation levels.
259263
- `RIGHT JOIN` (rewrite as LEFT with sources swapped); `FULL OUTER JOIN`. Both raise `NotSupportedException` at parse.
260264
- Comma-separated FROM (legacy ANSI-89 join syntax).
261265
- 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.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// End-to-end tests for EF Core 10's <c>Database.BeginTransaction</c> path.
7+
/// Probe-confirmed against SQL Server 2025: EF wraps SaveChanges through
8+
/// SqlClient's transaction API (not raw <c>BEGIN TRANSACTION</c> SQL), so
9+
/// the simulator's connection-scoped <see cref="Storage.UndoLog"/> handles
10+
/// the entire transaction lifecycle. Bundle 1 already covered statement-
11+
/// level atomicity; Bundle 2 adds the cross-statement transaction scope.
12+
/// </summary>
13+
[TestClass]
14+
public class EFCoreTransaction
15+
{
16+
public TestContext TestContext { get; set; } = null!;
17+
18+
[TestMethod]
19+
public void Database_BeginTransaction_Commit_PersistsAcrossSaveChanges()
20+
{
21+
using var context = new TestDbContext(TestDbContext.CreateAuthorsSimulation());
22+
using var tx = context.Database.BeginTransaction();
23+
24+
_ = context.Authors.Add(new Author { Name = "alice" });
25+
_ = context.SaveChanges();
26+
_ = context.Books.Add(new Book { AuthorId = 1, Title = "B1", Score = 10 });
27+
_ = context.SaveChanges();
28+
29+
tx.Commit();
30+
31+
Assert.AreEqual(1, context.Authors.AsNoTracking().Count());
32+
Assert.AreEqual(1, context.Books.AsNoTracking().Count());
33+
}
34+
35+
[TestMethod]
36+
public void Database_BeginTransaction_Rollback_UndoesAcrossSaveChanges()
37+
{
38+
using var context = new TestDbContext(TestDbContext.CreateAuthorsSimulation());
39+
using (var tx = context.Database.BeginTransaction())
40+
{
41+
_ = context.Authors.Add(new Author { Name = "alice" });
42+
_ = context.SaveChanges();
43+
_ = context.Books.Add(new Book { AuthorId = 1, Title = "B1", Score = 10 });
44+
_ = context.SaveChanges();
45+
tx.Rollback();
46+
}
47+
48+
// Both saves rolled back together.
49+
Assert.AreEqual(0, context.Authors.AsNoTracking().Count());
50+
Assert.AreEqual(0, context.Books.AsNoTracking().Count());
51+
}
52+
53+
[TestMethod]
54+
public void SaveChangesFailure_InsideTx_LeavesTxAliveAndCommitable()
55+
{
56+
// Probe-confirmed shape: a SaveChanges that hits a constraint
57+
// mid-batch (rolled back via the EF-emitted ROLLBACK TRANSACTION
58+
// savepoint) leaves the surrounding transaction alive. The user
59+
// can continue with more SaveChanges or commit what survived.
60+
var simulation = TestDbContext.CreateAuthorsSimulation();
61+
// Pre-seed via raw SQL so we have a key to collide with.
62+
_ = simulation.CreateOpenConnection()
63+
.CreateCommand("set identity_insert Authors on; insert into Authors (Id, Name) values (1, 'alice'); set identity_insert Authors off;")
64+
.ExecuteNonQuery();
65+
66+
using var context = new TestDbContext(simulation);
67+
using var tx = context.Database.BeginTransaction();
68+
69+
// Bob saves successfully (auto-Id=2).
70+
_ = context.Authors.Add(new Author { Name = "bob" });
71+
_ = context.SaveChanges();
72+
73+
// Force a duplicate-PK collision against the pre-seeded alice (Id=1).
74+
var dup = new Author { Id = 1, Name = "duplicate" };
75+
_ = context.Authors.Add(dup);
76+
_ = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
77+
context.Entry(dup).State = EntityState.Detached;
78+
79+
// Tx alive — commit succeeds; bob's pre-failure save survives.
80+
tx.Commit();
81+
82+
var names = context.Authors.AsNoTracking().Select(a => a.Name).OrderBy(n => n).ToArray();
83+
CollectionAssert.AreEqual(new[] { "alice", "bob" }, names);
84+
}
85+
86+
[TestMethod]
87+
public void Using_TxDisposedWithoutCommit_AutoRollsBack()
88+
{
89+
// The classic "exception before tx.Commit()" pattern: the using-block
90+
// disposes the tx, which auto-rolls-back since neither Commit nor
91+
// Rollback ran.
92+
using var context = new TestDbContext(TestDbContext.CreateAuthorsSimulation());
93+
try
94+
{
95+
using var tx = context.Database.BeginTransaction();
96+
_ = context.Authors.Add(new Author { Name = "alice" });
97+
_ = context.SaveChanges();
98+
throw new InvalidOperationException("simulated failure pre-commit");
99+
}
100+
catch (InvalidOperationException)
101+
{
102+
}
103+
104+
Assert.AreEqual(0, context.Authors.AsNoTracking().Count());
105+
}
106+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for explicit transactions opened via <c>DbConnection.BeginTransaction()</c>
8+
/// — the SqlClient API EF Core 10's <c>Database.BeginTransaction()</c> uses.
9+
/// Confirms a connection-scoped <see cref="Storage.UndoLog"/> spans multiple
10+
/// statements, that <c>Commit()</c> persists everything and <c>Rollback()</c>
11+
/// undoes everything, and that a failed statement within a transaction only
12+
/// undoes its own writes (the surrounding transaction stays alive — probe-
13+
/// confirmed against SQL Server 2025).
14+
/// </summary>
15+
[TestClass]
16+
public sealed class ExplicitTransactionTests
17+
{
18+
private static int CountRows(DbConnection conn, string table) =>
19+
(int)conn.CreateCommand($"select count(*) from {table}").ExecuteScalar()!;
20+
21+
private static DbConnection NewSeededConnection()
22+
{
23+
var conn = new Simulation().CreateOpenConnection();
24+
_ = conn.CreateCommand("create table t (id int primary key, val int)").ExecuteNonQuery();
25+
return conn;
26+
}
27+
28+
/// <summary>
29+
/// Runs <paramref name="sql"/> as a non-query bound to <paramref name="tx"/>.
30+
/// Keeps the test bodies focused on what's being executed rather than on
31+
/// the four-line ceremony of building a tx-bound DbCommand.
32+
/// </summary>
33+
private static void RunInTx(DbConnection conn, DbTransaction tx, string sql)
34+
{
35+
using var cmd = conn.CreateCommand();
36+
cmd.Transaction = tx;
37+
cmd.CommandText = sql;
38+
_ = cmd.ExecuteNonQuery();
39+
}
40+
41+
[TestMethod]
42+
public void Commit_PersistsAllWritesAcrossMultipleStatements()
43+
{
44+
using var conn = NewSeededConnection();
45+
using var tx = conn.BeginTransaction();
46+
RunInTx(conn, tx, "insert into t values (1, 10)");
47+
RunInTx(conn, tx, "insert into t values (2, 20)");
48+
tx.Commit();
49+
AreEqual(2, CountRows(conn, "t"));
50+
}
51+
52+
[TestMethod]
53+
public void Rollback_UndoesAllWritesAcrossMultipleStatements()
54+
{
55+
using var conn = NewSeededConnection();
56+
using var tx = conn.BeginTransaction();
57+
RunInTx(conn, tx, "insert into t values (1, 10)");
58+
RunInTx(conn, tx, "insert into t values (2, 20)");
59+
tx.Rollback();
60+
AreEqual(0, CountRows(conn, "t"));
61+
}
62+
63+
[TestMethod]
64+
public void OwnTransactionSeesOwnWrites_BeforeCommit()
65+
{
66+
using var conn = NewSeededConnection();
67+
using var tx = conn.BeginTransaction();
68+
RunInTx(conn, tx, "insert into t values (42, 100)");
69+
70+
using var sel = conn.CreateCommand();
71+
sel.Transaction = tx;
72+
sel.CommandText = "select count(*) from t";
73+
AreEqual(1, sel.ExecuteScalar());
74+
}
75+
76+
[TestMethod]
77+
public void StatementFailureWithinTx_LeavesTxAlive_OnlyOwnWritesUndone()
78+
{
79+
// Probe-confirmed: a statement that fails inside an explicit transaction
80+
// rolls back ONLY its own writes; subsequent statements still run; commit
81+
// persists the surviving (pre-failure + post-failure-success) writes.
82+
using var conn = NewSeededConnection();
83+
using var tx = conn.BeginTransaction();
84+
RunInTx(conn, tx, "insert into t values (1, 10)");
85+
86+
// Mid-tx failure: PK collision against the just-inserted row.
87+
_ = Throws<DbException>(() => RunInTx(conn, tx, "insert into t values (1, 99)"));
88+
89+
// Tx alive — third statement still works.
90+
RunInTx(conn, tx, "insert into t values (3, 30)");
91+
tx.Commit();
92+
93+
var rows = new List<(int, int)>();
94+
using var reader = conn.CreateCommand("select id, val from t order by id").ExecuteReader();
95+
while (reader.Read())
96+
rows.Add((reader.GetInt32(0), reader.GetInt32(1)));
97+
CollectionAssert.AreEqual(new[] { (1, 10), (3, 30) }, rows);
98+
}
99+
100+
[TestMethod]
101+
public void Dispose_WithoutCommit_AutoRollsBack()
102+
{
103+
// SqlClient's SqlTransaction auto-rolls-back on dispose if neither
104+
// Commit nor Rollback ran. Mirrors `using var tx = ...; ... tx.Commit();`
105+
// where an early exception path leaves dispose to do the rollback.
106+
using var conn = NewSeededConnection();
107+
{
108+
using var tx = conn.BeginTransaction();
109+
RunInTx(conn, tx, "insert into t values (1, 10)");
110+
// Drop out of scope without committing.
111+
}
112+
AreEqual(0, CountRows(conn, "t"));
113+
}
114+
115+
[TestMethod]
116+
public void ParallelBeginTransaction_RaisesInvalidOperation()
117+
{
118+
// SqlClient: "SqlConnection does not support parallel transactions."
119+
using var conn = NewSeededConnection();
120+
using var tx1 = conn.BeginTransaction();
121+
_ = Throws<InvalidOperationException>(() => _ = conn.BeginTransaction());
122+
}
123+
124+
[TestMethod]
125+
public void Commit_AfterCommit_RaisesInvalidOperation()
126+
{
127+
using var conn = NewSeededConnection();
128+
var tx = conn.BeginTransaction();
129+
tx.Commit();
130+
_ = Throws<InvalidOperationException>(tx.Commit);
131+
}
132+
133+
[TestMethod]
134+
public void Rollback_AfterCommit_RaisesInvalidOperation()
135+
{
136+
using var conn = NewSeededConnection();
137+
var tx = conn.BeginTransaction();
138+
tx.Commit();
139+
_ = Throws<InvalidOperationException>(tx.Rollback);
140+
}
141+
142+
[TestMethod]
143+
public void NewTransaction_AfterCommit_Allowed()
144+
{
145+
// Once committed, the connection is free to begin a fresh transaction.
146+
using var conn = NewSeededConnection();
147+
var tx1 = conn.BeginTransaction();
148+
RunInTx(conn, tx1, "insert into t values (1, 10)");
149+
tx1.Commit();
150+
151+
var tx2 = conn.BeginTransaction();
152+
RunInTx(conn, tx2, "insert into t values (2, 20)");
153+
tx2.Rollback();
154+
155+
AreEqual(1, CountRows(conn, "t"));
156+
}
157+
158+
[TestMethod]
159+
public void Savepoint_RollbackToName_UndoesOnlyPostSavepointWrites()
160+
{
161+
// SAVE TRANSACTION + ROLLBACK TRANSACTION savepoint_name partial
162+
// rollback. EF Core 10 emits this around each SaveChanges within
163+
// a Database.BeginTransaction so a failed save undoes only its
164+
// own writes while the surrounding transaction stays alive.
165+
using var conn = NewSeededConnection();
166+
using var tx = conn.BeginTransaction();
167+
RunInTx(conn, tx, "insert into t values (1, 10)");
168+
RunInTx(conn, tx, "save transaction sp1");
169+
RunInTx(conn, tx, "insert into t values (2, 20)");
170+
RunInTx(conn, tx, "insert into t values (3, 30)");
171+
RunInTx(conn, tx, "rollback transaction sp1");
172+
tx.Commit();
173+
174+
var rows = new List<(int, int)>();
175+
using var reader = conn.CreateCommand("select id, val from t order by id").ExecuteReader();
176+
while (reader.Read())
177+
rows.Add((reader.GetInt32(0), reader.GetInt32(1)));
178+
CollectionAssert.AreEqual(new[] { (1, 10) }, rows);
179+
}
180+
181+
[TestMethod]
182+
public void Savepoint_AcceptsTranAbbreviation()
183+
{
184+
// SAVE TRAN / ROLLBACK TRAN are interchangeable with TRANSACTION.
185+
using var conn = NewSeededConnection();
186+
using var tx = conn.BeginTransaction();
187+
RunInTx(conn, tx, "insert into t values (1, 10)");
188+
RunInTx(conn, tx, "save tran sp1");
189+
RunInTx(conn, tx, "insert into t values (2, 20)");
190+
RunInTx(conn, tx, "rollback tran sp1");
191+
tx.Commit();
192+
193+
AreEqual(1, CountRows(conn, "t"));
194+
}
195+
196+
[TestMethod]
197+
public void Rollback_RestoresExistingRows_NotJustNewOnes()
198+
{
199+
// A transaction that updates and deletes existing rows must restore
200+
// them on rollback. UPDATE = delete-old + insert-new, so each affected
201+
// row produces two log entries — undo unwinds both halves.
202+
using var conn = NewSeededConnection();
203+
_ = conn.CreateCommand("insert into t values (1, 10), (2, 20), (3, 30)").ExecuteNonQuery();
204+
205+
using (var tx = conn.BeginTransaction())
206+
{
207+
RunInTx(conn, tx, "update t set val = val * 10 where id <= 2");
208+
RunInTx(conn, tx, "delete from t where id = 3");
209+
tx.Rollback();
210+
}
211+
212+
var rows = new List<(int, int)>();
213+
using var reader = conn.CreateCommand("select id, val from t order by id").ExecuteReader();
214+
while (reader.Read())
215+
rows.Add((reader.GetInt32(0), reader.GetInt32(1)));
216+
CollectionAssert.AreEqual(new[] { (1, 10), (2, 20), (3, 30) }, rows);
217+
}
218+
}

0 commit comments

Comments
 (0)