Skip to content

Commit 2f8aa43

Browse files
committed
Output-direction sql_variant and CLR-UDT RPC parameters write back over TDS: end-of-batch write-back stamps the engine-typed SqlValue on an internal SimulatedDbParameter side-channel and the RETURNVALUE writer reuses the column TYPE_INFO/value writers.
1 parent ac187f1 commit 2f8aa43

10 files changed

Lines changed: 205 additions & 44 deletions

File tree

docs/claude/backlog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ Remaining phases, roughly in value order:
4242
**`text`/`ntext`/`image` ship fully** (2026-07-19) — result columns, `image` (0x22) input parameters, and **`SqlBulkCopy` into a legacy-LOB column** (2026-07-19: the BCP LONGLEN COLMETADATA + in-band text-pointer ROW value now decode); see [`tds-endpoint.md`](tds-endpoint.md#legacy-text--ntext--image-wire-forms).
4343
**`sql_variant` and UDT (`geography`/`geometry`/`hierarchyid`) ship as RPC *input* parameters AND as *columns inside a TVP*** (2026-07-19 — the shared decoder made the TVP columns near-free; see [`tds-endpoint.md`](tds-endpoint.md#tvp-parameters-sqldbtypestructured)).
4444
**TVP parameters (0xF3, `SqlDbType.Structured`) ship**`DataTable` / `IEnumerable<SqlDataRecord>` / `DbDataReader` sources over proc-RPC + sp_executesql, error parity for arity / type / NOT NULL / CHECK / PK / UNIQUE / unknown-type.
45-
Residuals: **output-direction** UDT / `sql_variant` params; **`SET TEXTSIZE`** doesn't truncate returned LOB data (parse-and-discard).
45+
Residuals: **`SET TEXTSIZE`** doesn't truncate returned LOB data (parse-and-discard).
46+
(Output-direction UDT / `sql_variant` params shipped 2026-07-19 — probed RETURNVALUE wire forms in [`tds-endpoint.md`](tds-endpoint.md#clr-udt--sql_variant-parameters).)
4647
(The LOB-backed TVP column → stored-proc READONLY parameter dangling-pointer bug was fixed 2026-07-19: the proc-parameter copy re-homes off-row values into the parameter's heap; see [`table-valued-parameters.md`](table-valued-parameters.md).)
4748
- **Smaller fidelity items**: TDS 8.0 / `Encrypt=Strict` (ALPN via `SslApplicationProtocol`); user-supplied `X509Certificate2` — an add-on-demand public-surface expansion.
4849
(Off-loopback binding shipped 2026-07-19 as `ListenNetworkAsync`, gated on a registered login; per-interface bind-address selection remains add-on-demand.)

docs/claude/tds-endpoint.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,13 @@ Probed against SQL Server 2025 + SqlClient 7.0.2 (2026-07-19).
276276
- **`sql_variant` (`0x62`)** — TYPE_INFO is a 4-byte max length (ignored); the value is a 4-byte total length (**0 = NULL**) then the MS-TDS §2.2.5.5.3 body (base-type token + property-byte count + property bytes + inner value), the read mirror of `TdsTypeCodec.BuildVariantBody`, decoded into the inner `SqlValue` and wrapped by `SqlValue.FromVariant` so `SQL_VARIANT_PROPERTY(@p,'BaseType')` reports the sent type.
277277
SqlClient's per-CLR-type base-type choices (probe-confirmed): `int`/`bigint`/`smallint`/`tinyint`/`bit` direct; `decimal``numeric(38, 2)` (base token `0x6C`); `float`/`real` direct; **both `string` and `SqlString``nvarchar`** (varchar is not sent as a variant); `DateTime``datetime`; `Guid``uniqueidentifier`; `SqlMoney``money`; `byte[]``varbinary`; `TimeSpan``time(7)`.
278278
(SqlClient 7.0.2 mis-serializes a `DateOnly` variant — the stream truncates client-side before the server sees it; not a server concern.)
279-
- **Residuals**: **output-direction** UDT / `sql_variant` parameters (real supports both) are unmodeled — the RETURNVALUE writeback for these types is rejected up front (`TdsTypeCodec.WriteReturnValue`, keyed off the `DbType.Object` marker) with a clear ERROR rather than a malformed RETURNVALUE token that desyncs the client.
280-
(**TVP columns** of UDT / `sql_variant` type now decode — the RPC decoders and the column decoder share `TdsWireValue`; see [TVP parameters](#tvp-parameters-sqldbtypestructured).)
279+
- **Output direction ships** (probe-captured RETURNVALUE shapes, SQL Server 2025 + SqlClient 7.0.2, 2026-07-19): a `sql_variant` output parameter's RETURNVALUE TYPE_INFO is `0x62` + ULONG max length (8009) with the same 4-byte-total-length + self-describing-body value a variant result column carries; a CLR-UDT output parameter's TYPE_INFO is the **COLMETADATA-shaped** UDT_INFO (USHORT max byte size — 892 for `hierarchyid`, `0xFFFF` for spatial — then db / schema / type B_VARCHARs and the US_VARCHAR assembly-qualified name; richer than the three-B_VARCHAR client request form) with a PLP value, PLP NULL for NULL.
280+
Real fills the db segment with the current database; the simulator reuses the column writers verbatim (empty db segment — SqlClient reads both).
281+
The engine value reaches the writer via `SimulatedDbParameter.OutputSqlValue`, an internal side-channel stamped at end-of-batch write-back alongside the CLR `Value` conversion, because the CLR object alone no longer carries the variant inner type / UDT kind; `TdsSession.Rpc.WriteOutputReturnValues` routes `DbType.Object`-marked outputs through the `SqlValue` overload of `TdsTypeCodec.WriteReturnValue`.
282+
**Variant decimal bodies are always 1 sign byte + a 16-byte magnitude** regardless of precision (probe-captured in both a result column and a RETURNVALUE): SqlClient's row-value reader tolerates a narrower magnitude but its RETURNVALUE reader reads the fixed 17 data bytes and desyncs on anything shorter — `BuildVariantDecimal` writes the fixed form.
283+
(**TVP columns** of UDT / `sql_variant` type also decode — the RPC decoders and the column decoder share `TdsWireValue`; see [TVP parameters](#tvp-parameters-sqldbtypestructured).)
281284
A `sql_variant` carrying an inner value implicitly converted to a narrower sink (e.g. `insert … (int_col) values (@variant_int)`) is accepted by the simulator's engine-level variant coercion where **real raises Msg 257** — a pre-existing in-process `sql_variant` divergence surfaced (not introduced) by wire parameters.
282-
Oracles: `UdtRpcParameterTests`, `SqlVariantRpcParameterTests` (Tests.SqlClient), using the real `Microsoft.SqlServer.Types` package for typed `SqlGeography` / `SqlHierarchyId` construction (works headless on Linux; `SqlGeometry.STGeomFromText` does **not** — geometry is built from raw WKB bytes).
285+
Oracles: `UdtRpcParameterTests`, `SqlVariantRpcParameterTests`, `SqlVariantWireTests` (Tests.SqlClient), using the real `Microsoft.SqlServer.Types` package for typed `SqlGeography` / `SqlHierarchyId` construction (works headless on Linux; `SqlGeometry.STGeomFromText` does **not** — geometry is built from raw WKB bytes).
283286

284287
## Transaction Manager requests
285288

@@ -385,8 +388,7 @@ A real build number is load-bearing for SSMS's per-build client feature gates (A
385388
- Login INFO states are approximations.
386389
- MARS ships (see [MARS](#mars-multiple-active-result-sets) below); no TDS 8.0 / `Encrypt=Strict`, no plaintext sessions, no integrated auth (an SSPI/FedAuth login presents an empty SQL username, which under a non-empty registry fails as Msg 18456 rather than negotiating).
387390
- Credential-enforcement edges not modeled: `ALTER LOGIN … DISABLE` parses but doesn't block login; password policy (`CHECK_POLICY` / expiration / lockout) never enforced; no login auditing.
388-
- RPC parameter gaps: **output-direction** UDT / `sql_variant` parameters rejected (input decode ships — see [CLR-UDT / sql_variant parameters](#clr-udt--sql_variant-parameters)).
389-
Every input-parameter TYPE_INFO is accepted — TVP / UDT / `sql_variant` / `text` / `ntext` / `image` — and every client value-stream column type (bulk / TVP) now decodes through the shared `TdsWireValue` / `TdsColumnDecoder`, including `sql_variant` / CLR-UDT columns and legacy-LOB bulk destinations.
391+
- RPC parameters are gap-free in both directions: every input TYPE_INFO is accepted — TVP / UDT / `sql_variant` / `text` / `ntext` / `image` — every client value-stream column type (bulk / TVP) decodes through the shared `TdsWireValue` / `TdsColumnDecoder`, and output-direction UDT / `sql_variant` parameters write back as RETURNVALUE tokens (see [CLR-UDT / sql_variant parameters](#clr-udt--sql_variant-parameters)).
390392
Non-cursor well-known ProcIDs beyond the sp_execute/sp_prepare family are rejected with ERROR 50000 naming the id.
391393
- Mid-stream attention (cancel) ships — see below.
392394
Residual: cancel reaction is bounded by the current statement's *materialization*, not just its streaming, because a statement's rows are materialized in one synchronous step before they stream (a cancel mid-way through a compute-heavy `SELECT`/sort waits for that materialization to finish, then discards the result at the outcome boundary; real streams-as-it-computes and stops sooner).

src/SqlServerSimulator/Network/CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ These notes are the local implementation contracts.
5858
COLMETADATA = type byte + 4-byte max length (8009).
5959
Each value = 4-byte total length then the MS-TDS 2.2.5.5.3 body (base-type token + cbProps + props + data).
6060
**NULL is total-length 0, not 0xFFFFFFFF** — a non-NULL variant is always ≥2 bytes, so SqlClient reads 0 as NULL (the one non-obvious wire rule; got this wrong first and it desynced the stream).
61-
`sql_variant` **input** *parameters* decode (RPC read side, `TdsRpc.DecodeSqlVariant``TdsWireValue.ReadVariantBody` — the read mirror of `BuildVariantBody`, into `SqlValue.FromVariant`; **output** direction stays rejected), and **`sql_variant` columns inside a TVP** decode through the same body reader (`TdsColumnDecoder`: 0x62 + 4-byte max length, value = 4-byte total length `0`=NULL + body).
61+
`sql_variant` **input** *parameters* decode (RPC read side, `TdsRpc.DecodeSqlVariant``TdsWireValue.ReadVariantBody` — the read mirror of `BuildVariantBody`, into `SqlValue.FromVariant`), **output** direction writes back as a RETURNVALUE (`0x62` + ULONG maxlen 8009 + the column-form value; the engine value rides `SimulatedDbParameter.OutputSqlValue`, stamped at write-back, since the CLR `Value` loses the inner type), and **`sql_variant` columns inside a TVP** decode through the same body reader (`TdsColumnDecoder`: 0x62 + 4-byte max length, value = 4-byte total length `0`=NULL + body).
62+
Variant decimal bodies are always sign + 16-byte magnitude regardless of precision (probed in column and RETURNVALUE positions; SqlClient's RETURNVALUE reader hard-reads 17 data bytes).
6263
Oracles: `SqlVariantWireTests`, `SqlVariantRpcParameterTests`, `TvpVariantUdtColumnTests`.
6364
- **`geography` / `geometry` (UDTTYPE `0xF0`)**: result-column-only.
6465
COLMETADATA = ushort max-byte-size (`0xFFFF`) + three B_VARCHAR names (db empty — the static codec can't reach the session db; schema `sys`; type name) + US_VARCHAR assembly-qualified name (`SpatialAssemblyQualifiedName`, probe-matched).
@@ -69,7 +70,7 @@ These notes are the local implementation contracts.
6970
Oracle: `HierarchyIdWireTests`, `HierarchyIdOrdPathTests`.
7071
UDT **input** *parameters* decode (`TdsRpc.DecodeClrUdt``TdsWireValue.BuildUdtValue`): the client UDT_INFO is three B_VARCHARs (db/schema/type, no max-size, no AQN — shorter than COLMETADATA) + PLP value; hierarchyid OrdPath bytes bind verbatim, spatial WKB decodes via `SpatialWkbDecoder.TryDecode`, resolved case-insensitively into a pre-built `SqlValue` (unknown type → Msg 8064, invalid spatial bytes → Msg 8023).
7172
**UDT columns inside a TVP** decode through the same builder (`TdsColumnDecoder`: 0xF0 + three B_VARCHARs + PLP value).
72-
**Output** direction stays rejected.
73+
**Output** direction writes back as a RETURNVALUE carrying the **COLMETADATA-shaped** UDT_INFO (max byte size + db/schema/type + AQN — richer than the client request form) + PLP value, from `SimulatedDbParameter.OutputSqlValue`.
7374
A LOB-backed UDT TVP column (`geography`/`geometry`) round-trips via both the `sp_executesql` text path and a stored-proc READONLY parameter (the proc-parameter copy re-homes off-row values — docs/claude/table-valued-parameters.md).
7475
Oracles: `UdtRpcParameterTests`, `TvpVariantUdtColumnTests`.
7576
- **Length-prefix trap**: most strings are char-counted (B_VARCHAR / US_VARCHAR), but TM-request transaction names are byte-counted — misreading them as char-counted overruns the payload on every `SqlTransaction.Save`.

src/SqlServerSimulator/Network/TdsSession.Rpc.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Data;
22
using System.Globalization;
3+
using SqlServerSimulator.Storage;
34

45
namespace SqlServerSimulator.Network;
56

@@ -201,8 +202,7 @@ private async ValueTask ExecuteStatementRpcAsync(
201202
if (handleReturn is { } handleValue)
202203
TdsTypeCodec.WriteReturnValue(writer, 0, handleValue.Name, DbType.Int32, handleValue.Handle);
203204

204-
foreach (var (ordinal, wire, bound) in outputs)
205-
TdsTypeCodec.WriteReturnValue(writer, checked((ushort)ordinal), wire.Name, wire.DbType, bound.Value);
205+
WriteOutputReturnValues(writer, outputs);
206206

207207
if (!moreRequests)
208208
this.WriteDatabaseChangeIfAny(writer);
@@ -238,8 +238,7 @@ private async ValueTask ExecuteProcedureRpcAsync(TdsRpcRequest request, TdsToken
238238
return;
239239

240240
writer.WriteReturnStatus(returnParameter.Value is int returnCode ? returnCode : 0);
241-
foreach (var (ordinal, wire, bound) in outputs)
242-
TdsTypeCodec.WriteReturnValue(writer, checked((ushort)ordinal), wire.Name, wire.DbType, bound.Value);
241+
WriteOutputReturnValues(writer, outputs);
243242

244243
if (!moreRequests)
245244
this.WriteDatabaseChangeIfAny(writer);
@@ -269,6 +268,26 @@ private static ushort WellKnownProcId(string procName) =>
269268
["sp_cursorclose"] = Tds.ProcIdCursorClose,
270269
};
271270

271+
/// <summary>
272+
/// RETURNVALUE tokens for a request's output-direction parameters.
273+
/// <see cref="DbType.Object"/> marks a <c>sql_variant</c> / CLR-UDT
274+
/// parameter (its wire value rode a pre-built <see cref="SqlValue"/>, not
275+
/// a <see cref="DbType"/>): those write from the engine-typed
276+
/// <see cref="SimulatedDbParameter.OutputSqlValue"/> stamped at
277+
/// end-of-batch write-back, falling back to echoing the decoded input
278+
/// value when the batch never reached write-back.
279+
/// </summary>
280+
private static void WriteOutputReturnValues(TdsTokenWriter writer, List<(int Ordinal, TdsRpcParameter Wire, SimulatedDbParameter Bound)> outputs)
281+
{
282+
foreach (var (ordinal, wire, bound) in outputs)
283+
{
284+
if (wire.DbType == DbType.Object)
285+
TdsTypeCodec.WriteReturnValue(writer, checked((ushort)ordinal), wire.Name, bound.OutputSqlValue ?? (SqlValue)wire.Value!);
286+
else
287+
TdsTypeCodec.WriteReturnValue(writer, checked((ushort)ordinal), wire.Name, wire.DbType, bound.Value);
288+
}
289+
}
290+
272291
private static SimulatedDbParameter AddParameter(SimulatedDbCommand command, TdsRpcParameter wire)
273292
{
274293
var parameter = command.CreateParameter();

src/SqlServerSimulator/Network/TdsTypeCodec.cs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,26 @@ private static void WriteRawFixedValue(TdsTokenWriter writer, SqlType type, SqlV
162162
/// </summary>
163163
public static void WriteReturnValue(TdsTokenWriter writer, ushort ordinal, string name, DbType dbType, object? value)
164164
{
165-
// DbType.Object is the RPC read side's marker for a CLR-UDT / sql_variant
166-
// parameter (its value rode a pre-built SqlValue, not a DbType). Writing
167-
// one back as a RETURNVALUE is unmodeled; reject up front — before any
168-
// token bytes — rather than emit a malformed RETURNVALUE that desyncs the
169-
// client (SqlClient surfaces the half-token as ArgumentOutOfRangeException).
170-
if (dbType == DbType.Object)
171-
throw new NotSupportedException("output CLR UDT / sql_variant RPC parameters");
165+
var declared = SqlType.GetByDbType(dbType);
166+
var sqlValue = value is null or DBNull ? SqlValue.Null(declared) : declared.ConvertParameter(value);
167+
WriteReturnValue(writer, ordinal, name, sqlValue.IsNull ? declared : sqlValue.Type, sqlValue);
168+
}
169+
170+
/// <summary>
171+
/// RETURNVALUE token from an engine-typed <see cref="SqlValue"/> — the
172+
/// path for <c>sql_variant</c> and CLR-UDT (<c>hierarchyid</c> /
173+
/// <c>geography</c> / <c>geometry</c>) output parameters, whose wire form
174+
/// (self-describing variant body; UDT_INFO + PLP value) needs the value's
175+
/// own type identity rather than a <see cref="DbType"/> mapping.
176+
/// Probe-captured against SQL Server 2025 (2026-07-19): the RETURNVALUE
177+
/// TYPE_INFO and value bytes are the same forms the matching result
178+
/// columns carry, so the column writers are reused as-is.
179+
/// </summary>
180+
public static void WriteReturnValue(TdsTokenWriter writer, ushort ordinal, string name, SqlValue value)
181+
=> WriteReturnValue(writer, ordinal, name, value.Type, value);
172182

183+
private static void WriteReturnValue(TdsTokenWriter writer, ushort ordinal, string name, SqlType wireType, SqlValue sqlValue)
184+
{
173185
writer.EnterComposite();
174186
writer.WriteByte(Tds.TokenReturnValue);
175187
writer.WriteUInt16(ordinal);
@@ -179,9 +191,6 @@ public static void WriteReturnValue(TdsTokenWriter writer, ushort ordinal, strin
179191
writer.WriteByte(0x09);
180192
writer.WriteByte(0);
181193

182-
var declared = SqlType.GetByDbType(dbType);
183-
var sqlValue = value is null or DBNull ? SqlValue.Null(declared) : declared.ConvertParameter(value);
184-
var wireType = sqlValue.IsNull ? declared : sqlValue.Type;
185194
WriteTypeInfo(writer, wireType);
186195
WriteValue(writer, wireType, sqlValue);
187196
writer.LeaveComposite();
@@ -829,7 +838,13 @@ private static byte[] BuildVariantBinary(byte typeToken, SqlValue inner)
829838

830839
private static byte[] BuildVariantDecimal(DecimalSqlType type, SqlValue inner)
831840
{
832-
var magnitudeBytes = MagnitudeBytes(type.precision);
841+
// Server-sent variant decimal data is always 1 sign byte + a 16-byte
842+
// magnitude regardless of precision (probe-captured 2026-07-19 in both
843+
// a result column and a RETURNVALUE; RPC *request* decimals are the
844+
// precision-width exception, on the decode side). SqlClient's row
845+
// reader tolerates a narrower magnitude but its RETURNVALUE reader
846+
// reads the fixed 17 and desyncs on anything shorter.
847+
const int magnitudeBytes = 16;
833848
// 2 prop bytes (precision, scale); data = 1 sign byte + magnitude.
834849
var body = new byte[2 + 2 + 1 + magnitudeBytes];
835850
body[0] = 0x6A;

src/SqlServerSimulator/SimulatedDbParameter.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Data;
22
using System.Data.Common;
33
using System.Diagnostics.CodeAnalysis;
4+
using SqlServerSimulator.Storage;
45

56
[module: SuppressMessage("Microsoft.Design", "CA1065:DoNotRaiseExceptionsInUnexpectedLocations", Scope = "member", Target = "~P:SqlServerSimulator.SimulatedDbParameter.DbType", Justification = "Mirrors SqlParameter inference: a CLR Value with no DbType mapping is surfaced as ArgumentException at property read, not later at command execution.")]
67

@@ -37,6 +38,16 @@ public sealed class SimulatedDbParameter : DbParameter
3738
{
3839
private DbType? dbType;
3940

41+
/// <summary>
42+
/// The typed engine value written back to an output-direction parameter
43+
/// at end of batch, alongside the CLR conversion stored in
44+
/// <see cref="Value"/>. The TDS listener's RETURNVALUE writer reads it
45+
/// for <c>sql_variant</c> / CLR-UDT parameters, whose wire form needs
46+
/// the inner type identity (variant base type, UDT kind) that the CLR
47+
/// object no longer carries.
48+
/// </summary>
49+
internal SqlValue? OutputSqlValue;
50+
4051
/// <inheritdoc/>
4152
public override DbType DbType
4253
{

src/SqlServerSimulator/Simulation/Simulation.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -994,7 +994,11 @@ private IEnumerable<SimulatedStatementOutcome> InvokeFromCommandTypeStoredProced
994994
{
995995
var pname = parameter.ParameterName.StartsWith('@') ? parameter.ParameterName[1..] : parameter.ParameterName;
996996
if (batch.Variables.TryGetValue(pname, out var slot))
997+
{
998+
if (parameter is SimulatedDbParameter simulated)
999+
simulated.OutputSqlValue = slot.Value;
9971000
parameter.Value = slot.Value.IsNull ? DBNull.Value : slot.Value.ToObject();
1001+
}
9981002
}
9991003
}
10001004

@@ -1896,9 +1900,10 @@ private static void WriteBackOutputParameters(BatchContext batch)
18961900
{
18971901
foreach (var slot in batch.Variables.Values)
18981902
{
1899-
if (slot.Parameter is { } parameter
1903+
if (slot.Parameter is SimulatedDbParameter parameter
19001904
&& parameter.Direction is ParameterDirection.InputOutput or ParameterDirection.Output)
19011905
{
1906+
parameter.OutputSqlValue = slot.Value;
19021907
parameter.Value = slot.Value.IsNull ? DBNull.Value : slot.Value.ToObject();
19031908
}
19041909
}

0 commit comments

Comments
 (0)