Skip to content

Commit 0fe030a

Browse files
committed
sql_variant values with mixed inner base types order, group, and compare by SQL Server's two-level family rules.
1 parent 51375ed commit 0fe030a

8 files changed

Lines changed: 423 additions & 25 deletions

File tree

docs/claude/backlog.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,7 @@ Low priority / niche — simulatable (as placeholder constants or a small model)
6868
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.
6969
- **Certificates** — CERTENCODED / CERTPRIVATEKEY (needs a small certificate-name → bytes model, or NULL placeholders).
7070
- **Full-text properties** — FULLTEXTCATALOGPROPERTY (the `CREATE FULLTEXT CATALOG` / `INDEX` DDL already ships; this reads its metadata).
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.
71+
- **`sql_variant` minor quirks** (cross-type family ordering shipped 2026-07-19 — see [`scalars.md`](scalars.md#sql_variant-expression-semantics)): a decimal-declared inner reports BaseType `numeric` rather than real's `decimal` (the single-decimal-family naming divergence); a mixed variant-vs-base-typed comparison unwraps and promotes where real converts the base side up to variant and applies the family rules (a string variant never matches a numeric literal on real — unprobed, retained).
7472
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.
7573
- **FILESTREAM** — GET_FILESTREAM_TRANSACTION_CONTEXT (needs a FILESTREAM storage binding; NULL is a faithful "no FILESTREAM context" placeholder).
7674

docs/claude/catalog-views.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,15 @@ Contracts:
143143
This is what lets the DSC catalog view — which re-encodes each generator row through `RowEncoder` — carry variant cells.
144144
- **Coercion**`CAST(x AS sql_variant)` wraps (`CoerceTo` a variant target); `CAST(variant AS <concrete>)` unwraps the inner and re-coerces.
145145
`ISNULL(variant, x)` / `COALESCE(variant, …)` stay `sql_variant` (ISNULL fixes to the first arg's type; `Promote(variant, X) → variant`).
146-
- **Comparison** — a variant operand unwraps to its inner value before the promote-and-compare (`CompareValuesPromoted`), so `value = 0` unwraps a variant int/bit and compares numerically.
147-
Identity (Equals / GetHashCode for GROUP BY / DISTINCT) is strict on the inner value, so mixed-inner values are all distinct.
148-
**Residual:** `ORDER BY` / `GROUP BY` over a variant column with *mixed inner base-type families* (int and nvarchar in one column) isn't modeled — SQL Server orders by a documented datatype-family rank, but the inner `CompareTo` rejects the cross-type pair (surfaces as an `InvalidOperationException` from the sort).
149-
Same-inner-family ordering works; the built-ins realistically feed single-scalar / comparison / `CAST` flows that are unaffected.
146+
- **Comparison** — a variant operand against a base-typed one unwraps to its inner value before the promote-and-compare (`CompareValuesPromoted`), so `value = 0` unwraps a variant int/bit and compares numerically; a both-variant pair follows the datatype-family rules.
147+
Identity and ordering (Equals / GetHashCode / CompareTo for GROUP BY / DISTINCT / ORDER BY) follow SQL Server's two-level family comparison — family rank, then value within the family, with cross-type equal values collapsing into one bucket (`Storage/SqlVariantOrdering.cs`; probed rules in [`scalars.md`](scalars.md#sql_variant-expression-semantics)).
150148
- **Arithmetic / concatenation** — probe-confirmed against SQL Server 2025: `variant + non-variant` raises **Msg 257** (`Implicit conversion from data type sql_variant to <target> is not allowed. Use the CONVERT function to run this query.`); `variant + variant` and `string + variant` raise **Msg 402** (`… incompatible in the <op> operator`).
151149
`SqlType.PromoteForArithmetic` is the single source; a runtime guard in `IntegerArithmetic` routes through it so `Run`-time and projection-schema errors agree.
152-
- **TDS wire** — a `sql_variant` result column emits COLMETADATA type `0x62` (SSVARIANTTYPE) and the MS-TDS 2.2.5.5.3 per-value body (see [`tds-endpoint.md`](tds-endpoint.md)).
153-
RPC `sql_variant` *parameters* remain rejected.
150+
- **TDS wire** — a `sql_variant` result column emits COLMETADATA type `0x62` (SSVARIANTTYPE) and the MS-TDS 2.2.5.5.3 per-value body; RPC `sql_variant` parameters ship in both directions, TVP variant columns included (see [`tds-endpoint.md`](tds-endpoint.md)).
154151

155152
**Consumers surfacing true `sql_variant`:** the `SERVERPROPERTY` / `SESSIONPROPERTY` / `CONNECTIONPROPERTY` / `COLLATIONPROPERTY` / `LOGINPROPERTY` / `SESSION_CONTEXT` / `SQL_VARIANT_PROPERTY` / `DATABASEPROPERTYEX` / `OBJECTPROPERTYEX` scalar family (see [`scalars.md`](scalars.md)), `sys.configurations`.`value*`, `sys.database_scoped_configurations`.`value*`, `sys.sequences` (`start_value` / `increment` / `minimum_value` / `maximum_value` / `current_value` / `last_used_value`, inner = the sequence's declared type), `sys.identity_columns` (`seed_value` / `increment_value` / `last_value`, inner = the identity column's declared type), `sys.parameters.default_value` (always a NULL variant), `sys.partition_range_values.value` (always empty), and the `sys.symmetric_keys` / `sys.asymmetric_keys` sql_variant columns (`key_thumbprint` / `cryptographic_provider_algid`, always empty).
156153
No inner-type substitution remains.
157-
The one **semantic residual** is `ORDER BY` / `GROUP BY` over a variant column with *mixed inner base-type families* (see [`scalars.md`](scalars.md#sql_variant-expression-semantics)); a decimal-declared sequence's inner reports BaseType `numeric` rather than real's `decimal` (the single-decimal-family quirk).
154+
Remaining quirk: a decimal-declared sequence's inner reports BaseType `numeric` rather than real's `decimal` (the single-decimal-family naming divergence).
158155
- **`sys.filegroups`** is the row-filegroup subset of `sys.data_spaces` (same `Database.Filegroups` enumeration via `EnumerateFilegroupRows` — PRIMARY plus any bacpac-registered filegroup like WWI's `[USERDATA]`) plus `filegroup_guid uniqueidentifier` (NULL), `log_filegroup_id int` (NULL), `is_read_only` / `is_autogrow_all_files bit` (0).
159156
SMO's CREATE-scripting index/full-text queries read it; DacFx's bacpac export re-emits a standalone `SqlFilegroup` element per non-PRIMARY row.
160157
- **`sys.periods`** projects one row per table carrying a `PERIOD FOR SYSTEM_TIME` declaration (`HeapTable.PeriodColumns`), **history siblings excluded** (they hold no period of their own even though the simulator copies `PeriodColumns` onto them for the `FOR SYSTEM_TIME` query machinery).

docs/claude/scalars.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,11 +508,18 @@ Probe-confirmed against SQL Server 2025 (2026-07-16):
508508
- **`ISNULL` / `COALESCE` / `CASE`** keep the `sql_variant` result type (variant has highest data-type precedence in `SqlType.Promote`) and preserve each value's inner type — `ISNULL(SESSIONPROPERTY('ANSI_NULLS'), 0)` stays `sql_variant`.
509509
- **`UNION [ALL]`** keeps each row's own inner base type — no schema unification/promotion (a variant column can be `int` on one row, `nvarchar` on the next).
510510
- **Comparison** against a non-variant (`WHERE int_col = SESSION_CONTEXT(N'k')`) unwraps the variant to its inner value.
511+
**Both-operands-variant comparison** follows the family rules below (probe-confirmed: variant `bigint 1000 < float 0.5`, variant `int 5 = bigint 5`, variant `nvarchar N'x' = varchar 'x'`).
512+
Residual: real converts the base-typed side of a mixed pair *up* to `sql_variant` (highest precedence) and applies the family rules — so a string variant never matches a numeric literal on real, while the unwrap path converts and can match; unprobed, retained.
511513
- **`SELECT … INTO`** from a variant-producing built-in creates a `sql_variant` column (probe-confirmed).
512514
- **Arithmetic** rejects: `variant + non-variant`**Msg 257** (`Implicit conversion from data type sql_variant to <target> is not allowed. Use the CONVERT function to run this query.`); `variant + variant` and `string + variant`**Msg 402** (`… incompatible in the add operator`).
513515
`PromoteForArithmetic` is the single source; a runtime guard in `IntegerArithmetic` routes through it so `Run`-time and projection-schema errors agree.
514-
- **Residual**: `ORDER BY` / `GROUP BY` over a variant column with *mixed inner base-type families* (e.g. `int` and `nvarchar` in the same column) isn't modeled — real orders by a documented type-family rank, the simulator's inner `CompareTo` rejects the cross-type pair (surfaces as an `InvalidOperationException` from the sort).
515-
Same-inner-family ordering and mixed-inner `DISTINCT` (equality unwraps per value) both work; single-scalar / `ISNULL` / comparison / `CAST` flows the built-ins realistically feed are unaffected.
516+
- **Cross-type ordering / grouping** (`Storage/SqlVariantOrdering.cs`, probe-confirmed 2026-07-19): comparison is two-level — datatype-family rank first, then value within the family.
517+
Six families, lowest to highest: **1 `uniqueidentifier`; 2 binary** (`binary`/`varbinary`, byte-lexicographic); **3 character** (`char`/`varchar`/`nchar`/`nvarchar` — Unicode and non-Unicode are ONE family); **4 exact numeric** (`bit`, integer types, `decimal`, `money`/`smallmoney`, compared as decimal); **5 approximate** (`real`/`float` — above *every* exact value regardless of magnitude); **6 date/time** (compared as an instant: `time` anchored to 1900-01-01, `datetimeoffset` by UTC instant).
518+
Cross-family comparison is value-blind (`float 0.5 > bigint 1000000`); within a family, cross-type values compare by value and equal values are truly equal — `int 5` / `bigint 5` / `decimal 5.00` are one GROUP BY / DISTINCT bucket whose representative is the first value encountered (matching real's plan-order representative), and their ORDER BY tie order is undefined on real (plan-dependent), so tests must not pin it.
519+
NULL sorts lowest.
520+
`MIN`/`MAX` pick the family-hierarchy extremes.
521+
Same-collation character pairs compare under that collation; cross-collation pairs compare by code point **without** a Msg 468 conflict (probed) — the character family hashes by rank alone since no single hash agrees with both regimes.
522+
The `SqlValue` variant arms (`CompareTo` / `Equals` / `GetHashCode`) implement the rules, so ORDER BY, GROUP BY, DISTINCT, MIN/MAX, and hash joins all inherit them via `SqlValueKey`.
516523

517524
## Built-in TVF: `STRING_SPLIT`
518525
`STRING_SPLIT(input, separator [, enable_ordinal])` dispatches in `ParseSingleFromSource` alongside `OPENJSON` — case-insensitive name match before generic name resolution.

src/SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,12 +1233,21 @@ private protected override bool TryAppendFilterDefinition(StringBuilder sb, Batc
12331233
/// </summary>
12341234
internal static bool? CompareValuesPromoted(SqlValue l, SqlValue r, string operatorName, Func<SqlValue, SqlValue, bool> compare)
12351235
{
1236-
// sql_variant operands compare by their inner value: unwrap here so the
1236+
// Both operands sql_variant: real compares by datatype-family rank
1237+
// then value within the family (probe-confirmed — bigint 1000 < float
1238+
// 0.5, variant int 5 = variant bigint 5), which the SqlValue variant
1239+
// arms implement; apply the operator to the wrapped pair directly.
1240+
if (l.Type is SqlVariantSqlType && r.Type is SqlVariantSqlType)
1241+
return l.IsNull || r.IsNull ? null : compare(l, r);
1242+
1243+
// One sql_variant operand against a base-typed one: unwrap so the
12371244
// downstream promote-and-compare runs on the base types (a variant int
12381245
// vs an int literal, a variant bit vs 0, etc.). A NULL variant stays
1239-
// wrapped — the NULL short-circuit below folds it to UNKNOWN. Full
1240-
// sql_variant datatype-family comparison rules (where a string variant
1241-
// never matches a numeric literal) are out of scope; see docs.
1246+
// wrapped — the NULL short-circuit below folds it to UNKNOWN.
1247+
// Residual: real converts the base-typed side UP to sql_variant
1248+
// (highest precedence) and applies the family rules, so a string
1249+
// variant never matches a numeric literal on real, while the unwrap
1250+
// path here converts and can match — unprobed, retained.
12421251
if (l.Type is SqlVariantSqlType && !l.IsNull)
12431252
l = l.AsVariantInner;
12441253
if (r.Type is SqlVariantSqlType && !r.IsNull)

src/SqlServerSimulator/Storage/SqlValue.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,7 @@ public bool Equals(SqlValue other) =>
748748
&& this.IsNull == other.IsNull
749749
&& (this.IsNull
750750
|| (this.Type is SqlVariantSqlType
751-
? this.AsVariantInner.Equals(other.AsVariantInner)
751+
? SqlVariantOrdering.Compare(this.AsVariantInner, other.AsVariantInner) == 0
752752
: IsStringTypeRef(this.Type)
753753
? this.Type.Collation!.Equals(TrimTrailing((string)this.reference!), TrimTrailing((string)other.reference!))
754754
: this.Type is DateTimeOffsetSqlType
@@ -848,11 +848,9 @@ SqlTypeCategory.Integer or SqlTypeCategory.DateTime or SqlTypeCategory.Money
848848
// OrdPath's defining property: unsigned bytewise order equals
849849
// depth-first tree order, so hierarchyid comparison is a memcmp.
850850
HierarchyIdSqlType => this.AsHierarchyIdBytes.AsSpan().SequenceCompareTo(other.AsHierarchyIdBytes),
851-
// sql_variant ordering compares the inner values; differing
852-
// inner base types fall to the inner CompareTo's own
853-
// cross-type rejection (SQL Server's full sql_variant sort
854-
// by type-family rank is out of scope — see docs).
855-
SqlVariantSqlType => this.AsVariantInner.CompareTo(other.AsVariantInner),
851+
// sql_variant orders by datatype-family rank, then value
852+
// within the family (probe-confirmed; see SqlVariantOrdering).
853+
SqlVariantSqlType => SqlVariantOrdering.Compare(this.AsVariantInner, other.AsVariantInner),
856854
_ => throw new NotSupportedException($"Comparison for {this.Type} isn't implemented yet."),
857855
},
858856
_ => throw new NotSupportedException($"Comparison for {this.Type} isn't implemented yet."),
@@ -870,8 +868,9 @@ public override int GetHashCode()
870868

871869
if (this.Type is SqlVariantSqlType)
872870
{
873-
// Identity is the inner value alone; equality unwraps to it.
874-
hash.Add(this.AsVariantInner.GetHashCode());
871+
// Identity is the family-canonical inner value, agreeing with the
872+
// family-rank equality (int 5 / bigint 5 / decimal 5.00 hash alike).
873+
hash.Add(SqlVariantOrdering.InnerHashCode(this.AsVariantInner));
875874
}
876875
else if (IsStringTypeRef(this.Type))
877876
{

0 commit comments

Comments
 (0)