Skip to content

Commit c6de980

Browse files
committed
The TDS wire batch loop now continues after statement-terminating errors (severity 11-16 except deadlock; class ≥ 17 and NotSupportedException still abort), matching real SQL Server's default non-XACT_ABORT semantics — statement errors surface as a SimulatedErrorOutcome and the batch runs on, via a per-command continueOnError that only the wire opts into; sys.databases now projects the full 98-column SQL Server 2025 shape.
1 parent 3098181 commit c6de980

14 files changed

Lines changed: 638 additions & 44 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Over the wire, a statement-terminating error (severity 11-16) ends its
8+
/// statement but the batch continues to the next one — real SQL Server's
9+
/// default (non-XACT_ABORT) behavior, and the fix that lets SMO's all-DROP
10+
/// temp-table cleanup batch run so Object Explorer can enumerate. Contrast
11+
/// with the in-process ADO surface, which stays fail-fast (first error throws)
12+
/// — see <c>BatchErrorContinuationInProcTests</c> in <c>SqlServerSimulator.Tests</c>.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class BatchErrorContinuationTests
16+
{
17+
public TestContext TestContext { get; set; } = null!;
18+
19+
[TestMethod]
20+
public async Task StatementError_MidBatch_LaterStatementStillRuns()
21+
{
22+
var simulation = new Simulation();
23+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
24+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
25+
26+
// drop of a nonexistent temp table raises Msg 3701 (severity 11);
27+
// the insert after it must still execute because the batch continues.
28+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
29+
{
30+
await using var command = new SqlCommand(
31+
"create table #t (a int); drop table #nope; insert #t values (1)",
32+
connection);
33+
_ = await command.ExecuteNonQueryAsync(TestContext.CancellationToken);
34+
});
35+
AreEqual(3701, ex.Number);
36+
AreEqual(1, ex.Errors.Count);
37+
38+
// Proof the batch continued past the 3701: the insert landed a row.
39+
await using var count = new SqlCommand("select count(*) from #t", connection);
40+
AreEqual(1, await count.ExecuteScalarAsync(TestContext.CancellationToken));
41+
}
42+
43+
[TestMethod]
44+
public async Task SmoStyleTempDropCleanup_AllRaise3701_SessionStaysUsable()
45+
{
46+
var simulation = new Simulation();
47+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
48+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
49+
50+
// The exact shape of SMO's Object-Explorer "Databases" refresh cleanup:
51+
// every drop is Msg 3701 on a fresh session. Real SQL Server runs them
52+
// all and surfaces one SqlException carrying every error token.
53+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
54+
{
55+
await using var command = new SqlCommand(
56+
"drop table #tmp_db_ars; drop table #tmp_db_ags; drop table #tmp_db_hadr_dbrs; drop table #tmp_sync_states",
57+
connection);
58+
_ = await command.ExecuteNonQueryAsync(TestContext.CancellationToken);
59+
});
60+
AreEqual(4, ex.Errors.Count);
61+
foreach (SqlError error in ex.Errors)
62+
AreEqual(3701, error.Number);
63+
64+
// Session survived the cleanup batch, so SMO proceeds to enumerate.
65+
await using var ok = new SqlCommand("select 1", connection);
66+
AreEqual(1, await ok.ExecuteScalarAsync(TestContext.CancellationToken));
67+
}
68+
69+
[TestMethod]
70+
public async Task ResultSetBeforeError_FirstResultReadable_NextResultThrows()
71+
{
72+
var simulation = new Simulation();
73+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
74+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
75+
76+
await using var command = new SqlCommand("select 1 as a; drop table #nope", connection);
77+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
78+
79+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
80+
AreEqual(1, reader.GetInt32(0));
81+
IsFalse(await reader.ReadAsync(TestContext.CancellationToken));
82+
83+
// The error follows the first result set's DONE, so it surfaces when
84+
// the reader advances past that result set.
85+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
86+
await reader.NextResultAsync(TestContext.CancellationToken));
87+
AreEqual(3701, ex.Number);
88+
}
89+
90+
[TestMethod]
91+
public async Task BatchAbortingError_AbortsBatch_LaterStatementDoesNotRun()
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+
// No SimulatedSqlException factory produces a class >= 17 (batch/
98+
// connection-terminating) severity, and deadlock (class 13) needs
99+
// concurrent sessions to provoke; NotSupportedException is the
100+
// reachable batch-aborting case here (BEGIN DISTRIBUTED TRANSACTION),
101+
// surfacing as a Msg 50000 error token that ends the batch. The insert
102+
// after it must NOT run — the contrast with continued errors above.
103+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
104+
{
105+
await using var command = new SqlCommand(
106+
"create table #t (a int); begin distributed transaction; insert #t values (1)",
107+
connection);
108+
_ = await command.ExecuteNonQueryAsync(TestContext.CancellationToken);
109+
});
110+
AreEqual(50000, ex.Number);
111+
112+
await using var count = new SqlCommand("select count(*) from #t", connection);
113+
AreEqual(0, await count.ExecuteScalarAsync(TestContext.CancellationToken));
114+
}
115+
}

