|
3 | 3 | namespace SqlServerSimulator; |
4 | 4 |
|
5 | 5 | /// <summary> |
6 | | -/// The in-process ADO surface keeps its long-standing fail-fast contract: the |
7 | | -/// first error in a batch throws immediately and later statements never run. |
8 | | -/// This is deliberately the opposite of the TDS wire path, which continues a |
9 | | -/// batch past a statement-terminating error (see |
10 | | -/// <c>BatchErrorContinuationTests</c> in <c>SqlServerSimulator.Tests.SqlClient</c>). |
11 | | -/// The wire opts in via <c>CreateResultSetsForCommand(command, continueOnError: true)</c>; |
12 | | -/// the in-process path leaves the default <see langword="false"/>, and its |
13 | | -/// reader filters outcomes on result sets so it never observes the wire-only |
14 | | -/// error outcome anyway. |
| 6 | +/// The in-process ADO surface shares the TDS wire's continue-on-error engine: |
| 7 | +/// a statement-terminating error (severity 11-16) ends its statement but the |
| 8 | +/// batch runs to completion, and the front door renders the shared outcome |
| 9 | +/// stream as SqlClient-shaped exceptions. The eight cases below mirror the |
| 10 | +/// pinned oracle probed against real SQL Server 2025 + Microsoft.Data.SqlClient |
| 11 | +/// (2026-07-17): ExecuteNonQuery / ExecuteScalar drain the batch and surface |
| 12 | +/// every statement error through one aggregated |
| 13 | +/// <see cref="SimulatedSqlException.Errors"/> collection; the reader surfaces |
| 14 | +/// errors positionally and survives; and a batch-aborting error (a bind miss or |
| 15 | +/// an uncaught THROW) stops the following statements. Message text may differ |
| 16 | +/// from real SQL Server, but Msg numbers, Errors.Count, side-effect counts, and |
| 17 | +/// positional behavior match. The wire counterpart lives in |
| 18 | +/// <c>BatchErrorContinuationTests</c> in <c>SqlServerSimulator.Tests.SqlClient</c>. |
15 | 19 | /// </summary> |
16 | 20 | [TestClass] |
17 | 21 | public sealed class BatchErrorContinuationInProcTests |
18 | 22 | { |
| 23 | + /// <summary>Probe 1: ExecuteNonQuery over a batch with two continue-class errors and three inserts.</summary> |
19 | 24 | [TestMethod] |
20 | | - public void DropNonexistentTemp_ThenSelect_FailsFast() |
21 | | - => _ = new Simulation().AssertSqlError("drop table #nope\nselect 1", 3701); |
| 25 | + public void ExecuteNonQuery_TwoContinueClassErrors_AggregatesBoth_AllInsertsPersist() |
| 26 | + { |
| 27 | + using var connection = new Simulation().CreateOpenConnection(); |
| 28 | + _ = connection.CreateCommand("create table t (id int)").ExecuteNonQuery(); |
| 29 | + |
| 30 | + using var batch = connection.CreateCommand( |
| 31 | + "insert t values (1); select 1/0; insert t values (2); drop table does_not_exist; insert t values (3)"); |
| 32 | + var ex = Throws<SimulatedSqlException>(() => batch.ExecuteNonQuery()); |
22 | 33 |
|
| 34 | + // One exception carrying both errors in batch order: Msg 8134 (divide, |
| 35 | + // class 16) then Msg 3701 (drop missing, class 11). |
| 36 | + AreEqual(2, ex.Errors.Count); |
| 37 | + AreEqual(8134, ex.Errors[0].Number); |
| 38 | + AreEqual(3701, ex.Errors[1].Number); |
| 39 | + AreEqual(8134, ex.Number); |
| 40 | + |
| 41 | + // The whole batch ran — all three inserts landed. |
| 42 | + AreEqual(3, connection.CreateCommand("select count(*) from t").ExecuteScalar()); |
| 43 | + } |
| 44 | + |
| 45 | + /// <summary>Probe 2: ExecuteReader with an error between result sets — positional at Read, reader survives.</summary> |
23 | 46 | [TestMethod] |
24 | | - public void StatementErrorMidBatch_LaterInsertNeverRuns() |
| 47 | + public void ExecuteReader_ErrorBetweenResultSets_ReadThrows_ReaderSurvives() |
25 | 48 | { |
26 | | - // One shared connection so the session #t survives the failed batch for |
27 | | - // the follow-up count (a fresh-connection-per-call helper would drop it). |
28 | 49 | using var connection = new Simulation().CreateOpenConnection(); |
| 50 | + using var command = connection.CreateCommand("select 'rs1' as a; select 1/0 as boom; select 'rs3' as c"); |
| 51 | + using var reader = command.ExecuteReader(); |
| 52 | + |
| 53 | + IsTrue(reader.Read()); |
| 54 | + AreEqual("rs1", reader.GetValue(0)); |
| 55 | + IsFalse(reader.Read()); |
| 56 | + |
| 57 | + // Advancing to the failed SELECT succeeds (it is row-returning, so real |
| 58 | + // SQL Server framed it with COLMETADATA); the first Read throws. |
| 59 | + IsTrue(reader.NextResult()); |
| 60 | + var ex = Throws<SimulatedSqlException>(() => reader.Read()); |
| 61 | + AreEqual(8134, ex.Number); |
| 62 | + |
| 63 | + // The reader survives to the trailing result set. |
| 64 | + IsTrue(reader.NextResult()); |
| 65 | + IsTrue(reader.Read()); |
| 66 | + AreEqual("rs3", reader.GetValue(0)); |
| 67 | + } |
| 68 | + |
| 69 | + /// <summary>Probe 3: ExecuteReader with an error inside a result set — Read throws, tail reads clean.</summary> |
| 70 | + [TestMethod] |
| 71 | + public void ExecuteReader_ErrorMidResultSet_ReadThrows_TailReadsClean() |
| 72 | + { |
| 73 | + using var connection = new Simulation().CreateOpenConnection(); |
| 74 | + _ = connection.CreateCommand("create table t (id int)").ExecuteNonQuery(); |
| 75 | + _ = connection.CreateCommand("insert t values (2),(1),(0),(5)").ExecuteNonQuery(); |
| 76 | + |
| 77 | + using var command = connection.CreateCommand("select 10/id from t; select 'after' as tail"); |
| 78 | + using var reader = command.ExecuteReader(); |
| 79 | + |
| 80 | + // Divergence (documented, out of scope): the simulator materializes a |
| 81 | + // SELECT's rows up front, so the divide-by-zero fires before any row is |
| 82 | + // yielded — real SQL Server streams two rows first. The positional |
| 83 | + // behavior matches: Read throws, and the reader survives. |
| 84 | + var ex = Throws<SimulatedSqlException>(() => |
| 85 | + { |
| 86 | + while (reader.Read()) |
| 87 | + { |
| 88 | + } |
| 89 | + }); |
| 90 | + AreEqual(8134, ex.Number); |
| 91 | + |
| 92 | + IsTrue(reader.NextResult()); |
| 93 | + IsTrue(reader.Read()); |
| 94 | + AreEqual("after", reader.GetValue(0)); |
| 95 | + } |
| 96 | + |
| 97 | + /// <summary>Probe 4: ExecuteScalar with the error before the first result set.</summary> |
| 98 | + [TestMethod] |
| 99 | + public void ExecuteScalar_ErrorBeforeFirstResult_Throws() |
| 100 | + { |
| 101 | + var ex = Throws<SimulatedSqlException>(() => new Simulation().ExecuteScalar("select 1/0; select 42")); |
| 102 | + AreEqual(8134, ex.Number); |
| 103 | + AreEqual(1, ex.Errors.Count); |
| 104 | + } |
| 105 | + |
| 106 | + /// <summary>Probe 5: ExecuteScalar with the error after the first result set still throws (drains the batch).</summary> |
| 107 | + [TestMethod] |
| 108 | + public void ExecuteScalar_ErrorAfterFirstResult_ThrowsRatherThanReturningValue() |
| 109 | + { |
| 110 | + var ex = Throws<SimulatedSqlException>(() => new Simulation().ExecuteScalar("select 42; select 1/0")); |
| 111 | + AreEqual(8134, ex.Number); |
| 112 | + } |
| 113 | + |
| 114 | + /// <summary>Probe 6: disposing a reader without draining executes the remaining statements and swallows their errors.</summary> |
| 115 | + [TestMethod] |
| 116 | + public void ReaderDispose_WithoutDraining_ExecutesRemainingStatements_SwallowsErrors() |
| 117 | + { |
| 118 | + using var connection = new Simulation().CreateOpenConnection(); |
| 119 | + _ = connection.CreateCommand("create table t (id int)").ExecuteNonQuery(); |
| 120 | + |
| 121 | + using (var command = connection.CreateCommand( |
| 122 | + "select 'rs1' as a; insert t values (1); select 1/0; insert t values (2)")) |
| 123 | + { |
| 124 | + using var reader = command.ExecuteReader(); |
| 125 | + IsTrue(reader.Read()); |
| 126 | + // Dispose here (end of using) without draining. |
| 127 | + } |
| 128 | + |
| 129 | + // Both inserts ran during the drain-on-dispose; the divide-by-zero was |
| 130 | + // swallowed (no exception escaped the dispose). |
| 131 | + AreEqual(2, connection.CreateCommand("select count(*) from t").ExecuteScalar()); |
| 132 | + } |
29 | 133 |
|
30 | | - using var failing = connection.CreateCommand(); |
31 | | - failing.CommandText = "create table #t (a int); drop table #nope; insert #t values (1)"; |
32 | | - var ex = Throws<SimulatedSqlException>(() => failing.ExecuteNonQuery()); |
| 134 | + /// <summary>Probe 7: a batch-aborting bind error (Msg 208) stops the following statements.</summary> |
| 135 | + [TestMethod] |
| 136 | + public void BatchAbortingError_Msg208_StopsFollowingStatements() |
| 137 | + { |
| 138 | + using var connection = new Simulation().CreateOpenConnection(); |
| 139 | + _ = connection.CreateCommand("create table t (id int)").ExecuteNonQuery(); |
| 140 | + |
| 141 | + using var batch = connection.CreateCommand( |
| 142 | + "insert t values (1); select * from table_that_does_not_exist; insert t values (2)"); |
| 143 | + var ex = Throws<SimulatedSqlException>(() => batch.ExecuteNonQuery()); |
| 144 | + AreEqual(208, ex.Number); |
| 145 | + AreEqual(1, ex.Errors.Count); |
| 146 | + |
| 147 | + // The insert before the miss ran; the one after did not. |
| 148 | + AreEqual(1, connection.CreateCommand("select count(*) from t").ExecuteScalar()); |
| 149 | + } |
| 150 | + |
| 151 | + /// <summary>Probe 8: severity-16 RAISERROR continues; an uncaught THROW aborts. Both errors aggregate.</summary> |
| 152 | + [TestMethod] |
| 153 | + public void RaiserrorSeverity16Continues_ThrowAborts_AggregatesBothErrors() |
| 154 | + { |
| 155 | + using var connection = new Simulation().CreateOpenConnection(); |
| 156 | + _ = connection.CreateCommand("create table t (id int)").ExecuteNonQuery(); |
| 157 | + |
| 158 | + using var batch = connection.CreateCommand( |
| 159 | + "insert t values (1); raiserror('custom failure', 16, 1); insert t values (2); throw 50001, 'thrown failure', 1; insert t values (3)"); |
| 160 | + var ex = Throws<SimulatedSqlException>(() => batch.ExecuteNonQuery()); |
| 161 | + |
| 162 | + // RAISERROR's Msg 50000 and THROW's Msg 50001, in batch order. |
| 163 | + AreEqual(2, ex.Errors.Count); |
| 164 | + AreEqual(50000, ex.Errors[0].Number); |
| 165 | + AreEqual(50001, ex.Errors[1].Number); |
| 166 | + |
| 167 | + // The two inserts before the THROW ran; the THROW aborted the batch so |
| 168 | + // the third insert did not. |
| 169 | + AreEqual(2, connection.CreateCommand("select count(*) from t").ExecuteScalar()); |
| 170 | + } |
| 171 | + |
| 172 | + /// <summary> |
| 173 | + /// A single non-row-returning statement that fails surfaces eagerly at |
| 174 | + /// ExecuteReader — real SQL Server sent no result-set envelope, so SqlClient |
| 175 | + /// throws on the advance rather than a later Read. This is what lets EF |
| 176 | + /// Core's no-OUTPUT modification batches, which never call Read, observe a |
| 177 | + /// failed INSERT / UPDATE / DELETE. |
| 178 | + /// </summary> |
| 179 | + [TestMethod] |
| 180 | + public void NonRowReturningError_SurfacesEagerlyAtExecuteReader() |
| 181 | + { |
| 182 | + using var connection = new Simulation().CreateOpenConnection(); |
| 183 | + var ex = Throws<SimulatedSqlException>(() => |
| 184 | + { |
| 185 | + using var reader = connection.CreateCommand("drop table #nope").ExecuteReader(); |
| 186 | + }); |
| 187 | + AreEqual(3701, ex.Number); |
| 188 | + } |
| 189 | + |
| 190 | + /// <summary> |
| 191 | + /// A non-row-returning error between two result sets surfaces on the |
| 192 | + /// NextResult that advances onto it (not a later Read) — matching how real |
| 193 | + /// SqlClient surfaces an error token that no COLMETADATA precedes. |
| 194 | + /// </summary> |
| 195 | + [TestMethod] |
| 196 | + public void NonRowReturningError_BetweenResultSets_SurfacesAtNextResult() |
| 197 | + { |
| 198 | + using var connection = new Simulation().CreateOpenConnection(); |
| 199 | + using var command = connection.CreateCommand("select 1 as a; drop table #nope; select 2 as b"); |
| 200 | + using var reader = command.ExecuteReader(); |
| 201 | + |
| 202 | + IsTrue(reader.Read()); |
| 203 | + AreEqual(1, reader.GetValue(0)); |
| 204 | + IsFalse(reader.Read()); |
| 205 | + |
| 206 | + var ex = Throws<SimulatedSqlException>(() => reader.NextResult()); |
33 | 207 | AreEqual(3701, ex.Number); |
| 208 | + } |
| 209 | + |
| 210 | + /// <summary> |
| 211 | + /// A severity ≤ 10 RAISERROR is informational, not a statement error: it |
| 212 | + /// raises the <see cref="SimulatedDbConnection.InfoMessage"/> event and the |
| 213 | + /// batch continues without an exception. |
| 214 | + /// </summary> |
| 215 | + [TestMethod] |
| 216 | + public void InformationalRaiserror_DoesNotThrow_FiresInfoMessage_BatchContinues() |
| 217 | + { |
| 218 | + using var connection = (SimulatedDbConnection)new Simulation().CreateOpenConnection(); |
| 219 | + _ = connection.CreateCommand("create table t (id int)").ExecuteNonQuery(); |
| 220 | + |
| 221 | + var messages = new List<string>(); |
| 222 | + connection.InfoMessage += (_, e) => messages.Add(e.Message); |
| 223 | + |
| 224 | + using var batch = connection.CreateCommand( |
| 225 | + "insert t values (1); raiserror('just a note', 5, 1); insert t values (2)"); |
| 226 | + var affected = batch.ExecuteNonQuery(); |
34 | 227 |
|
35 | | - // The insert after the failed drop never executed — fail-fast aborts |
36 | | - // the whole batch in-process (the wire would have continued and run it). |
37 | | - using var count = connection.CreateCommand(); |
38 | | - count.CommandText = "select count(*) from #t"; |
39 | | - AreEqual(0, count.ExecuteScalar()); |
| 228 | + AreEqual(2, affected); |
| 229 | + Contains("just a note", string.Join("\n", messages)); |
| 230 | + AreEqual(2, connection.CreateCommand("select count(*) from t").ExecuteScalar()); |
40 | 231 | } |
41 | 232 | } |
0 commit comments