Skip to content

Commit f521bcb

Browse files
committed
In-process batches now share the wire path's continue-on-error semantics, uncaught THROW becomes batch-aborting while severity-16 RAISERROR continues, and draining exposed two stranded-cursor parser bugs now fixed.
1 parent 03a4248 commit f521bcb

20 files changed

Lines changed: 699 additions & 132 deletions

SqlServerSimulator.Tests.SqlClient/BatchErrorContinuationTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ public async Task ResultSetBeforeError_FirstResultReadable_NextResultThrows()
8787
AreEqual(3701, ex.Number);
8888
}
8989

90+
[TestMethod]
91+
public async Task ExecuteScalar_TrailingError_DrainsBatch_Throws()
92+
{
93+
var simulation = new Simulation();
94+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
95+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
96+
97+
// ExecuteScalar drains the whole batch: the value of the first result
98+
// set is produced, but a trailing statement-terminating error surfaces
99+
// as a throw rather than the value being returned (oracle probe 5).
100+
await using var command = new SqlCommand("select 42; select 1/0", connection);
101+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
102+
_ = await command.ExecuteScalarAsync(TestContext.CancellationToken));
103+
AreEqual(8134, ex.Number);
104+
}
105+
90106
[TestMethod]
91107
public async Task BatchAbortingError_AbortsBatch_LaterStatementDoesNotRun()
92108
{

SqlServerSimulator.Tests/BatchErrorContinuationInProcTests.cs

Lines changed: 213 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,230 @@
33
namespace SqlServerSimulator;
44

55
/// <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>.
1519
/// </summary>
1620
[TestClass]
1721
public sealed class BatchErrorContinuationInProcTests
1822
{
23+
/// <summary>Probe 1: ExecuteNonQuery over a batch with two continue-class errors and three inserts.</summary>
1924
[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());
2233

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>
2346
[TestMethod]
24-
public void StatementErrorMidBatch_LaterInsertNeverRuns()
47+
public void ExecuteReader_ErrorBetweenResultSets_ReadThrows_ReaderSurvives()
2548
{
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).
2849
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+
}
29133

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());
33207
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();
34227

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());
40231
}
41232
}

SqlServerSimulator.Tests/BatchErrorRecoveryTests.cs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ namespace SqlServerSimulator;
1111
/// statement-terminating one (unlike Msg 3701 / 8134 / a severity-16 RAISERROR,
1212
/// which let the batch continue). Missing column (Msg 207), ambiguous column
1313
/// (Msg 209), and unbindable multi-part identifiers (Msg 4104) abort the same
14-
/// way. The in-process ADO surface reaches the same end state through its
15-
/// fail-fast contract (first error throws, later statements never run — see
16-
/// <see cref="BatchErrorContinuationInProcTests"/>); the wire-path contrast
17-
/// (results before the error still arrive, exactly one error token, no
18-
/// abandoned-mid-parse Msg 319 / 102 cascade) lives in
14+
/// way. The in-process ADO surface now shares the wire's continue-on-error
15+
/// engine: it drains the batch and surfaces the aggregated error(s) at
16+
/// completion, so a batch-aborting miss still stops the following statements
17+
/// (the dispatch loop breaks) while a statement-terminating error lets them
18+
/// run — see <see cref="BatchErrorContinuationInProcTests"/>. The wire-path
19+
/// contrast (results before the error still arrive, exactly one error token,
20+
/// no abandoned-mid-parse Msg 319 / 102 cascade) lives in
1921
/// <c>BatchErrorRecoveryTests</c> in <c>SqlServerSimulator.Tests.SqlClient</c>.
2022
/// </summary>
2123
[TestClass]
@@ -64,18 +66,22 @@ public void MissingColumnMidBatch_AbortsBatch()
6466
}
6567

