Skip to content

Commit c1ffa69

Browse files
committed
The RPC-parameter and bulk/TVP-column value decoders now unify on a shared TdsWireValue core with TdsColumnDecoder as the single COLMETADATA-shaped decode home.
1 parent ec37dc5 commit c1ffa69

15 files changed

Lines changed: 857 additions & 329 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using SqlServerSimulator.Network;
2+
using SqlServerSimulator.Storage;
3+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// Pins the <see cref="TdsTokenWriter.AtTokenBoundary"/> invariant the terminal
9+
/// crash boundary relies on: the flag is true whenever the buffer ends at a
10+
/// complete-token boundary and false only while a composite token
11+
/// (COLMETADATA / ROW / RETURNVALUE — the writers that interleave a
12+
/// throw-capable per-column sub-write) is mid-write. A crash that struck
13+
/// mid-composite therefore leaves the flag false, and the backstop declines to
14+
/// append an ERROR token that would desync the stream.
15+
/// </summary>
16+
[TestClass]
17+
public sealed class TokenBoundaryTests
18+
{
19+
private static TdsTokenWriter NewWriter() => new(new TdsPacketTransport(new MemoryStream()));
20+
21+
[TestMethod]
22+
public void FreshWriter_IsAtTokenBoundary()
23+
{
24+
IsTrue(NewWriter().AtTokenBoundary);
25+
}
26+
27+
[TestMethod]
28+
public void SelfContainedToken_LeavesBoundaryTrue()
29+
{
30+
var writer = NewWriter();
31+
writer.WriteDone(Tds.DoneFinal, 0);
32+
IsTrue(writer.AtTokenBoundary);
33+
}
34+
35+
[TestMethod]
36+
public void Composite_IsNotAtBoundary_WhileOpen_TrueWhenClosed()
37+
{
38+
var writer = NewWriter();
39+
writer.EnterComposite();
40+
IsFalse(writer.AtTokenBoundary);
41+
writer.LeaveComposite();
42+
IsTrue(writer.AtTokenBoundary);
43+
}
44+
45+
[TestMethod]
46+
public void WriteColMetadata_LeavesBoundaryTrue()
47+
{
48+
var writer = NewWriter();
49+
TdsTypeCodec.WriteColMetadata(writer, [SqlType.Int32], ["a"], null);
50+
IsTrue(writer.AtTokenBoundary);
51+
}
52+
53+
[TestMethod]
54+
public void CompositeAbandonedMidWrite_LeavesBoundaryFalse()
55+
{
56+
// Simulates a throw between the token's first byte and its completion —
57+
// WriteColMetadata's EnterComposite ran but a per-column WriteTypeInfo
58+
// would have thrown before LeaveComposite. The flag stays false, so the
59+
// backstop must NOT append another token here.
60+
var writer = NewWriter();
61+
writer.EnterComposite();
62+
writer.WriteByte(Tds.TokenColMetadata);
63+
IsFalse(writer.AtTokenBoundary);
64+
}
65+
}

SqlServerSimulator.Tests.SqlClient/BulkCopyTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,59 @@ public async Task EmptySource_WritesNoRows()
415415
AreEqual(0, Scalar(connection, "select count(*) from t"));
416416
}
417417