SqlServerSimulator.Tests.SqlClient/RpcErrorTests.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public async Task TableValuedParameter_SurfacesAsUnsupported()
6262
}
6363

6464
[TestMethod]
65-
public async Task OutputParameterWithErrorInSameStatement_ThrowsAndLeavesOutputUnwritten()
65+
public async Task OutputParameterBeforeErrorInBatch_ThrowsButWritesOutput()
6666
{
6767
var simulation = new Simulation();
6868
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
@@ -77,8 +77,12 @@ public async Task OutputParameterWithErrorInSameStatement_ThrowsAndLeavesOutputU
7777
var ex = await Assert.ThrowsAsync<SqlException>(async () => await command.ExecuteNonQueryAsync(TestContext.CancellationToken));
7878
AreEqual(8134, ex.Number);
7979

80-
// The statement faulted before the RETURNVALUE token was written, so the
81-
// output parameter is never assigned the server-side value.
82-
IsNull(output.Value);
80+
// The `set @out = 5` ran, then the divide-by-zero (severity 16) ended
81+
// its own statement but let the RPC continue — so the RETURNVALUE token
82+
// still carries the assigned value. Probed against SQL Server 2025
83+
// (2026-07-14): real SqlClient reports output.Value == 5 here, matching
84+
// this. (Before wire error-continuation the simulator left it unwritten,
85+
// which diverged from the reference.)
86+
AreEqual(5, output.Value);
8387
}
8488
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
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.
15+
/// </summary>
16+
[TestClass]
17+
public sealed class BatchErrorContinuationInProcTests
18+
{
19+
[TestMethod]
20+
public void DropNonexistentTemp_ThenSelect_FailsFast()
21+
=> _ = new Simulation().AssertSqlError("drop table #nope\nselect 1", 3701);
22+
23+
[TestMethod]
24+
public void StatementErrorMidBatch_LaterInsertNeverRuns()
25+
{
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+
using var connection = new Simulation().CreateOpenConnection();
29+
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());
33+
AreEqual(3701, ex.Number);
34+
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());
40+
}
41+
}

SqlServerSimulator.Tests/CatalogViewTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,4 +437,90 @@ public void SysConfigurations_AgentXps_CountByNameIsOne()
437437
public void SysConfigurations_ReadableViaThreePartMasterName()
438438
=> AreEqual(106, new Simulation().ExecuteScalar(
439439
"select count(*) from master.sys.configurations"));
440+
441+
// === sys.databases: full 98-column projection (SQL Server 2025) ===
442+
443+
[TestMethod]
444+
public void SysDatabases_Projects98Columns()
445+
{
446+
using var reader = new Simulation().ExecuteReader(
447+
"select * from sys.databases where name = 'simulated'");
448+
AreEqual(98, reader.FieldCount);
449+
}
450+
451+
[TestMethod]
452+
public void SysDatabases_KeyColumnTypes_DatabaseIdIsInt_StateOnline()
453+
{
454+
using var reader = new Simulation().ExecuteReader("""
455+
select name, database_id, source_database_id, owner_sid, create_date,
456+
state_desc, physical_database_name
457+
from sys.databases where name = 'simulated'
458+
""");
459+
AreEqual(typeof(string), reader.GetFieldType(0));
460+
AreEqual(typeof(int), reader.GetFieldType(1));
461+
AreEqual(typeof(int), reader.GetFieldType(2));
462+
AreEqual(typeof(byte[]), reader.GetFieldType(3));
463+
AreEqual(typeof(DateTime), reader.GetFieldType(4));
464+
IsTrue(reader.Read());
465+
AreEqual("simulated", reader.GetString(0));
466+
AreEqual(5, reader.GetInt32(1));
467+
IsTrue(reader.IsDBNull(2));
468+
AreEqual("ONLINE", reader.GetString(5));
469+
AreEqual("simulated", reader.GetString(6));
470+
}
471+
472+
[TestMethod]
473+
public void SysDatabases_CodeDescPairs_InternallyConsistent()
474+
{
475+
using var reader = new Simulation().ExecuteReader("""
476+
select user_access, user_access_desc, state, state_desc,
477+
recovery_model, recovery_model_desc,
478+
snapshot_isolation_state, snapshot_isolation_state_desc
479+
from sys.databases where name = 'simulated'
480+
""");
481+
IsTrue(reader.Read());
482+
AreEqual((byte)0, reader.GetByte(0));
483+
AreEqual("MULTI_USER", reader.GetString(1));
484+
AreEqual((byte)0, reader.GetByte(2));
485+
AreEqual("ONLINE", reader.GetString(3));
486+
AreEqual((byte)3, reader.GetByte(4));
487+
AreEqual("SIMPLE", reader.GetString(5));
488+
AreEqual((byte)0, reader.GetByte(6));
489+
AreEqual("OFF", reader.GetString(7));
490+
}
491+
492+
// The model template reports FULL recovery (per the reference instance);
493+
// every other database reports SIMPLE.
494+
[TestMethod]
495+
public void SysDatabases_ModelRecoveryModel_IsFull()
496+
{
497+
using var reader = new Simulation().ExecuteReader(
498+
"select recovery_model, recovery_model_desc from sys.databases where name = 'model'");
499+
IsTrue(reader.Read());
500+
AreEqual((byte)1, reader.GetByte(0));
501+
AreEqual("FULL", reader.GetString(1));
502+
}
503+
504+
// SMO's Object-Explorer "Databases" node enumeration for a v17 server;
505+
// filtering out the four system databases leaves only the user database.
506+
[TestMethod]
507+
public void SysDatabases_SmoStyleEnumeration_ReturnsUserDatabaseRow()
508+
{
509+
using var reader = new Simulation().ExecuteReader("""
510+
select name, database_id, has_dbaccess(name), state_desc,
511+
recovery_model_desc, owner_sid, create_date,
512+
source_database_id, containment
513+
from sys.databases
514+
where name not in ('master', 'tempdb', 'model', 'msdb')
515+
order by name
516+
""");
517+
IsTrue(reader.Read());
518+
AreEqual("simulated", reader.GetString(0));
519+
AreEqual(5, reader.GetInt32(1));
520+
AreEqual(1, reader.GetInt32(2));
521+
AreEqual("ONLINE", reader.GetString(3));
522+
AreEqual("SIMPLE", reader.GetString(4));
523+
AreEqual((byte)0, reader.GetByte(8));
524+
IsFalse(reader.Read());
525+
}
440526
}

