Skip to content

Commit 96c9c21

Browse files
committed
Added Simulation.ListenAsync/SimulatedNetworkListener: a loopback TDS 7.4 endpoint so unmodified SqlClient connects with only a connection-string change.
1 parent 7dcd64c commit 96c9c21

30 files changed

Lines changed: 3098 additions & 10 deletions

CLAUDE.md

Lines changed: 11 additions & 10 deletions
Large diffs are not rendered by default.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace SqlServerSimulator;
2+
3+
[TestClass]
4+
public static class AssemblyHooks
5+
{
6+
/// <summary>
7+
/// Triggers JIT compilation of the most common path among all tests, improving
8+
/// the accuracy of their timings. Also functions as a sanity check against the
9+
/// simulator being completely broken.
10+
/// </summary>
11+
[AssemblyInitialize]
12+
public static void HotPath(TestContext _)
13+
{
14+
if (System.Diagnostics.Debugger.IsAttached)
15+
return;
16+
17+
using var connection = new Simulation().CreateDbConnection();
18+
connection.Open();
19+
using var command = connection.CreateCommand();
20+
command.CommandText = "select 1";
21+
Assert.AreEqual(1, command.ExecuteScalar());
22+
}
23+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Multi-result batches, summed rows-affected, empty result sets, and the
8+
/// session id — all as the real client observes them over the wire.
9+
/// </summary>
10+
[TestClass]
11+
public sealed class BatchSemanticsTests
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
[TestMethod]
16+
public async Task MultiStatementBatch_NextResult_WalksResultSets()
17+
{
18+
var simulation = new Simulation();
19+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
20+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
21+
await using var command = new SqlCommand("select 1; select 2, 3", connection);
22+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
23+
24+
AreEqual(1, reader.FieldCount);
25+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
26+
AreEqual(1, reader.GetInt32(0));
27+
IsFalse(await reader.ReadAsync(TestContext.CancellationToken));
28+
29+
IsTrue(await reader.NextResultAsync(TestContext.CancellationToken));
30+
AreEqual(2, reader.FieldCount);
31+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
32+
AreEqual(2, reader.GetInt32(0));
33+
AreEqual(3, reader.GetInt32(1));
34+
35+
IsFalse(await reader.NextResultAsync(TestContext.CancellationToken));
36+
}
37+
38+
[TestMethod]
39+
public async Task ExecuteNonQuery_MultiDml_ReturnsSummedRowsAffected()
40+
{
41+
var simulation = new Simulation();
42+
Wire.ExecInProc(simulation, "create table t (id int)");
43+
44+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
45+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
46+
await using var command = new SqlCommand("insert t values (1), (2); insert t values (3)", connection);
47+
AreEqual(3, await command.ExecuteNonQueryAsync(TestContext.CancellationToken));
48+
}
49+
50+
[TestMethod]
51+
public async Task EmptyResultSet_HasRowsFalse_FieldCountCorrect()
52+
{
53+
var simulation = new Simulation();
54+
Wire.ExecInProc(simulation, "create table t (a int, b int)");
55+
56+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
57+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
58+
await using var command = new SqlCommand("select a, b from t where 1 = 0", connection);
59+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
60+
61+
AreEqual(2, reader.FieldCount);
62+
IsFalse(reader.HasRows);
63+
IsFalse(await reader.ReadAsync(TestContext.CancellationToken));
64+
}
65+
66+
[TestMethod]
67+
public async Task Spid_IsAtLeast51_AndMatchesInProcessType()
68+
{
69+
var simulation = new Simulation();
70+
var oracle = Wire.ReadAllInProc(simulation, "select @@spid");
71+
72+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
73+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
74+
await using var command = new SqlCommand("select @@spid", connection);
75+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
76+
77+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
78+
var wireSpid = reader.GetValue(0);
79+
// @@SPID is a distinct session per surface, so the numbers differ; the
80+
// CLR shape (smallint -> short) and the >= 51 floor must still match.
81+
AreEqual(oracle[0][0]!.GetType(), wireSpid.GetType());
82+
IsGreaterThanOrEqualTo(51, Convert.ToInt32(wireSpid));
83+
}
84+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Error tokens surface through the wire as real <see cref="SqlException"/>s
8+
/// carrying the simulator's number/class/state, the connection stays usable
9+
/// after one, and the server name is <c>SIMULATED</c>. Class/state are checked
10+
/// against the same simulation's in-process exception rather than hardcoded.
11+
/// </summary>
12+
[TestClass]
13+
public sealed class ErrorSurfaceTests
14+
{
15+
public TestContext TestContext { get; set; } = null!;
16+
17+
private static SimulatedSqlException CaptureInProc(Simulation simulation, string sql)
18+
{
19+
try
20+
{
21+
Wire.ExecInProc(simulation, sql);
22+
}
23+
catch (SimulatedSqlException ex)
24+
{
25+
return ex;
26+
}
27+
28+
throw new AssertFailedException($"Expected an in-process error from: {sql}");
29+
}
30+
31+
[TestMethod]
32+
public async Task DivideByZero_InReaderLoop_Number8134()
33+
{
34+
var simulation = new Simulation();
35+
var oracle = CaptureInProc(simulation, "select 1 / 0");
36+
37+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
38+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
39+
40+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
41+
{
42+
await using var command = new SqlCommand("select 1 / 0", connection);
43+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
44+
while (await reader.ReadAsync(TestContext.CancellationToken))
45+
_ = reader.GetValue(0);
46+
});
47+
48+
AreEqual(8134, ex.Number);
49+
AreEqual(oracle.Class, ex.Class);
50+
AreEqual(oracle.State, ex.State);
51+
// SqlClient reports its own data source on SqlException.Server, not the
52+
// server name carried in the TDS error token, so the observable value is
53+
// the connection's data source rather than the token's "SIMULATED".
54+
AreEqual(connection.DataSource, ex.Server);
55+
}
56+
57+
[TestMethod]
58+
public async Task SyntaxError_Number102_Class15()
59+
{
60+
var simulation = new Simulation();
61+
var oracle = CaptureInProc(simulation, "select from where");
62+
63+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
64+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
65+
66+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
67+
{
68+
await using var command = new SqlCommand("select from where", connection);
69+
_ = await command.ExecuteReaderAsync(TestContext.CancellationToken);
70+
});
71+
72+
AreEqual(102, ex.Number);
73+
AreEqual(15, ex.Class);
74+
AreEqual(oracle.State, ex.State);
75+
// SqlClient reports its own data source on SqlException.Server, not the
76+
// server name carried in the TDS error token, so the observable value is
77+
// the connection's data source rather than the token's "SIMULATED".
78+
AreEqual(connection.DataSource, ex.Server);
79+
}
80+
81+
[TestMethod]
82+
public async Task AfterError_SameConnection_StillExecutes()
83+
{
84+
var simulation = new Simulation();
85+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
86+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
87+
88+
_ = await Assert.ThrowsAsync<SqlException>(async () =>
89+
{
90+
await using var command = new SqlCommand("select 1 / 0", connection);
91+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
92+
while (await reader.ReadAsync(TestContext.CancellationToken))
93+
_ = reader.GetValue(0);
94+
});
95+
96+
await using var ok = new SqlCommand("select 1", connection);
97+
AreEqual(1, await ok.ExecuteScalarAsync(TestContext.CancellationToken));
98+
}
99+
100+
[TestMethod]
101+
public async Task MissingTable_Number208()
102+
{
103+
var simulation = new Simulation();
104+
var oracle = CaptureInProc(simulation, "select * from nonexistent_table");
105+
106+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
107+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
108+
109+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
110+
{
111+
await using var command = new SqlCommand("select * from nonexistent_table", connection);
112+
_ = await command.ExecuteReaderAsync(TestContext.CancellationToken);
113+
});
114+
115+
AreEqual(208, ex.Number);
116+
AreEqual(oracle.Class, ex.Class);
117+
AreEqual(oracle.State, ex.State);
118+
// SqlClient reports its own data source on SqlException.Server, not the
119+
// server name carried in the TDS error token, so the observable value is
120+
// the connection's data source rather than the token's "SIMULATED".
121+
AreEqual(connection.DataSource, ex.Server);
122+
}
123+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Low-severity server messages (PRINT, RAISERROR at severity &lt;= 10) arrive
8+
/// on <see cref="SqlConnection.InfoMessage"/> rather than as exceptions.
9+
/// </summary>
10+
[TestClass]
11+
public sealed class InfoMessageTests
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
private static List<string> Subscribe(SqlConnection connection)
16+
{
17+
var messages = new List<string>();
18+
connection.InfoMessage += (_, e) =>
19+
{
20+
foreach (SqlError error in e.Errors)
21+
messages.Add(error.Message);
22+
};
23+
24+
return messages;
25+
}
26+
27+
[TestMethod]
28+
public async Task Print_RaisesInfoMessage()
29+
{
30+
var simulation = new Simulation();
31+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
32+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
33+
var messages = Subscribe(connection);
34+
35+
await using var command = new SqlCommand("print 'hello'", connection);
36+
_ = await command.ExecuteNonQueryAsync(TestContext.CancellationToken);
37+
38+
Contains("hello", messages);
39+
}
40+
41+
[TestMethod]
42+
public async Task RaiseError_LowSeverity_ArrivesAsInfoMessage()
43+
{
44+
var simulation = new Simulation();
45+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
46+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
47+
var messages = Subscribe(connection);
48+
49+
await using var command = new SqlCommand("raiserror('warn', 5, 1)", connection);
50+
_ = await command.ExecuteNonQueryAsync(TestContext.CancellationToken);
51+
52+
Contains("warn", messages);
53+
}
54+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Payloads large enough to force PLP chunking and multi-packet TDS responses,
8+
/// plus a wide row count, all over the real wire.
9+
/// </summary>
10+
[TestClass]
11+
public sealed class LargeValueTests
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
[TestMethod]
16+
public async Task NVarcharMax_200k_RoundTripsExactly()
17+
{
18+
var value = string.Concat(Enumerable.Range(0, 20000).Select(i => $"アイウ{i:D6}x")); // 200,000 chars
19+
AreEqual(200000, value.Length);
20+
21+
var simulation = new Simulation();
22+
Wire.ExecInProc(simulation, "create table t (v nvarchar(max))");
23+
Wire.ExecInProcParam(simulation, "insert t (v) values (@v)", "@v", value);
24+
25+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
26+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
27+
28+
await using var command = new SqlCommand("select v from t", connection);
29+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
30+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
31+
var read = reader.GetString(0);
32+
AreEqual(200000, read.Length);
33+
AreEqual(value, read);
34+
}
35+
36+
[TestMethod]
37+
public async Task VarbinaryMax_300k_RoundTripsExactly()
38+
{
39+
var value = new byte[300000];
40+
for (var i = 0; i < value.Length; i++)
41+
value[i] = (byte)((i * 31) + 7);
42+
43+
var simulation = new Simulation();
44+
Wire.ExecInProc(simulation, "create table t (v varbinary(max))");
45+
Wire.ExecInProcParam(simulation, "insert t (v) values (@v)", "@v", value);
46+
47+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
48+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
49+
50+
await using var command = new SqlCommand("select v from t", connection);
51+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
52+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
53+
var read = (byte[])reader.GetValue(0);
54+
HasCount(300000, read);
55+
CollectionAssert.AreEqual(value, read);
56+
}
57+
58+
[TestMethod]
59+
public async Task TenThousandRows_StreamOverWire()
60+
{
61+
var simulation = new Simulation();
62+
63+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
64+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
65+
await using var command = new SqlCommand("select value from generate_series(1, 10000) order by value", connection);
66+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
67+
68+
var count = 0;
69+
var first = 0L;
70+
var last = 0L;
71+
while (await reader.ReadAsync(TestContext.CancellationToken))
72+
{
73+
var current = Convert.ToInt64(reader.GetValue(0));
74+
if (count == 0)
75+
first = current;
76+
last = current;
77+
count++;
78+
}
79+
80+
AreEqual(10000, count);
81+
AreEqual(1L, first);
82+
AreEqual(10000L, last);
83+
}
84+
}

0 commit comments

Comments
 (0)