418+
[TestMethod]
419+
public async Task LegacyLobColumns_TextNtextImage_InsertAndRoundTrip()
420+
{
421+
// SqlClient sends legacy text / ntext / image destination columns in the
422+
// BCP stream with their LONGLEN TYPE_INFO (a 4-byte max size, the 5-byte
423+
// collation for the string pair, and a zero-part TableName field) and the
424+
// in-band text-pointer ROW value form — the same value form results carry,
425+
// now decoded by the shared column decoder. Probe-captured against
426+
// SqlClient 7.0.2 (2026-07-19).
427+
var (_, listener, connection) = await SetUpAsync(
428+
"create table t (id int, t_col text null, n_col ntext null, i_col image null)",
429+
TestContext.CancellationToken);
430+
await using var listenerScope = listener;
431+
await using var connectionScope = connection;
432+
433+
var big = new string('Z', 20000);
434+
var image = new byte[300];
435+
for (var i = 0; i < image.Length; i++)
436+
image[i] = (byte)(i & 0xFF);
437+
438+
var data = Table(("id", typeof(int)), ("t_col", typeof(string)), ("n_col", typeof(string)), ("i_col", typeof(byte[])));
439+
_ = data.Rows.Add(1, "hello text", "wörld ntext", new byte[] { 1, 2, 3, 254 });
440+
_ = data.Rows.Add(2, DBNull.Value, DBNull.Value, DBNull.Value);
441+
_ = data.Rows.Add(3, big, big, image);
442+
using (var bulk = new SqlBulkCopy(connection) { DestinationTableName = "t" })
443+
{
444+
Map(bulk, "id", "id");
445+
Map(bulk, "t_col", "t_col");
446+
Map(bulk, "n_col", "n_col");
447+
Map(bulk, "i_col", "i_col");
448+
await bulk.WriteToServerAsync(data, TestContext.CancellationToken);
449+
}
450+
451+
await using var read = new SqlCommand("select t_col, n_col, i_col from t order by id", connection);
452+
await using var reader = await read.ExecuteReaderAsync(TestContext.CancellationToken);
453+
454+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
455+
AreEqual("hello text", reader.GetString(0));
456+
AreEqual("wörld ntext", reader.GetString(1));
457+
CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 254 }, (byte[])reader.GetValue(2));
458+
459+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
460+
IsTrue(reader.IsDBNull(0));
461+
IsTrue(reader.IsDBNull(1));
462+
IsTrue(reader.IsDBNull(2));
463+
464+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
465+
AreEqual(big, reader.GetString(0));
466+
AreEqual(big, reader.GetString(1));
467+
CollectionAssert.AreEqual(image, (byte[])reader.GetValue(2));
468+
IsFalse(await reader.ReadAsync(TestContext.CancellationToken));
469+
}
470+
418471
[TestMethod]
419472
public async Task MixedScalarTypes_RoundTripOverTheWire()
420473
{
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// The TDS session's terminal crash boundary: an exception the typed catch list
8+
/// does not anticipate (and no handler converted to an ERROR token) used to kill
9+
/// the session silently, leaving the client with a bare transport reset. The
10+
/// backstop now emits a best-effort Msg 0 / severity 20 ERROR — the shape real
11+
/// SQL Server sends for an internal failure — so SqlClient surfaces a
12+
/// <c>SqlException</c> and marks the connection dead, the same way it treats a
13+
/// severity ≥ 20 error from a real server.
14+
/// </summary>
15+
[TestClass]
16+
public sealed class CrashBoundaryTests
17+
{
18+
public TestContext TestContext { get; set; } = null!;
19+
20+
[TestMethod]
21+
public async Task UnexpectedSessionException_SurfacesSevereError_NotTransportReset()
22+
{
23+
var simulation = new Simulation
24+
{
25+
// A type absent from RunAsync's typed catch list, so it escapes to the
26+
// terminal backstop rather than a per-statement ERROR conversion.
27+
NetworkBatchCrashHookForTesting = () => throw new InvalidOperationException("forced internal failure"),
28+
};
29+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
30+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
31+
await using var command = new SqlCommand("select 1", connection);
32+
33+
var exception = await Assert.ThrowsExactlyAsync<SqlException>(
34+
async () => await command.ExecuteScalarAsync(TestContext.CancellationToken));
35+
36+
// Msg 0, severity 20 — a SqlException, not a transport-layer exception.
37+
AreEqual(0, exception.Number);
38+
AreEqual((byte)20, exception.Class);
39+
Contains("A severe error occurred on the current command", exception.Message);
40+
}
41+
42+
[TestMethod]
43+
public async Task SevereError_LeavesConnectionUnusable()
44+
{
45+
var simulation = new Simulation
46+
{
47+
NetworkBatchCrashHookForTesting = () => throw new InvalidOperationException("forced internal failure"),
48+
};
49+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
50+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
51+
await using var command = new SqlCommand("select 1", connection);
52+
53+
_ = await Assert.ThrowsExactlyAsync<SqlException>(
54+
async () => await command.ExecuteScalarAsync(TestContext.CancellationToken));
55+
56+
// Severity 20 is fatal client-side: the connection is no longer open.
57+
AreNotEqual(System.Data.ConnectionState.Open, connection.State);
58+
}
59+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System.Data;
2+
using System.Data.SqlTypes;
3+
using Microsoft.Data.SqlClient;
4+
using Microsoft.Data.SqlClient.Server;
5+
using Microsoft.SqlServer.Types;
6+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
7+
8+
namespace SqlServerSimulator;
9+
10+
/// <summary>
11+
/// <c>sql_variant</c> and CLR-UDT (<c>hierarchyid</c> / <c>geography</c> /
12+
/// <c>geometry</c>) columns inside a table-valued parameter. Once the RPC value
13+
/// decoders and the COLMETADATA-shaped column decoder share one implementation
14+
/// (<c>TdsWireValue</c>), a TVP column carrying a self-describing
15+
/// <c>sql_variant</c> body or a PLP-carried UDT value decodes through the same
16+
/// path an RPC <c>sql_variant</c> / UDT parameter uses. Wire shapes probe-captured
17+
/// against SQL Server 2025 + SqlClient 7.0.2 through a cleartext tee proxy
18+
/// (2026-07-19): the <c>sql_variant</c> column TYPE_INFO is <c>0x62</c> + a 4-byte
19+
/// max length with a 4-byte-total-length (0 = NULL) body value; the UDT column
20+
/// TYPE_INFO is <c>0xF0</c> + three B_VARCHARs (db / schema / type) with a PLP
21+
/// value — both the same forms as the matching RPC parameters.
22+
/// </summary>
23+
/// <remarks>
24+
/// These drive the TVP through the <c>sp_executesql</c> text path (the parameter
25+
/// binds as its own <c>@rows</c> table variable). Routing a LOB-backed TVP column
26+
/// (<c>geography</c> / <c>geometry</c>, like <c>nvarchar(max)</c>) through a
27+
/// stored-procedure READONLY parameter hits a pre-existing table-variable LOB-copy
28+
/// gap in proc-parameter binding, unrelated to the wire decode; see
29+
/// docs/claude/tds-endpoint.md.
30+
/// </remarks>
31+
[TestClass]
32+
public sealed class TvpVariantUdtColumnTests
33+
{
34+
public TestContext TestContext { get; set; } = null!;
35+
36+
// geometry POINT(3 4): the 22-byte single-point MS spatial binary
37+
// (SqlGeometry.STGeomFromText is unavailable on managed Types under Linux, so
38+
// geometry rides raw WKB bytes — the shape DacFx / bulk-import consumers send).
39+
private const string GeometryPointWkbHex = "00000000010C00000000000008400000000000001040";
40+
41+
private static async Task InsertViaTvp(SqlConnection connection, string typeName, IEnumerable<SqlDataRecord> rows, CancellationToken token)
42+
{
43+
await using var command = new SqlCommand("insert into dbo.sink select * from @rows", connection);
44+
var parameter = command.Parameters.AddWithValue("@rows", rows);
45+
parameter.SqlDbType = SqlDbType.Structured;
46+
parameter.TypeName = typeName;
47+
_ = await command.ExecuteNonQueryAsync(token);
48+
}
49+
50+
[TestMethod]
51+
public async Task SqlVariantColumn_PerBaseType_RoundTripsWithBaseType()
52+
{
53+
var simulation = new Simulation();
54+
Wire.ExecInProc(simulation, "create type dbo.VarRows as table (id int, v sql_variant)");
55+
Wire.ExecInProc(simulation, "create table dbo.sink (id int, v sql_variant)");
56+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
57+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
58+
59+
var metadata = new[] { new SqlMetaData("id", SqlDbType.Int), new SqlMetaData("v", SqlDbType.Variant) };
60+
var rows = new List<SqlDataRecord>();
61+
foreach (var (id, value) in new (int, object?)[] { (1, 4242), (2, "hello"), (3, 3.5d), (4, null) })
62+
{
63+
var record = new SqlDataRecord(metadata);
64+
record.SetInt32(0, id);
65+
if (value is null)
66+
record.SetDBNull(1);
67+
else
68+
record.SetValue(1, value);
69+
rows.Add(record);
70+
}
71+
72+
await InsertViaTvp(connection, "dbo.VarRows", rows, TestContext.CancellationToken);
73+
74+
var read = Wire.Drain(await new SqlCommand(
75+
"select id, v, sql_variant_property(v, 'BaseType') from dbo.sink order by id",
76+
connection).ExecuteReaderAsync(TestContext.CancellationToken));
77+
HasCount(4, read);
78+
AreEqual(4242, read[0][1]);
79+
AreEqual("int", read[0][2]);
80+
AreEqual("hello", read[1][1]);
81+
AreEqual("nvarchar", read[1][2]);
82+
AreEqual(3.5d, read[2][1]);
83+
AreEqual("float", read[2][2]);
84+
IsNull(read[3][1]);
85+
}
86+
87+
[TestMethod]
88+
public async Task HierarchyIdColumn_RoundTripsToPathAndDataLength()
89+
{
90+
var simulation = new Simulation();
91+
Wire.ExecInProc(simulation, "create type dbo.HierRows as table (id int, h hierarchyid)");
92+
Wire.ExecInProc(simulation, "create table dbo.sink (id int, h hierarchyid)");
93+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
94+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
95+
96+
var metadata = new[] { new SqlMetaData("id", SqlDbType.Int), new SqlMetaData("h", SqlDbType.Udt, typeof(SqlHierarchyId), "hierarchyid") };
97+
var record = new SqlDataRecord(metadata);
98+
record.SetInt32(0, 1);
99+
record.SetValue(1, SqlHierarchyId.Parse(new SqlString("/1/2/")));
100+
101+
await InsertViaTvp(connection, "dbo.HierRows", [record], TestContext.CancellationToken);
102+
103+
await using var reader = await new SqlCommand("select h.ToString(), datalength(h) from dbo.sink", connection)
104+
.ExecuteReaderAsync(TestContext.CancellationToken);
105+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
106+
AreEqual("/1/2/", reader.GetString(0));
107+
AreEqual(2, reader.GetInt32(1));
108+
}
109+
110+
[TestMethod]
111+
public async Task GeographyColumn_RoundTripsToWkt()
112+
{
113+
var simulation = new Simulation();
114+
Wire.ExecInProc(simulation, "create type dbo.GeoRows as table (id int, g geography)");
115+
Wire.ExecInProc(simulation, "create table dbo.sink (id int, g geography)");
116+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
117+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
118+
119+
var metadata = new[] { new SqlMetaData("id", SqlDbType.Int), new SqlMetaData("g", SqlDbType.Udt, typeof(SqlGeography), "geography") };
120+
var record = new SqlDataRecord(metadata);
121+
record.SetInt32(0, 1);
122+
record.SetValue(1, SqlGeography.STGeomFromText(new SqlChars("POINT(-122.3 47.6)"), 4326));
123+
124+
await InsertViaTvp(connection, "dbo.GeoRows", [record], TestContext.CancellationToken);
125+
126+
AreEqual("POINT (-122.3 47.6)", await new SqlCommand("select g.ToString() from dbo.sink", connection)
127+
.ExecuteScalarAsync(TestContext.CancellationToken));
128+
}
129+
130+
[TestMethod]
131+
public async Task GeometryColumn_RoundTripsToWkt()
132+
{
133+
var simulation = new Simulation();
134+
Wire.ExecInProc(simulation, "create type dbo.GeomRows as table (id int, g geometry)");
135+
Wire.ExecInProc(simulation, "create table dbo.sink (id int, g geometry)");
136+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
137+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
138+
139+
var metadata = new[] { new SqlMetaData("id", SqlDbType.Int), new SqlMetaData("g", SqlDbType.Udt, typeof(SqlGeometry), "geometry") };
140+
var record = new SqlDataRecord(metadata);
141+
record.SetInt32(0, 1);
142+
record.SetSqlBytes(1, new SqlBytes(Convert.FromHexString(GeometryPointWkbHex)));
143+
144+
await InsertViaTvp(connection, "dbo.GeomRows", [record], TestContext.CancellationToken);
145+
146+
AreEqual("POINT (3 4)", await new SqlCommand("select g.ToString() from dbo.sink", connection)
147+
.ExecuteScalarAsync(TestContext.CancellationToken));
148+
}
149+
}

0 commit comments

Comments
 (0)