Skip to content

Commit e341378

Browse files
committed
CLR-UDT (0xF0) and sql_variant (0x62) RPC parameters decode over the TDS wire: the UDT TYPE_INFO is three bare B_VARCHARs with a PLP value blob routed to the existing spatial/OrdPath representations, sql_variant reads the exact mirror of the writer's variant body across 15 probed base types with SQL_VARIANT_PROPERTY parity, hierarchyid bytes accepted verbatim, leaving legacy image as the only rejected parameter TYPE_INFO, with output-direction UDT/variant params rejected cleanly as a documented residual.
1 parent cc3a5e6 commit e341378

12 files changed

Lines changed: 592 additions & 17 deletions

File tree

SqlServerSimulator.Tests.SqlClient/SqlServerSimulator.Tests.SqlClient.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<PrivateAssets>all</PrivateAssets>
2121
</PackageReference>
2222
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
23+
<PackageReference Include="Microsoft.SqlServer.Types" Version="160.1000.6" />
2324
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
2425
<PackageReference Include="MSTest.TestAdapter" Version="4.1.0" />
2526
<PackageReference Include="MSTest.TestFramework" Version="4.1.0" />
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using System.Data;
2+
using System.Data.SqlTypes;
3+
using Microsoft.Data.SqlClient;
4+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
5+
6+
namespace SqlServerSimulator;
7+
8+
/// <summary>
9+
/// <c>sql_variant</c> RPC parameters (TDS type token 0x62): the value is a
10+
/// 4-byte total length (0 = NULL) then the MS-TDS §2.2.5.5.3 body — a base-type
11+
/// token, a property-byte count, the property bytes, then the inner value —
12+
/// decoded into the matching inner <see cref="Storage.SqlValue"/> and wrapped so
13+
/// <c>SQL_VARIANT_PROPERTY(@p,'BaseType')</c> reports the base type SqlClient
14+
/// chose. Per-base-type layouts + the base-type SqlClient assigns each CLR value
15+
/// probe-confirmed against SQL Server 2025 + SqlClient 7.0.2 (2026-07-19):
16+
/// strings (both <c>string</c> and <c>SqlString</c>) promote to <c>nvarchar</c>.
17+
/// </summary>
18+
[TestClass]
19+
public sealed class SqlVariantRpcParameterTests
20+
{
21+
public TestContext TestContext { get; set; } = null!;
22+
23+
[TestMethod]
24+
public async Task VariantParameter_PerBaseType_RoundTripsWithBaseType()
25+
{
26+
var simulation = new Simulation();
27+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
28+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
29+
30+
await RoundTrip(connection, 42, "int", 42);
31+
await RoundTrip(connection, 42L, "bigint", 42L);
32+
await RoundTrip(connection, (short)42, "smallint", (short)42);
33+
await RoundTrip(connection, (byte)42, "tinyint", (byte)42);
34+
await RoundTrip(connection, true, "bit", true);
35+
await RoundTrip(connection, 123.45m, "numeric", 123.45m);
36+
await RoundTrip(connection, 3.14d, "float", 3.14d);
37+
await RoundTrip(connection, 3.5f, "real", 3.5f);
38+
await RoundTrip(connection, "hello", "nvarchar", "hello");
39+
await RoundTrip(connection, new SqlString("ansi"), "nvarchar", "ansi");
40+
await RoundTrip(connection, new DateTime(2020, 1, 2, 3, 4, 5, 123), "datetime", new DateTime(2020, 1, 2, 3, 4, 5, 123));
41+
await RoundTrip(connection, Guid.Parse("11111111-2222-3333-4444-555555555555"), "uniqueidentifier", Guid.Parse("11111111-2222-3333-4444-555555555555"));
42+
await RoundTrip(connection, new SqlMoney(12.34m), "money", 12.34m);
43+
await RoundTrip(connection, new byte[] { 1, 2, 3, 4 }, "varbinary", new byte[] { 1, 2, 3, 4 });
44+
await RoundTrip(connection, new TimeSpan(1, 2, 3), "time", new TimeSpan(1, 2, 3));
45+
}
46+
47+
[TestMethod]
48+
public async Task VariantParameter_Null_ReadsAsNull()
49+
{
50+
var simulation = new Simulation();
51+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
52+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
53+
await using var command = new SqlCommand("select @p, SQL_VARIANT_PROPERTY(@p, 'BaseType')", connection);
54+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Variant) { Value = DBNull.Value });
55+
56+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
57+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
58+
IsTrue(await reader.IsDBNullAsync(0, TestContext.CancellationToken));
59+
IsTrue(await reader.IsDBNullAsync(1, TestContext.CancellationToken));
60+
}
61+
62+
[TestMethod]
63+
public async Task VariantParameter_DecimalPrecisionScale_SurfaceViaProperty()
64+
{
65+
var simulation = new Simulation();
66+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
67+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
68+
await using var command = new SqlCommand(
69+
"select SQL_VARIANT_PROPERTY(@p, 'Precision'), SQL_VARIANT_PROPERTY(@p, 'Scale')", connection);
70+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Variant) { Value = 123.45m });
71+
72+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
73+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
74+
// SqlClient sends a variant decimal as numeric(38, 2).
75+
AreEqual(38, Convert.ToInt32(reader.GetValue(0)));
76+
AreEqual(2, Convert.ToInt32(reader.GetValue(1)));
77+
}
78+
79+
[TestMethod]
80+
public async Task VariantParameter_DirectProcedureCall_Binds()
81+
{
82+
var simulation = new Simulation();
83+
Wire.ExecInProc(simulation, "create procedure dbo.EchoVariantType @v sql_variant as select SQL_VARIANT_PROPERTY(@v, 'BaseType')");
84+
85+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
86+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
87+
await using var command = new SqlCommand("dbo.EchoVariantType", connection) { CommandType = CommandType.StoredProcedure };
88+
_ = command.Parameters.Add(new SqlParameter("@v", SqlDbType.Variant) { Value = 99 });
89+
90+
AreEqual("int", await command.ExecuteScalarAsync(TestContext.CancellationToken));
91+
}
92+
93+
/// <summary>
94+
/// Output-direction UDT / sql_variant parameters are a documented residual:
95+
/// the RETURNVALUE writeback for these types is unmodeled and rejected up
96+
/// front with a clear error rather than a malformed token that would desync
97+
/// the client (real SQL Server supports them).
98+
/// </summary>
99+
[TestMethod]
100+
public async Task VariantParameter_OutputDirection_RaisesResidualError()
101+
{
102+
var simulation = new Simulation();
103+
Wire.ExecInProc(simulation, "create procedure dbo.SetVariant @v sql_variant output as set @v = cast(5 as int)");
104+
105+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
106+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
107+
await using var command = new SqlCommand("dbo.SetVariant", connection) { CommandType = CommandType.StoredProcedure };
108+
_ = command.Parameters.Add(new SqlParameter("@v", SqlDbType.Variant) { Direction = ParameterDirection.Output, Value = DBNull.Value });
109+
110+
var ex = await ThrowsExactlyAsync<SqlException>(async () => await command.ExecuteNonQueryAsync(TestContext.CancellationToken));
111+
Contains("output CLR UDT / sql_variant", ex.Message);
112+
}
113+
114+
private async Task RoundTrip(SqlConnection connection, object value, string expectedBaseType, object expectedValue)
115+
{
116+
await using var command = new SqlCommand("select @p, SQL_VARIANT_PROPERTY(@p, 'BaseType')", connection);
117+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Variant) { Value = value });
118+
119+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
120+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
121+
Wire.AssertValueEqual(expectedValue, reader.GetValue(0));
122+
AreEqual(expectedBaseType, reader.GetString(1));
123+
}
124+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System.Data;
2+
using System.Data.SqlTypes;
3+
using Microsoft.Data.SqlClient;
4+
using Microsoft.SqlServer.Types;
5+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
6+
7+
namespace SqlServerSimulator;
8+
9+
/// <summary>
10+
/// CLR-UDT RPC parameters (TDS type token 0xF0): <c>geography</c> /
11+
/// <c>geometry</c> / <c>hierarchyid</c> arrive as a three-B_VARCHAR UDT_INFO
12+
/// (db / schema / type, only the type filled) plus a PLP value — OrdPath bytes
13+
/// for hierarchyid (stored verbatim) or the MS spatial binary for the spatial
14+
/// types (decoded back to WKT via <c>SpatialWkbDecoder</c>). SqlClient serializes
15+
/// a typed <c>SqlGeography</c> / <c>SqlHierarchyId</c> value or a raw
16+
/// <c>byte[]</c> to the same wire form; both are exercised. Semantics and error
17+
/// numbers probe-confirmed against SQL Server 2025 + SqlClient 7.0.2
18+
/// (2026-07-19). <c>SqlGeometry.STGeomFromText</c> is not implemented on Linux
19+
/// (managed <c>Microsoft.SqlServer.Types</c>), so geometry is constructed from
20+
/// raw WKB bytes — the shape DacFx / bulk-import consumers send.
21+
/// </summary>
22+
[TestClass]
23+
public sealed class UdtRpcParameterTests
24+
{
25+
public TestContext TestContext { get; set; } = null!;
26+
27+
// geometry POINT(3 4): SRID 0 + version 1 + props 0x0C (isValid|isSinglePoint)
28+
// + x=3.0 + y=4.0, the 22-byte single-point MS spatial binary.
29+
private const string GeometryPointWkbHex = "00000000010C00000000000008400000000000001040";
30+
31+
// hierarchyid '/1/2/' canonical OrdPath bytes.
32+
private static readonly byte[] Node1_2 = [0x5B, 0x40];
33+
34+
[TestMethod]
35+
public async Task GeographyParameter_TypedValue_RoundTripsToWkt()
36+
{
37+
var simulation = new Simulation();
38+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
39+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
40+
await using var command = new SqlCommand("select @p.ToString()", connection);
41+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt)
42+
{
43+
UdtTypeName = "geography",
44+
Value = SqlGeography.STGeomFromText(new SqlChars("POINT(-122.3 47.6)"), 4326),
45+
});
46+
47+
AreEqual("POINT (-122.3 47.6)", await command.ExecuteScalarAsync(TestContext.CancellationToken));
48+
}
49+
50+
[TestMethod]
51+
public async Task GeographyParameter_Null_ReadsAsNull()
52+
{
53+
var simulation = new Simulation();
54+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
55+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
56+
await using var command = new SqlCommand("select @p.ToString()", connection);
57+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt) { UdtTypeName = "geography", Value = DBNull.Value });
58+
59+
_ = IsInstanceOfType<DBNull>(await command.ExecuteScalarAsync(TestContext.CancellationToken));
60+
}
61+
62+
[TestMethod]
63+
public async Task GeometryParameter_RawWkbBytes_RoundTripsToWkt()
64+
{
65+
var simulation = new Simulation();
66+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
67+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
68+
await using var command = new SqlCommand("select @p.ToString()", connection);
69+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt)
70+
{
71+
UdtTypeName = "geometry",
72+
Value = Convert.FromHexString(GeometryPointWkbHex),
73+
});
74+
75+
AreEqual("POINT (3 4)", await command.ExecuteScalarAsync(TestContext.CancellationToken));
76+
}
77+
78+
[TestMethod]
79+
public async Task HierarchyIdParameter_TypedValue_RoundTripsToPathAndDataLength()
80+
{
81+
var simulation = new Simulation();
82+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
83+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
84+
await using var command = new SqlCommand("select @p.ToString(), datalength(@p)", connection);
85+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt)
86+
{
87+
UdtTypeName = "hierarchyid",
88+
Value = SqlHierarchyId.Parse(new SqlString("/1/2/")),
89+
});
90+
91+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
92+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
93+
AreEqual("/1/2/", reader.GetString(0));
94+
AreEqual(2, reader.GetInt32(1));
95+
}
96+
97+
[TestMethod]
98+
public async Task HierarchyIdParameter_RawOrdPathBytes_RoundTripsToPath()
99+
{
100+
var simulation = new Simulation();
101+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
102+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
103+
await using var command = new SqlCommand("select @p.ToString()", connection);
104+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt) { UdtTypeName = "hierarchyid", Value = Node1_2 });
105+
106+
AreEqual("/1/2/", await command.ExecuteScalarAsync(TestContext.CancellationToken));
107+
}
108+
109+
[TestMethod]
110+
public async Task HierarchyIdParameter_Null_ReadsAsNull()
111+
{
112+
var simulation = new Simulation();
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 @p.ToString()", connection);
116+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt) { UdtTypeName = "hierarchyid", Value = DBNull.Value });
117+
118+
_ = IsInstanceOfType<DBNull>(await command.ExecuteScalarAsync(TestContext.CancellationToken));
119+
}
120+
121+
[TestMethod]
122+
public async Task UnknownUdtTypeName_RaisesMsg8064()
123+
{
124+
var simulation = new Simulation();
125+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
126+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
127+
await using var command = new SqlCommand("select @p", connection);
128+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt) { UdtTypeName = "nosuchtype", Value = new byte[] { 1, 2 } });
129+
130+
var ex = await ThrowsExactlyAsync<SqlException>(async () => await command.ExecuteScalarAsync(TestContext.CancellationToken));
131+
AreEqual(8064, ex.Number);
132+
// db segment resolves to the current database; schema stays empty.
133+
Contains("[simulated].[].[nosuchtype]", ex.Message);
134+
Contains("The CLR type does not exist", ex.Message);
135+
}
136+
137+
[TestMethod]
138+
public async Task InvalidGeographyBytes_RaisesMsg8023()
139+
{
140+
var simulation = new Simulation();
141+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
142+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
143+
await using var command = new SqlCommand("select @p.ToString()", connection);
144+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Udt) { UdtTypeName = "geography", Value = new byte[] { 0, 1, 2, 3 } });
145+
146+
var ex = await ThrowsExactlyAsync<SqlException>(async () => await command.ExecuteScalarAsync(TestContext.CancellationToken));
147+
AreEqual(8023, ex.Number);
148+
Contains("not a valid instance of data type geography", ex.Message);
149+
}
150+
151+
[TestMethod]
152+
public async Task HierarchyIdParameter_DirectProcedureCall_Binds()
153+
{
154+
var simulation = new Simulation();
155+
Wire.ExecInProc(simulation, "create procedure dbo.EchoNode @h hierarchyid as select @h.ToString()");
156+
157+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
158+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
159+
await using var command = new SqlCommand("dbo.EchoNode", connection) { CommandType = CommandType.StoredProcedure };
160+
_ = command.Parameters.Add(new SqlParameter("@h", SqlDbType.Udt)
161+
{
162+
UdtTypeName = "hierarchyid",
163+
Value = SqlHierarchyId.Parse(new SqlString("/3/4/")),
164+
});
165+
166+
AreEqual("/3/4/", await command.ExecuteScalarAsync(TestContext.CancellationToken));
167+
}
168+
}

