|
| 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