Skip to content

Commit ce255d4

Browse files
committed
Top-level ORDER BY after set operations computes sort keys by decoding only the ORDER BY columns from the byte[] rows instead of full-row decode.
1 parent e6bf0ce commit ce255d4

5 files changed

Lines changed: 106 additions & 16 deletions

File tree

SqlServerSimulator.Tests/SetOperationTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,56 @@ public void TopLevelOrderBy_AppliesToCombinedResult()
139139
public void PerBranchOrderBy_RaisesMsg156()
140140
=> _ = new Simulation().AssertSqlError("select 1 order by 1 union select 2", 156);
141141

142+
// The top-level (post-set-op) ORDER BY sorts the combined rows in their
143+
// encoded byte[] form and decodes only the sort-key column per row; the
144+
// non-key columns (here a string and a NULL-bearing int) must still drain
145+
// correctly for every row after the sort, and NULL ordering / collation
146+
// must match the single-SELECT path.
147+
[TestMethod]
148+
public void TopLevelOrderBy_MultiColumn_DrainsNonKeyColumnsAfterSort()
149+
{
150+
var simulation = new Simulation();
151+
_ = simulation.ExecuteNonQuery("""
152+
create table a (k int, s varchar(10), n int);
153+
create table b (k int, s varchar(10), n int);
154+
insert a values (3, 'gamma', 30), (1, 'alpha', null);
155+
insert b values (2, 'Beta', 20), (4, 'delta', 40)
156+
""");
157+
158+
using var reader = simulation.CreateCommand(
159+
"select k, s, n from a union all select k, s, n from b order by s").ExecuteReader();
160+
var rows = new List<(int K, string S, int? N)>();
161+
while (reader.Read())
162+
rows.Add((reader.GetInt32(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetInt32(2)));
163+
164+
// Case-insensitive default collation: alpha < Beta < delta < gamma.
165+
CollectionAssert.AreEqual(
166+
new[] { (1, "alpha", (int?)null), (2, "Beta", 20), (4, "delta", 40), (3, "gamma", 30) },
167+
rows);
168+
}
169+
170+
// Ordinal ORDER BY over a set-op resolves against the projected column and
171+
// sorts NULLs first under ASC, exercising the ordinal branch of the
172+
// top-level key decode.
173+
[TestMethod]
174+
public void TopLevelOrderBy_Ordinal_NullsFirst()
175+
{
176+
var simulation = new Simulation();
177+
_ = simulation.ExecuteNonQuery("""
178+
create table a (v int);
179+
create table b (v int);
180+
insert a values (3), (null);
181+
insert b values (1), (2)
182+
""");
183+
184+
using var reader = simulation.CreateCommand(
185+
"select v from a union all select v from b order by 1").ExecuteReader();
186+
var values = new List<int?>();
187+
while (reader.Read())
188+
values.Add(reader.IsDBNull(0) ? null : reader.GetInt32(0));
189+
CollectionAssert.AreEqual(new int?[] { null, 1, 2, 3 }, values);
190+
}
191+
142192
// Non-set-op SELECT can ORDER BY a non-projected source column.
143193
[TestMethod]
144194
public void SingleSelect_OrderByNonProjectedSource_StillWorks()

SqlServerSimulator/Parser/Selection.Execution.OrderBy.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@ private static SqlValue[] ComputeOrderKeys(
5050
return keys;
5151
}
5252

53+
/// <summary>
54+
/// Computes ORDER BY keys for the top-level (post-set-op) sort directly off
55+
/// an encoded <c>byte[]</c> row, decoding only the columns an ORDER BY item
56+
/// references rather than the whole tuple. References resolve against the
57+
/// inner plan's projected column names / ordinals only — there are no source
58+
/// columns to fall back to (an unresolved name is Msg 207), matching the
59+
/// <see cref="ComputeOrderKeys"/> path this mirrors. <paramref name="columns"/>
60+
/// is the schema's cached <see cref="HeapColumn"/>[] so each per-column decode
61+
/// hits the RowLayout geometry cache.
62+
/// </summary>
63+
private static SqlValue[] ComputeTopLevelOrderKeys(
64+
List<OrderBySpec> orderBy,
65+
string[] columnNames,
66+
HeapColumn[] columns,
67+
byte[] rowBytes,
68+
BatchContext batch)
69+
{
70+
SqlValue ResolveByOutputName(MultiPartName name)
71+
{
72+
for (var j = 0; j < columnNames.Length; j++)
73+
{
74+
if (BuiltInToken.Equals(columnNames[j], name.Leaf))
75+
return RowDecoder.DecodeColumn(columns, rowBytes, j);
76+
}
77+
throw SimulatedSqlException.InvalidColumnName(name);
78+
}
79+
80+
var keys = new SqlValue[orderBy.Count];
81+
for (var i = 0; i < orderBy.Count; i++)
82+
{
83+
var spec = orderBy[i];
84+
keys[i] = spec.IsOrdinal
85+
? RowDecoder.DecodeColumn(columns, rowBytes, spec.Ordinal - 1)
86+
: spec.Expr!.Run(new RuntimeContext(ResolveByOutputName, batch));
87+
}
88+
return keys;
89+
}
90+
5391
/// <summary>
5492
/// Lexicographic compare of two key tuples per the per-key descending
5593
/// flags. NULL is treated as the smallest value (NULL first under ASC,

SqlServerSimulator/Parser/Selection.Execution.SetOps.cs

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ private static Selection ApplyTopLevelOrderBy(Selection inner, List<OrderBySpec>
195195
var schema = inner.Schema;
196196
var columnNames = inner.ColumnNames;
197197

198+
// Cached HeapColumn[] for the combined schema, so per-column key decodes
199+
// hit RowLayout's identity-keyed geometry cache (see RowDecoder.ColumnsFor).
200+
var keyColumns = RowDecoder.ColumnsFor(schema);
201+
198202
return new Selection(schema, columnNames,
199203
hasOrderBy: true,
200204
hasTopOrOffsetOrFetch: inner.HasTopOrOffsetOrFetch || offsetExpression is not null || fetchExpression is not null,
@@ -214,23 +218,16 @@ private static Selection ApplyTopLevelOrderBy(Selection inner, List<OrderBySpec>
214218
}
215219
else
216220
{
221+
// The inner set-op chain yields byte[] rows natively (branch
222+
// dedup / coercion re-encode). Keep that form through the sort
223+
// and let the reader / TDS cursor decode once at drain —
224+
// eagerly decoding every column into SqlValue[] here only to
225+
// re-materialize strings for the whole buffer measured slower
226+
// and heavier than the lazy-from-bytes path. Sort keys decode
227+
// only the ORDER BY columns off each row, not the full tuple.
217228
var keyed = new List<(byte[] Row, SqlValue[] Keys)>(allRows.Count);
218229
foreach (var rowBytes in allRows)
219-
{
220-
var values = DecodeRowToValues(rowBytes, schema);
221-
SqlValue ResolveByOutputName(MultiPartName name)
222-
{
223-
for (var j = 0; j < columnNames.Length; j++)
224-
{
225-
if (BuiltInToken.Equals(columnNames[j], name.Leaf))
226-
return values[j];
227-
}
228-
throw SimulatedSqlException.InvalidColumnName(name);
229-
}
230-
231-
var keys = ComputeOrderKeys(orderBy, values, columnNames, distinct: false, batch, ResolveByOutputName);
232-
keyed.Add((rowBytes, keys));
233-
}
230+
keyed.Add((rowBytes, ComputeTopLevelOrderKeys(orderBy, columnNames, keyColumns, rowBytes, batch)));
234231

235232
keyed.Sort((a, b) => CompareOrderKeys(a.Keys, b.Keys, orderBy));
236233
ordered = keyed.Select(r => r.Row);

docs/claude/backlog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ The endpoint ships with SQLBatch + RPC + Transaction Manager support and credent
2424

2525
- **Tool shakedown** — point real client tools at the endpoint and harvest their exotic catalog queries / SET shapes into this backlog. Tool scope (user decision, 2026-07-16): tools a SQL Server + .NET developer already has — SSMS, sqlcmd, Visual Studio (SQL Server Object Explorer / DacFx), LINQPad; DBA-flavored tools like DBeaver are out of scope. **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. 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. **Remaining frontier**: Table Designer, Activity Monitor, standard reports, and IntelliSense's background metadata harvest. (The sp_cursor* API-server-cursor RPC family — SSMS query-editor grid navigation / multi-row edit — shipped; see [`tds-endpoint.md`](tds-endpoint.md).) Candidate follow-on legs within tool scope: Visual Studio's SQL Server Object Explorer (DacFx-driven, a different query dialect from SMO) and LINQPad.
2626
- **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. 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.
27-
- **ORDER BY result sets ride encoded `byte[]` rows** through the reader-cursor decode path instead of the projected `SqlValue[]` fast path the unordered FROM-bearing SELECT takes — an encode-then-re-decode round-trip per row. The next large-result-drain throughput lever (DacFx export chunks all carry ORDER BY, so it taxes every export row).
2827
- **Wire forms deferred by the codec**: `text`/`ntext`/`image` (textptr ROW form + table-name-bearing COLMETADATA); `sql_variant` RPC *parameters* (the result-column form ships — see [`tds-endpoint.md`](tds-endpoint.md); candidates for true-variant migration off inner-type substitution: SERVERPROPERTY / SESSIONPROPERTY / CONNECTIONPROPERTY / COLLATIONPROPERTY / LOGINPROPERTY / SESSION_CONTEXT / `sys.configurations`); UDT *parameters* for `geography`/`geometry`/`hierarchyid` (their result-column forms ship). **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; see [`tds-endpoint.md`](tds-endpoint.md#tvp-parameters-sqldbtypestructured). Residual: `sql_variant` / UDT *columns inside* a TVP still reject.
2928
- **Smaller fidelity items**: mid-stream attention (cancel during a large result requires a concurrent reader); MARS; TDS 8.0 / `Encrypt=Strict` (ALPN via `SslApplicationProtocol`); user-supplied `X509Certificate2`; off-loopback binding — the last two are add-on-demand public-surface expansions.
29+
- **Chunked `OFFSET/FETCH` re-sorts the whole set per page**: an `ORDER BY … OFFSET @o FETCH @f` paginated drain (the DacFx chunked-export shape) buffers and sorts the entire result on every chunk, then skip/takes the page — so N chunks pay N full sorts (measured ~10× the single-sort µs/row at 15 chunks over 150k rows). The projection/drain representation is already the decoded-once `SqlValue[]` fast path (single-SELECT ORDER BY sorts projected rows, no byte[] re-decode — the prior gap here is closed); this residual is redundant re-sorting across pages, not a decode round-trip. A "sort once, serve many pages" lever would need cross-execution result caching the plan cache doesn't currently model.
3030

3131
### Built-in functions
3232

docs/claude/query.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
- TOP + OFFSET → **Msg 10741**.
2222
- Counts resolve at parse time (constants, parameters, arithmetic).
2323

24+
## Result drain / ORDER BY representation
25+
The FROM-bearing SELECT projection paths — streaming, buffered (ORDER BY / DISTINCT), windowed, and aggregate — all yield already-projected `SqlValue[]` rows, so `SimulatedSqlResultSet` serves the reader and TDS cursors directly with no encode-then-re-decode round-trip (see the `SimulatedSqlResultSet` doc + [`data-reader.md`](data-reader.md)). **ORDER BY on a single SELECT sorts those projected `SqlValue[]` rows** (`ProjectBuffered.materialized.Sort`), so ordered drains ride the same decoded-once fast path as unordered ones; the only ordered-vs-unordered cost is the inherent buffer + per-row key computation + `List.Sort`, not a decode round-trip, and peak retained memory is effectively unchanged (measured within 0.2% on a 150k-row wide drain).
26+
27+
The **top-level ORDER BY after a set-op chain** (`ApplyTopLevelOrderBy`) is the exception: the inner UNION / INTERSECT / EXCEPT chain yields byte[] rows natively (branch dedup / coercion re-encode), so that path keeps the byte[] form through the sort and lets the drain cursor decode once — eagerly decoding every column into `SqlValue[]` for the whole buffer measured slower and heavier (strings re-materialized and retained across the sort). Sort keys decode only the ORDER BY columns off each row (`ComputeTopLevelOrderKeys`), not the full tuple.
28+
2429
## Aggregates
2530
`COUNT(*)` / `COUNT(expr)` / `COUNT(DISTINCT)` / `COUNT_BIG`, `SUM` / `AVG`, `MAX` / `MIN`, statistical (`STDEV` / `STDEVP` / `VAR` / `VARP`), `STRING_AGG`, `CHECKSUM_AGG`, `APPROX_COUNT_DISTINCT`. `AVG(int)` truncates; `AVG(decimal(p,s))` widens to `decimal(38, max(s,6))`.
2631

0 commit comments

Comments
 (0)