Skip to content

Commit 51375ed

Browse files
committed
SET TEXTSIZE truncates LOB data at the client boundary: the session byte cap clips the six MAX/legacy-LOB types in result columns and output parameters through a TextSizeCursor decorator on the shared client-cursor seam keyed by declared schema type with the cap stamped per statement at production, so a proc body's SET governs its own result sets across the exit revert, wide chars floor the odd byte, xml/bounded/UDT types are exempt, and server-side computation, assignment, and stored data stay full. The direct proc-RPC binding now honors Size=-1 as a MAX declaration like the batch path always did.
1 parent 2f8aa43 commit 51375ed

21 files changed

Lines changed: 560 additions & 53 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,9 @@ Each entry below is a trigger: read the linked file on demand when working in th
282282
- **Cross-database / cross-server DML** (`INSERT`/`UPDATE`/`DELETE`/`MERGE` through a 3-/4-part name) → `NotSupportedException` via `BatchContext.RejectCrossDatabaseMutation``USE <db>` first.
283283
Cross-DB / four-part *reads* (SELECT/JOIN) ship; catalog-view reads via four-part names (`srv.db.sys.tables`) fall to Msg 208.
284284
See [`schemas.md`](docs/claude/schemas.md), [`linked-servers.md`](docs/claude/linked-servers.md).
285-
- **`SET <option>` accept-list** (`Simulation.Set.cs`): XACT_ABORT, all ANSI/session-state toggles, `STATISTICS {IO|TIME|XML|PROFILE}`, value-taking options (`TEXTSIZE`/`DATEFIRST`/etc.) — all parse-and-discard.
285+
- **`SET <option>` accept-list** (`Simulation.Set.cs`): XACT_ABORT, all ANSI/session-state toggles, `STATISTICS {IO|TIME|XML|PROFILE}`, value-taking options (`ROWCOUNT`/`DATEFIRST`/etc.) — all parse-and-discard.
286286
Unknown SET → Msg 195.
287-
`SET @v`, `IDENTITY_INSERT`, `NOCOUNT`, `LOCK_TIMEOUT`, `TRANSACTION ISOLATION LEVEL`, `QUOTED_IDENTIFIER` (and `ANSI_DEFAULTS`'s QI component) carry semantic effect.
287+
`SET @v`, `IDENTITY_INSERT`, `NOCOUNT`, `LOCK_TIMEOUT`, `TEXTSIZE` (client-boundary LOB truncation — see [`scalars.md`](docs/claude/scalars.md)), `TRANSACTION ISOLATION LEVEL`, `QUOTED_IDENTIFIER` (and `ANSI_DEFAULTS`'s QI component) carry semantic effect.
288288
- **`ALTER DATABASE … SET` / `COLLATE`** — see [`database-options.md`](docs/claude/database-options.md).
289289
Most options parse-and-discard; `COMPATIBILITY_LEVEL`, `ALLOW_SNAPSHOT_ISOLATION`, `READ_COMMITTED_SNAPSHOT` are load-bearing.
290290
- `RANGE BETWEEN <N> PRECEDING/FOLLOWING` numeric-offset — Msg 4194 (real's licensed-feature rejection).

docs/claude/backlog.md

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,11 @@ Remaining phases, roughly in value order:
3434
**SSMS is the final boss** — an ongoing campaign, not a single leg: each surface is its own multi-round harvest, and clearing one unlocks the next.
3535
Cleared legs are recorded in the per-feature deep-dives (catalog surface in [`catalog-views.md`](catalog-views.md), wire behavior in [`tds-endpoint.md`](tds-endpoint.md)); the discovery harnesses are the gitignored `.vs/ssms-host` TDS host and the headless SMO property-bag drain.
3636
**Remaining frontier**: Table Designer, Activity Monitor, standard reports, and IntelliSense's background metadata harvest.
37-
(The sp_cursor* API-server-cursor RPC family — SSMS query-editor grid navigation / multi-row edit — shipped; see [`tds-endpoint.md`](tds-endpoint.md).)
3837
Candidate follow-on legs within tool scope: Visual Studio's SQL Server Object Explorer (DacFx-driven, a different query dialect from SMO) and LINQPad.
3938
- **SMO API sweep campaign**`.vs/smo-sweep` (gitignored local harness) walks SMO's full reachable read surface against the self-hosted simulator and, identically, against the live reference, draining every `Property.Value` and `Script()`-ing every `IScriptable`; modes `sweep` / `sweep --live` / `diff` → sorted JSON reports + `reports/triage.md`; workflow = sweep both sides → triage → fix bundles → graduated `Tests.Smo` tests → re-sweep.
4039
Open items from the latest triage: (a) `DBCC SHOW_STATISTICS … WITH STATS_STREAM` (SMO `Statistic.Stream`) stays `NotSupportedException` — it wants the raw serialized statistics-histogram blob, which the simulator has no faithful source for; (b) the unmodeled runtime/OS surfaces SMO reaches as absent objects (backup history `msdb.dbo.backupset`, `sys.dm_tran_persistent_version_store_stats`, file-space/IO DMVs, `sys.dm_os_process_memory`, `master.dbo.sysprocesses`, `FILEPROPERTY`, registry/OS xps) — surfaced as `PropertyCannotBeRetrievedException` / defaults, the legitimate-gap category.
41-
- **Wire forms deferred by the codec**: the RPC-parameter and bulk/TVP-column decoders now share one implementation (`TdsWireValue` + `TdsColumnDecoder`; see [`tds-endpoint.md`](tds-endpoint.md#client-value-decode-tdswirevalue--tdscolumndecoder)), which closed the split's residuals.
42-
**`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).
43-
**`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)).
44-
**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: **`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).)
47-
(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).)
48-
- **Smaller fidelity items**: TDS 8.0 / `Encrypt=Strict` (ALPN via `SslApplicationProtocol`); user-supplied `X509Certificate2` — an add-on-demand public-surface expansion.
49-
(Off-loopback binding shipped 2026-07-19 as `ListenNetworkAsync`, gated on a registered login; per-interface bind-address selection remains add-on-demand.)
50-
(**Terminal crash boundary** shipped 2026-07-19 — an exception outside the typed catch list now emits a best-effort Msg 0 / severity 20 ERROR at a token boundary instead of a silent transport reset, `AtTokenBoundary`-gated for mid-token safety, on both the non-MARS and MARS session loops; see [`tds-endpoint.md`](tds-endpoint.md#terminal-crash-boundary).
51-
Mid-stream attention / cancel shipped 2026-07-18 — carry-forward concurrent read, DONE_ATTN ack, probed XACT_ABORT transaction semantics; see [`tds-endpoint.md`](tds-endpoint.md#mid-stream-attention-cancel).
52-
Residual: cancel reaction is bounded by the in-flight statement's materialization, not interruptible inside a single statement's row loop.
53-
**MARS** shipped 2026-07-18 — SMP demux/mux, strictly-additive prelogin negotiation, serialized cooperative multiplexing over one shared connection, per-session attention; see [`tds-endpoint.md`](tds-endpoint.md#mars-multiple-active-result-sets).
54-
Residual: Msg 8628/8651 never raised, per-session response fully materialized under the execution gate.)
40+
- **Smaller fidelity items**: TDS 8.0 / `Encrypt=Strict` (ALPN via `SslApplicationProtocol`); user-supplied `X509Certificate2` and per-interface bind-address selection — add-on-demand public-surface expansions.
41+
Open residuals of shipped wire features (details in [`tds-endpoint.md`](tds-endpoint.md)): cancel/attention reaction is bounded by the in-flight statement's materialization, not interruptible inside a single statement's row loop; MARS never raises Msg 8628/8651 and fully materializes each session's response under the execution gate.
5542
- **Chunked `OFFSET/FETCH` paging: per-page constant factor, complexity class matches real** (probed 2026-07-18, plans + timings): real SQL Server also redoes the work on every page — `Top(OFFSET…)` over an ordered index scan reading offset+fetch rows when an index supplies the order, or a full scan + `Sort(TOP offset+fetch)` re-sorted per page when not; no cross-query sorted-result caching exists on either side, so "sort once, serve many pages" is rejected (it would invent behavior real doesn't have).
5643
The residual is constant-factor only: the simulator materializes (and for non-index order, sorts) all n rows per page regardless of offset where real's indexed plan touches only offset+fetch (measured at 150k rows / fetch 100 / offset 140k: sim ~41 ms vs real ~13 ms indexed, ~116 ms vs ~22 ms unindexed — real's sort is also 16-way parallel).
5744
Possible lever if paged drains ever matter: recognize index-supplied order in the `OFFSET/FETCH` path (real's plan shape) to skip the sort and bound the scan.
@@ -81,11 +68,10 @@ Low priority / niche — simulatable (as placeholder constants or a small model)
8168
A named-filegroup registry now ships (`Database.Filegroups`, seeded PRIMARY=1, extended by the bacpac loader's `SqlFilegroup` dispatch; surfaced by `sys.filegroups`/`sys.data_spaces` and FILEGROUP-scoped extended properties) — the scalar helpers above could read it, though a `file_id` 1 placeholder is still needed for the file-level ones.
8269
- **Certificates** — CERTENCODED / CERTPRIVATEKEY (needs a small certificate-name → bytes model, or NULL placeholders).
8370
- **Full-text properties** — FULLTEXTCATALOGPROPERTY (the `CREATE FULLTEXT CATALOG` / `INDEX` DDL already ships; this reads its metadata).
84-
- **`sql_variant` mixed-inner-family ordering** — the last open piece of the `sql_variant` migration (all property scalars and catalog sql_variant columns now project true `sql_variant` with probed inner base types — 2026-07-19; see [`scalars.md`](scalars.md#sql_variant-expression-semantics), [`catalog-views.md`](catalog-views.md#the-sql_variant-type)).
85-
`ORDER BY` / `GROUP BY` over a variant column with *mixed inner base-type families* (int + nvarchar in one column) isn't modeled — real orders by a datatype-family rank, the inner `CompareTo` rejects the cross-type pair (surfaces as `InvalidOperationException` from the sort).
86-
Same-inner-family ordering, mixed-inner `DISTINCT`, and the single-scalar / comparison / `CAST` flows the built-ins realistically feed all work.
87-
(Minor related quirk: a decimal-declared sequence/column's inner reports BaseType `numeric` rather than real's `decimal` — the single-decimal-family divergence.)
88-
One catalog surface stays on `nvarchar` deliberately: `msdb.dbo.syspolicy_configuration.current_value` is a *view-body* projection (not a resource column) mixing `int` rows with a `binary` GUID row; every consumer reads a single named row and CASTs it, so the `nvarchar` surfacing is CAST-compatible and a variant migration there would only touch the view SQL text for no observable gain.
71+
- **`sql_variant` mixed-inner-family ordering**`ORDER BY` / `GROUP BY` over a variant column with *mixed inner base-type families* (int + nvarchar in one column) isn't modeled: real orders by a datatype-family rank, while the inner `CompareTo` rejects the cross-type pair (surfaces as `InvalidOperationException` from the sort).
72+
Same-inner-family ordering, mixed-inner `DISTINCT`, and the single-scalar / comparison / `CAST` flows all work — see [`scalars.md`](scalars.md#sql_variant-expression-semantics), [`catalog-views.md`](catalog-views.md#the-sql_variant-type).
73+
Related open quirk: a decimal-declared sequence/column's inner reports BaseType `numeric` rather than real's `decimal` — the single-decimal-family divergence.
74+
Deliberate exclusion, don't re-pitch: `msdb.dbo.syspolicy_configuration.current_value` stays `nvarchar` — it's a *view-body* projection (not a resource column) mixing `int` rows with a `binary` GUID row, every consumer reads a single named row and CASTs it, so a variant migration there would only touch the view SQL text for no observable gain.
8975
- **FILESTREAM** — GET_FILESTREAM_TRANSACTION_CONTEXT (needs a FILESTREAM storage binding; NULL is a faithful "no FILESTREAM context" placeholder).
9076

9177
## Fidelity gaps in shipped behavior
@@ -141,9 +127,6 @@ Worth a look before re-affirming or changing.
141127
- **CHECKSUM_AGG** uses an order-independent XOR fold.
142128
Rationale: same-multiset-same-checksum preserved, bit-identical wasn't required.
143129
Review: has any consumer needed bit-identical checksums (e.g. replication-comparison parity)?
144-
- **DATALENGTH** returns `int` for MAX-typed inputs; real returns `bigint`.
145-
Rationale: result fits in int for any value the simulator can produce.
146-
Review: does the projection-schema mismatch break any consumer (EF Core mappings, ORM projections) now that storage scales further?
147130
- **`float` CAST/CONVERT** text formatting uses .NET `G15`/`G7` rather than SQL Server's `1e+015`-style scientific.
148131
Rationale: .NET formatting is the default; the specific format wasn't a fidelity-oracle requirement.
149132
Review: do users hit float-as-string comparisons in real workloads?

docs/claude/scalars.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,10 @@ Constants whose values don't carry real session/server identity in the simulator
398398
- **`@@SERVICENAME`**`"MSSQLSERVER"`.
399399
- **`@@LANGID`** — 0.
400400
- **`@@LANGUAGE`**`"us_english"`.
401-
- **`@@TEXTSIZE`** — -1 (matches SQL Server's documented default).
401+
- **`@@TEXTSIZE`** — session state (`SimulatedDbConnection.TextSize`), default -1 (unlimited — the value a fresh SqlClient login establishes, probe-confirmed).
402+
`SET TEXTSIZE` carries semantic effect: the byte cap clips MAX-typed / legacy-LOB values at the client boundary (result columns via the `TextSizeCursor` decorator installed by `SimulatedQueryResult.CreateClientCursor`, output parameters at write-back), never server-side computation, variable assignment, or stored data; `varchar(max)`/`text` truncate at 1 byte per char, `nvarchar(max)`/`ntext` at 2 with an odd byte floored, `varbinary(max)`/`image` at raw bytes, while `xml`, bounded var types, and UDTs are exempt.
403+
Value mapping: `-1` preserved verbatim, `0` and every other negative collapse to 4096; a past-int-range literal raises **Msg 1080**; issued inside a proc body it reverts at proc exit while the body's result sets keep their production-time cap (`ClientTextSize` stamped per statement).
404+
All probe-confirmed against SQL Server 2025 (2026-07-19).
402405
- **`@@OPTIONS`** — 5432 (composite of ANSI/ARITHABORT/QUOTED_IDENTIFIER/CONCAT_NULL_YIELDS_NULL flags matching the simulator's defaults).
403406
- **`@@VERSION`** — a multi-line banner mirroring the real SQL Server 2025 `@@VERSION` shape (`Microsoft SQL Server 2025 (RTM-CU7) (KB5096981) - 17.0.4065.4 (X64)` / build-date line / copyright / edition line), with the simulator's own identity (`Developer Edition (64-bit) on SQL Server Simulator`) standing in for real's host-OS line.
404407
- **`@@MICROSOFTVERSION`** — int `285216737` (`0x11000FE1`), the `(major << 24) | (minor << 16) | build` packing of version `17.0.4065.4` (`(17 << 24) | 4065`), self-consistent with `SERVERPROPERTY('ProductVersion')` = `"17.0.4065.4"` and the real reference instance's value.

docs/claude/tds-endpoint.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,9 @@ A value larger than one packet is written as one contiguous data block — the t
355355
Both direct-proc RPC and `sp_executesql` bind identically.
356356
Output-direction legacy-LOB parameters aren't a real SQL Server feature and don't arrive.
357357

358-
**Residuals.**
359-
`SET TEXTSIZE` — real truncates returned `text`/`ntext`/`image` data to the byte limit; the simulator parse-and-discards TEXTSIZE (`Simulation.Set.cs`), so it always returns the full value.
360-
**`SqlBulkCopy` into a `text`/`ntext`/`image` column** now decodes (see [Bulk load](#bulk-load-sqlbulkcopy) step 3).
361-
Oracles: `LegacyLobWireTests`, `BulkCopyTests.LegacyLobColumns_TextNtextImage_InsertAndRoundTrip`.
358+
**`SET TEXTSIZE` truncates at wire egress** (shipped 2026-07-19): the session byte cap clips `text`/`ntext`/`image` and the MAX-typed trio in result columns and output parameters — the truncation rides the shared client-boundary cursor (`SimulatedQueryResult.CreateClientCursor``TextSizeCursor`), so the TDS row writer inherits it; see [`scalars.md`](scalars.md) for the full semantics.
359+
**`SqlBulkCopy` into a `text`/`ntext`/`image` column** decodes (see [Bulk load](#bulk-load-sqlbulkcopy) step 3).
360+
Oracles: `LegacyLobWireTests`, `BulkCopyTests.LegacyLobColumns_TextNtextImage_InsertAndRoundTrip`, `TextSizeWireTests`.
362361

363362
## Collation wire structure
364363

src/SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ partial class SimulatedSqlException
77
{
88
internal static SimulatedSqlException MissingEndCommentMark() => new("Missing end comment mark '*/'.", 113, 15, 1);
99

10+
/// <summary>
11+
/// Mimics SQL Server error 1080: an integral literal in a grammar slot
12+
/// that demands an <c>int</c> (<c>SET TEXTSIZE</c> / <c>SET ROWCOUNT</c>)
13+
/// exceeds the int range. Probe-confirmed against SQL Server 2025
14+
/// (2026-07-19): <c>SET TEXTSIZE 2147483648</c> → Msg 1080, Level 15.
15+
/// </summary>
16+
internal static SimulatedSqlException IntegerValueOutOfRange(string literal) =>
17+
new($"The integer value {literal} is out of range.", 1080, 15, 1);
18+
1019
/// <summary>
1120
/// Mimics SQL Server error 105: a string literal or quote-delimited
1221
/// identifier opened with <c>'</c> or <c>"</c> was never closed before

src/SqlServerSimulator/Network/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ These notes are the local implementation contracts.
4444
ROW value (`WriteLegacyLob`) = text-pointer-length byte + 16-byte placeholder pointer (`"dummy textptr\0\0\0"`) + 8-byte timestamp (`"dummyTS\0"`) + 4-byte data length + data (CP1252 text / UTF-16LE ntext / verbatim image); NULL is a single `0x00`.
4545
`image` **input** parameters decode (`TdsRpc.DecodeImage`): 4-byte max size + 4-byte data length (`0xFFFFFFFF` = NULL) + raw bytes, contiguous (not PLP) even multi-packet, bound as `DbType.Binary`.
4646
**`SqlBulkCopy` into a `text`/`ntext`/`image` column decodes** (`TdsColumnDecoder`'s legacy-LOB arm): COLMETADATA LONGLEN TYPE_INFO (4-byte max size + 5-byte collation for the string pair + a two-byte zero-part TableName field SqlClient sends in a client value stream) then the in-band text-pointer ROW value (1-byte ptr length `0`=NULL, else 16-byte ptr + 8-byte timestamp — both `0xFF` from SqlClient — 4-byte data length, data).
47-
Residual: `SET TEXTSIZE` doesn't truncate.
48-
Oracles: `LegacyLobWireTests`, `BulkCopyTests.LegacyLobColumns_TextNtextImage_InsertAndRoundTrip`.
47+
`SET TEXTSIZE` truncates legacy-LOB and MAX-typed result columns + output parameters at the client boundary (the shared `SimulatedQueryResult.CreateClientCursor``TextSizeCursor` seam, so the TDS row writer inherits it; docs/claude/scalars.md).
48+
Oracles: `LegacyLobWireTests`, `BulkCopyTests.LegacyLobColumns_TextNtextImage_InsertAndRoundTrip`, `TextSizeWireTests`.
4949
- **Token writer contract**: `TdsTokenWriter` buffers synchronously; `FlushAsync(final: false)` after every row keeps memory bounded to max(row, packet).
5050
Only the final flush sets EOM.
5151
Tokens may legally split across packet boundaries.

src/SqlServerSimulator/Network/TdsSession.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ private async ValueTask<bool> StreamOutcomesAsync(SimulatedDbCommand command, Td
662662
{
663663
TdsTypeCodec.WriteColMetadata(writer, query.Schema, query.ColumnNames, query.ColumnNullability);
664664
long rows = 0;
665-
using (var cursor = query.CreateCursor())
665+
using (var cursor = query.CreateClientCursor())
666666
{
667667
while (cursor.MoveNext())
668668
{

src/SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,9 +1200,11 @@ private static Dictionary<string, VariableSlot> SeedVariables(SimulatedDbCommand
12001200

12011201
/// <summary>
12021202
/// The MAX-typed variant of a variable-length parameter type, or null
1203-
/// when the type has no MAX form.
1203+
/// when the type has no MAX form. Applied when a parameter declares
1204+
/// SqlClient's <c>Size = -1</c> MAX convention — both the batch
1205+
/// variable-seed path and the direct stored-procedure binding.
12041206
/// </summary>
1205-
private static SqlType? AsMaxVariant(SqlType dbType) => dbType switch
1207+
internal static SqlType? AsMaxVariant(SqlType dbType) => dbType switch
12061208
{
12071209
VarcharSqlType varchar => VarcharSqlType.Get(SqlType.MaxLengthSentinel, varchar.Collation ?? Collation.Baseline, varchar.Coercibility),
12081210
NVarcharSqlType nvarchar => NVarcharSqlType.Get(SqlType.MaxLengthSentinel, nvarchar.Collation ?? Collation.Baseline, nvarchar.Coercibility),

0 commit comments

Comments
 (0)