SqlServerSimulator/Errors/SimulatedSqlException.TypeErrors.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,26 @@ internal static SimulatedSqlException TranslateUnequalChars() =>
530530
internal static SimulatedSqlException ParseConversionFailed(string value, string targetTypeName, string cultureName) =>
531531
new($"Error converting string value '{value}' into data type {targetTypeName} using culture '{cultureName}'.", 9819, 16, 1);
532532

533+
/// <summary>
534+
/// Mimics SQL Server error 8064: an RPC parameter declares a CLR-UDT type
535+
/// name (TDS type token <c>0xF0</c>) that resolves to no known type. Real
536+
/// substitutes the current database for the client's empty db segment and
537+
/// leaves the schema segment empty. Probe-confirmed against SQL Server 2025
538+
/// (2026-07-19).
539+
/// </summary>
540+
internal static SimulatedSqlException RpcClrTypeDoesNotExist(int ordinal, string database, string typeName) =>
541+
new($"Parameter {ordinal} ([{database}].[].[{typeName}]): The CLR type does not exist or you do not have permissions to access it.", 8064, 16, 1);
542+
543+
/// <summary>
544+
/// Mimics SQL Server error 8023: an RPC CLR-UDT parameter's serialized bytes
545+
/// are not a valid (modeled) instance of the declared spatial type — the
546+
/// simulator's WKB decoder covers a 2D shape subset, so bytes outside it (or
547+
/// genuinely corrupt bytes) surface here as real's bind-time rejection.
548+
/// Probe-confirmed against SQL Server 2025 (2026-07-19).
549+
/// </summary>
550+
internal static SimulatedSqlException RpcInvalidUdtInstance(int ordinal, string parameterName, string typeName) =>
551+
new($"The incoming tabular data stream (TDS) remote procedure call (RPC) protocol stream is incorrect. Parameter {ordinal} (\"{parameterName}\"): The supplied value is not a valid instance of data type {typeName}. Check the source data for invalid values. An example of an invalid value is data of numeric type with scale greater than precision.", 8023, 16, 4);
552+
533553
/// <summary>
534554
/// Returns the type name SQL Server uses in Msg 402 / 206 / 529 for a
535555
/// date/time type: the family root (e.g. <c>datetime2</c>,

0 commit comments

Comments
 (0)