|
| 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