Skip to content

Commit f271a0e

Browse files
committed
Legacy text/ntext/image result columns and image RPC input parameters ship over the TDS wire in the in-band textptr form: COLMETADATA carries the LONGLEN max sizes, text/ntext collation; image parameters arrive in the contiguous non-PLP legacy form closing the last rejected input TYPE_INFO.
1 parent 5265804 commit f271a0e

9 files changed

Lines changed: 361 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
165165
- **New top-level statement parser or dispatch-loop separator rules, double-quoted identifiers / QUOTED_IDENTIFIER**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
166166
- **BACPAC import** (`Simulation.ImportBacpac` — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
167167
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part FROM routing through the remote's ADO.NET pipeline, `OPENQUERY(server,'query')` ad-hoc pass-through + compile-time schema discovery, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
168-
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM/BulkLoad (`SqlBulkCopy`); TVP (`0xF3` `SqlDbType.Structured`) RPC params via the shared `TdsColumnDecoder`; `sp_cursor*` API-server-cursor RPC family; mid-stream attention (cancel / `CommandTimeout`) via a carry-forward concurrent read → `DONE_ATTN` ack + probed XACT_ABORT tx semantics; MARS (`MultipleActiveResultSets=True`) via an SMP demux/mux over one shared connection with serialized cooperative multiplexing + per-session attention; credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
168+
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM/BulkLoad (`SqlBulkCopy`); legacy `text`/`ntext`/`image` result columns + `image` RPC input params in the in-band textptr wire form; TVP (`0xF3` `SqlDbType.Structured`) RPC params via the shared `TdsColumnDecoder`; `sp_cursor*` API-server-cursor RPC family; mid-stream attention (cancel / `CommandTimeout`) via a carry-forward concurrent read → `DONE_ATTN` ack + probed XACT_ABORT tx semantics; MARS (`MultipleActiveResultSets=True`) via an SMP demux/mux over one shared connection with serialized cooperative multiplexing + per-session attention; credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
169169

170170
## Not modeled
171171

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using System.Data;
2+
using Microsoft.Data.SqlClient;
3+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// The deprecated <c>text</c> / <c>ntext</c> / <c>image</c> types over the wire.
9+
/// The codec advertises them with their legacy TYPE_INFO — LONGLEN max size
10+
/// (0x7FFFFFFF text/image, 0x7FFFFFFE ntext), the 5-byte collation for the
11+
/// string pair, and the TableName field only these types carry — and streams
12+
/// each ROW value in the in-band textptr form (a 16-byte text pointer + 8-byte
13+
/// timestamp placeholder, a 4-byte data length, then the raw bytes; a single
14+
/// zero byte for NULL). Wire shapes probe-captured against SQL Server 2025
15+
/// through a cleartext tee proxy (2026-07-19). Values are dual-read against the
16+
/// in-process ADO oracle over the same simulation.
17+
/// </summary>
18+
[TestClass]
19+
public sealed class LegacyLobWireTests
20+
{
21+
public TestContext TestContext { get; set; } = null!;
22+
23+
private static readonly string LargeText = new('Z', 100_000);
24+
private static readonly string LargeNText = new('ç', 60_000);
25+
26+
[TestMethod]
27+
public async Task LegacyLobColumns_SelectAllShapes_MatchInProcOracle()
28+
{
29+
var simulation = new Simulation();
30+
Wire.ExecInProc(simulation, "create table L (id int not null, t text null, n ntext null, i image null);");
31+
Wire.ExecInProcParam(simulation, "insert L (id, t, n, i) values (1, null, null, null)", "@x", 0);
32+
Wire.ExecInProc(simulation, "insert L (id, t, n, i) values (2, '', N'', 0x);");
33+
Wire.ExecInProc(simulation, "insert L (id, t, n, i) values (3, 'hello', N'wörld', 0x0102030405);");
34+
Wire.ExecInProcParam(simulation, "insert L (id, t, n, i) values (4, @x, null, null)", "@x", LargeText);
35+
Wire.ExecInProcParam(simulation, "insert L (id, t, n, i) values (5, null, @x, null)", "@x", LargeNText);
36+
Wire.ExecInProcParam(simulation, "insert L (id, t, n, i) values (6, null, null, @x)", "@x", MakeBytes(100_000));
37+
38+
var oracle = Wire.ReadAllInProc(simulation, "select t, n, i from L order by id");
39+
40+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
41+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
42+
await using var command = new SqlCommand("select t, n, i from L order by id", connection);
43+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
44+
45+
AreEqual("text", reader.GetDataTypeName(0));
46+
AreEqual("ntext", reader.GetDataTypeName(1));
47+
AreEqual("image", reader.GetDataTypeName(2));
48+
49+
var wireRows = Wire.Drain(reader);
50+
HasCount(oracle.Count, wireRows);
51+
for (var row = 0; row < oracle.Count; row++)
52+
{
53+
for (var col = 0; col < 3; col++)
54+
{
55+
if (oracle[row][col] is null)
56+
IsNull(wireRows[row][col]);
57+
else
58+
Wire.AssertValueEqual(oracle[row][col]!, wireRows[row][col]!);
59+
}
60+
}
61+
}
62+
63+
[TestMethod]
64+
public async Task LegacyLobColumns_AccessorMatrix()
65+
{
66+
var simulation = new Simulation();
67+
Wire.ExecInProc(simulation, "create table L (id int not null, t text null, n ntext null, i image null);");
68+
Wire.ExecInProc(simulation, "insert L values (1, 'hello world', N'grüße', 0x00ff10203040);");
69+
70+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
71+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
72+
await using var command = new SqlCommand("select t, n, i from L", connection);
73+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
74+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
75+
76+
// GetString / GetValue on the string pair.
77+
AreEqual("hello world", reader.GetString(0));
78+
AreEqual("grüße", reader.GetString(1));
79+
AreEqual("hello world", (string)reader.GetValue(0));
80+
81+
// GetChars materializes a windowed slice of the text column.
82+
var window = new char[5];
83+
var copied = reader.GetChars(0, 6, window, 0, 5);
84+
AreEqual(5L, copied);
85+
AreEqual("world", new string(window));
86+
87+
// GetTextReader streams the ntext column.
88+
using (var textReader = reader.GetTextReader(1))
89+
AreEqual("grüße", await textReader.ReadToEndAsync(TestContext.CancellationToken));
90+
91+
// GetSqlBytes / GetBytes on the image column.
92+
var expected = new byte[] { 0x00, 0xFF, 0x10, 0x20, 0x30, 0x40 };
93+
CollectionAssert.AreEqual(expected, reader.GetSqlBytes(2).Value);
94+
AreEqual(expected.Length, (int)reader.GetBytes(2, 0, null, 0, 0));
95+
CollectionAssert.AreEqual(expected, (byte[])reader.GetValue(2));
96+
}
97+
98+
/// <summary>
99+
/// <c>text</c> streams CP1252 bytes per the column collation, so a Windows-1252
100+
/// character round-trips to the same string the in-process path returns and
101+
/// <c>DATALENGTH</c> counts single bytes; <c>ntext</c> streams UTF-16LE.
102+
/// </summary>
103+
[TestMethod]
104+
public async Task LegacyLobColumns_StringCollationBytes()
105+
{
106+
var simulation = new Simulation();
107+
Wire.ExecInProc(simulation, "create table L (t text null, n ntext null);");
108+
// Euro sign: one byte 0x80 in CP1252, two bytes in UTF-16LE.
109+
Wire.ExecInProc(simulation, "insert L values ('a€b', N'a€b');");
110+
111+
var oracle = Wire.ReadAllInProc(simulation, "select t, n, datalength(t), datalength(n) from L");
112+
113+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
114+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
115+
await using var command = new SqlCommand("select t, n, datalength(t), datalength(n) from L", connection);
116+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
117+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
118+
119+
AreEqual((string)oracle[0][0]!, reader.GetString(0));
120+
AreEqual("a€b", reader.GetString(0));
121+
AreEqual("a€b", reader.GetString(1));
122+
AreEqual(3, reader.GetInt32(2)); // CP1252: 3 bytes
123+
AreEqual(6, reader.GetInt32(3)); // UTF-16LE: 6 bytes
124+
}
125+
126+
[TestMethod]
127+
public async Task LegacyLobColumns_LargeMultiPacket_RoundTrips()
128+
{
129+
var simulation = new Simulation();
130+
Wire.ExecInProc(simulation, "create table L (t text null, n ntext null, i image null);");
131+
Wire.ExecInProcParam(simulation, "insert L (t) values (@x)", "@x", LargeText);
132+
Wire.ExecInProcParam(simulation, "insert L (n) values (@x)", "@x", LargeNText);
133+
Wire.ExecInProcParam(simulation, "insert L (i) values (@x)", "@x", MakeBytes(100_000));
134+
135+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
136+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
137+
await using var command = new SqlCommand("select t, n, i from L", connection);
138+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
139+
140+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
141+
AreEqual(LargeText, reader.GetString(0));
142+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
143+
AreEqual(LargeNText, reader.GetString(1));
144+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
145+
CollectionAssert.AreEqual(MakeBytes(100_000), reader.GetSqlBytes(2).Value);
146+
IsFalse(await reader.ReadAsync(TestContext.CancellationToken));
147+
}
148+
149+
[TestMethod]
150+
public async Task LegacyLobInputParameters_ThroughStoredProc()
151+
{
152+
var simulation = new Simulation();
153+
Wire.ExecInProc(simulation, """
154+
create table L (id int identity primary key, t text null, n ntext null, i image null);
155+
""");
156+
Wire.ExecInProc(simulation, """
157+
create procedure Ins @a text, @b ntext, @c image as
158+
insert L (t, n, i) values (@a, @b, @c);
159+
""");
160+
161+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
162+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
163+
164+
await ExecProc(connection, "hi", "né", [9, 8, 7]);
165+
await ExecProc(connection, null, null, null);
166+
await ExecProc(connection, LargeText, LargeNText, MakeBytes(20_000));
167+
168+
await AssertBackFillOverWire(listener);
169+
}
170+
171+
[TestMethod]
172+
public async Task LegacyLobInputParameters_ThroughSpExecuteSql()
173+
{
174+
var simulation = new Simulation();
175+
Wire.ExecInProc(simulation, "create table L (id int identity primary key, t text null, n ntext null, i image null);");
176+
177+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
178+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
179+
180+
await ExecInsert(connection, "hi", "né", [9, 8, 7]);
181+
await ExecInsert(connection, null, null, null);
182+
await ExecInsert(connection, LargeText, LargeNText, MakeBytes(20_000));
183+
184+
await AssertBackFillOverWire(listener);
185+
}
186+
187+
private static async Task ExecProc(SqlConnection connection, string? text, string? ntext, byte[]? image)
188+
{
189+
await using var command = new SqlCommand("Ins", connection) { CommandType = CommandType.StoredProcedure };
190+
_ = command.Parameters.Add(new SqlParameter("@a", SqlDbType.Text) { Value = (object?)text ?? DBNull.Value });
191+
_ = command.Parameters.Add(new SqlParameter("@b", SqlDbType.NText) { Value = (object?)ntext ?? DBNull.Value });
192+
_ = command.Parameters.Add(new SqlParameter("@c", SqlDbType.Image) { Value = (object?)image ?? DBNull.Value });
193+
_ = await command.ExecuteNonQueryAsync();
194+
}
195+
196+
private static async Task ExecInsert(SqlConnection connection, string? text, string? ntext, byte[]? image)
197+
{
198+
await using var command = new SqlCommand("insert L (t, n, i) values (@a, @b, @c)", connection);
199+
_ = command.Parameters.Add(new SqlParameter("@a", SqlDbType.Text) { Value = (object?)text ?? DBNull.Value });
200+
_ = command.Parameters.Add(new SqlParameter("@b", SqlDbType.NText) { Value = (object?)ntext ?? DBNull.Value });
201+
_ = command.Parameters.Add(new SqlParameter("@c", SqlDbType.Image) { Value = (object?)image ?? DBNull.Value });
202+
_ = await command.ExecuteNonQueryAsync();
203+
}
204+
205+
private async Task AssertBackFillOverWire(SimulatedNetworkListener listener)
206+
{
207+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
208+
await using var command = new SqlCommand("select t, n, i from L order by id", connection);
209+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
210+
AssertBackFillRows(Wire.Drain(reader));
211+
}
212+
213+
private static void AssertBackFillRows(List<object?[]> rows)
214+
{
215+
HasCount(3, rows);
216+
217+
AreEqual("hi", rows[0][0]);
218+
AreEqual("né", rows[0][1]);
219+
CollectionAssert.AreEqual(new byte[] { 9, 8, 7 }, (byte[])rows[0][2]!);
220+
221+
IsNull(rows[1][0]);
222+
IsNull(rows[1][1]);
223+
IsNull(rows[1][2]);
224+
225+
AreEqual(LargeText, rows[2][0]);
226+
AreEqual(LargeNText, rows[2][1]);
227+
CollectionAssert.AreEqual(MakeBytes(20_000), (byte[])rows[2][2]!);
228+
}
229+
230+
private static byte[] MakeBytes(int count)
231+
{
232+
var bytes = new byte[count];
233+
for (var i = 0; i < count; i++)
234+
bytes[i] = (byte)(i & 0xFF);
235+
236+
return bytes;
237+
}
238+
}

0 commit comments

Comments
 (0)