SqlServerSimulator.Tests/CollationMetadataTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ public void SysDatabases_RowShape_CarriesCompatibilityAndIsolation()
206206
// the row-shape assertions to the simulated database by name.
207207
var sim = new Simulation();
208208
AreEqual("simulated", sim.ExecuteScalar("SELECT name FROM sys.databases WHERE name = 'simulated'"));
209-
AreEqual((short)5, sim.ExecuteScalar("SELECT database_id FROM sys.databases WHERE name = 'simulated'"));
209+
AreEqual(5, sim.ExecuteScalar("SELECT database_id FROM sys.databases WHERE name = 'simulated'"));
210210
AreEqual("ONLINE", sim.ExecuteScalar("SELECT state_desc FROM sys.databases WHERE name = 'simulated'"));
211211
}
212212

SqlServerSimulator.Tests/SystemDatabaseTests.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,29 +49,29 @@ public void SystemDatabases_HaveFixedIds()
4949

5050
[TestMethod]
5151
public void SimulatedUserDatabase_HasDatabaseIdFive()
52-
=> AreEqual((short)5, new Simulation().ExecuteScalar("select database_id from sys.databases where name = 'simulated'"));
52+
=> AreEqual(5, new Simulation().ExecuteScalar("select database_id from sys.databases where name = 'simulated'"));
5353

5454
[TestMethod]
5555
public void SysDatabases_ListsFourSystemDatabasesThenUserDatabase_AllOnline()
5656
{
5757
using var reader = new Simulation().ExecuteReader(
5858
"select name, database_id, state_desc from sys.databases order by database_id");
59-
List<(string Name, short Id, string State)> rows =
59+
List<(string Name, int Id, string State)> rows =
6060
[
61-
.. reader.EnumerateRecords().Select(r => (Name: r.GetString(0), Id: r.GetInt16(1), State: r.GetString(2))),
61+
.. reader.EnumerateRecords().Select(r => (Name: r.GetString(0), Id: r.GetInt32(1), State: r.GetString(2))),
6262
];
6363
HasCount(5, rows);
64-
AreEqual(("master", (short)1), (rows[0].Name, rows[0].Id));
65-
AreEqual(("tempdb", (short)2), (rows[1].Name, rows[1].Id));
66-
AreEqual(("model", (short)3), (rows[2].Name, rows[2].Id));
67-
AreEqual(("msdb", (short)4), (rows[3].Name, rows[3].Id));
68-
AreEqual(("simulated", (short)5), (rows[4].Name, rows[4].Id));
64+
AreEqual(("master", 1), (rows[0].Name, rows[0].Id));
65+
AreEqual(("tempdb", 2), (rows[1].Name, rows[1].Id));
66+
AreEqual(("model", 3), (rows[2].Name, rows[2].Id));
67+
AreEqual(("msdb", 4), (rows[3].Name, rows[3].Id));
68+
AreEqual(("simulated", 5), (rows[4].Name, rows[4].Id));
6969
IsTrue(rows.All(r => r.State == "ONLINE"));
7070
}
7171

7272
[TestMethod]
7373
public void ThreePartRead_MasterSysDatabases_Resolves()
74-
=> AreEqual((short)1, new Simulation().ExecuteScalar(
74+
=> AreEqual(1, new Simulation().ExecuteScalar(
7575
"select database_id from master.sys.databases where name = 'master'"));
7676

7777
[TestMethod]

0 commit comments

Comments
 (0)