6668
[TestMethod]
67-
public void SyntaxErrorMidBatch_AbortsBatch_FollowingDoesNotRun()
69+
public void SyntaxErrorMidBatch_Continues_FollowingRuns()
6870
{
69-
// A true syntax error aborts the batch on real SQL Server (probe:
70-
// `SELECT 1; SELECT FROM; SELECT 2` returns only Msg 156, no SELECT 2).
71-
// The in-process path reaches the same end state fail-fast.
71+
// Accepted divergence: a true syntax error aborts the batch on real SQL
72+
// Server (it fails at compile), but the simulator interleaves parse and
73+
// execution and can't tell a parse-origin error (Msg 156, class 15)
74+
// from a runtime one — so a mid-batch syntax error is statement-
75+
// terminating and the batch continues. This is the same parse-vs-runtime
76+
// divergence documented for the wire path; unifying the engine extends
77+
// it to the in-process surface. The insert after the syntax error runs.
7278
using var connection = new Simulation().CreateOpenConnection();
7379
_ = connection.CreateCommand("create table marker (n int)").ExecuteNonQuery();
7480

7581
using var failing = connection.CreateCommand(
7682
"insert marker values (1); select from; insert marker values (2)");
7783
_ = Throws<SimulatedSqlException>(() => failing.ExecuteNonQuery());
78-
AreEqual(1, connection.CreateCommand("select count(*) from marker").ExecuteScalar());
84+
AreEqual(2, connection.CreateCommand("select count(*) from marker").ExecuteScalar());
7985
}
8086

8187
/// <summary>
@@ -99,18 +105,19 @@ select 7
99105
"""));
100106

101107
[TestMethod]
102-
public void ContinuableError_DoesNotAbort_InProcStillFailsFast()
108+
public void ContinuableError_DoesNotAbort_FollowingRuns()
103109
{
104110
// Msg 3701 (drop missing) is statement-terminating, not batch-aborting:
105-
// the wire continues past it. The in-process path still fails fast — this
106-
// pins that the batch-abort carve-out didn't change the in-process
107-
// first-error-throws contract for a continuable error.
111+
// the batch continues past it on both front doors. In-process,
112+
// ExecuteNonQuery drains the whole batch — both inserts land — and
113+
// surfaces the 3701 at completion. Contrast the batch-aborting bind
114+
// errors above, where the following insert never runs.
108115
using var connection = new Simulation().CreateOpenConnection();
109116
_ = connection.CreateCommand("create table marker (n int)").ExecuteNonQuery();
110117
using var failing = connection.CreateCommand(
111118
"insert marker values (1); drop table #nope; insert marker values (2)");
112119
var ex = Throws<SimulatedSqlException>(() => failing.ExecuteNonQuery());
113120
AreEqual(3701, ex.Number);
114-
AreEqual(1, connection.CreateCommand("select count(*) from marker").ExecuteScalar());
121+
AreEqual(2, connection.CreateCommand("select count(*) from marker").ExecuteScalar());
115122
}
116123
}

SqlServerSimulator.Tests/CastTests.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,17 +339,19 @@ public void Cast_InvalidScale_OnLaterLine_PrefixesActualLine()
339339
[TestMethod]
340340
public void Cast_InvalidScale_InLaterStatementOfBatch_FiresWhenReaderReachesIt()
341341
{
342-
// Multi-statement batches parse lazily one statement at a time; statement 3's error doesn't fire until NextResult reaches it.
342+
// Multi-statement batches parse lazily one statement at a time and
343+
// continue past a statement error. Statement 3 is a (row-returning)
344+
// SELECT, so the reader advances onto it — NextResult returns true —
345+
// and its error surfaces positionally on the first Read, matching how
346+
// real SQL Server frames a failed SELECT (COLMETADATA precedes the
347+
// error over the wire).
343348
using var connection = new Simulation().CreateOpenConnection();
344349
using var command = connection.CreateCommand("select 1;\nselect 2;\nselect cast('x' as datetime2(8))");
345350
using var reader = command.ExecuteReader();
346351

347352
IsTrue(reader.NextResult());
348-
var ex = Throws<System.Data.Common.DbException>(() =>
349-
{
350-
_ = reader.NextResult();
351-
_ = reader.NextResult();
352-
});
353+
IsTrue(reader.NextResult());
354+
var ex = Throws<System.Data.Common.DbException>(() => reader.Read());
353355
AreEqual("Line 3: Specified scale 8 is invalid.", ex.Message);
354356
}
355357

SqlServerSimulator.Tests/DistinctTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ public void TopBeforeDistinct_IsSyntaxError()
179179
// SQL Server requires DISTINCT before TOP. Reversed order is a parse
180180
// failure (Msg 156 with "near 'distinct'").
181181
var ex = Throws<DbException>(() =>
182-
connection.CreateCommand("select top 2 distinct v from t").ExecuteReader());
182+
connection.CreateCommand("select top 2 distinct v from t").ExecuteReader().Read());
183183
AreEqual("Incorrect syntax near the keyword 'distinct'.", ex.Message);
184184
}
185185

0 commit comments

Comments
 (0)