Skip to content

Commit 6d68901

Browse files
committed
Improved fidelity of SQL_Latin1_General_CP1_CI_AS implementation.
1 parent c0d2484 commit 6d68901

4 files changed

Lines changed: 153 additions & 34 deletions

File tree

SqlServerSimulator.Tests.Internal/CollationTests.cs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,27 @@ public void Win100_AccentSensitive()
7070
}
7171

7272
[TestMethod]
73-
public void Win100_ApostropheIsPrimaryWeightZero_InSort()
73+
public void Win100_ApostropheIsMinimalWeight_InSort()
7474
{
7575
// Probe-confirmed against WideWorldImporters.Application.Cities:
76-
// MIN of ('Aaronsburg', "'Aiea") is 'Aaronsburg' under
77-
// Latin1_General_100_CI_AS because the apostrophe drops out of
78-
// the primary sort key, leaving "Aiea" > "Aaronsburg".
76+
// MIN of ('Aaronsburg', "'Aiea") is 'Aaronsburg' because the
77+
// apostrophe drops out of the *primary* sort key, leaving "Aiea" >
78+
// "Aaronsburg". The apostrophe is not weightless, though — it carries
79+
// a secondary weight, so between two strings that share a primary key
80+
// the copy bearing it sorts after: "A" < "'A", "OBrien" < "O'Brien"
81+
// (probe-confirmed on SQL Server 2025, all three families identical).
7982
IsLessThan(0, Latin1General100CiAs.Compare("Aaronsburg", "'Aiea"));
80-
AreEqual(0, Latin1General100CiAs.Compare("'A", "A"));
81-
AreEqual(0, Latin1General100CiAs.Compare("O'Brien", "OBrien"));
83+
IsGreaterThan(0, Latin1General100CiAs.Compare("'A", "A"));
84+
IsGreaterThan(0, Latin1General100CiAs.Compare("O'Brien", "OBrien"));
8285
}
8386

8487
[TestMethod]
85-
public void Win100_HyphenIsPrimaryWeightZero_InSort()
88+
public void Win100_HyphenIsMinimalWeight_InSort()
8689
{
87-
AreEqual(0, Latin1General100CiAs.Compare("co-op", "coop"));
88-
AreEqual(0, Latin1General100CiAs.Compare("re-do", "redo"));
90+
// Same secondary-weight rule as the apostrophe: "coop" < "co-op",
91+
// "redo" < "re-do".
92+
IsGreaterThan(0, Latin1General100CiAs.Compare("co-op", "coop"));
93+
IsGreaterThan(0, Latin1General100CiAs.Compare("re-do", "redo"));
8994
}
9095

9196
[TestMethod]
@@ -127,7 +132,7 @@ public void Win80_BehavesLikeWin100_ForAsciiAndLatin1()
127132
// Latin scripts and a handful of newly-added supplementary code
128133
// points; none reach the regression bar).
129134
IsTrue(Latin1GeneralCiAs.Equals("abc", "ABC"));
130-
AreEqual(0, Latin1GeneralCiAs.Compare("'A", "A"));
135+
IsGreaterThan(0, Latin1GeneralCiAs.Compare("'A", "A"));
131136
IsFalse(Latin1GeneralCiAs.Equals("'A", "A"));
132137
IsLessThan(0, Latin1GeneralCiAs.Compare("Aaronsburg", "'Aiea"));
133138
}

SqlServerSimulator.Tests/CollationBehaviorTests.cs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace SqlServerSimulator;
55
/// <summary>
66
/// Public-surface coverage for the default collation's
77
/// (<c>SQL_Latin1_General_CP1_CI_AS</c>) case-folding, accent-sensitivity,
8-
/// and primary-weight-zero sort/equality rules — exercised through
8+
/// and minimal-weight (hyphen / apostrophe) sort/equality rules — exercised through
99
/// <c>=</c>, <c>ORDER BY</c>, <c>DISTINCT</c>, and <c>COLLATE</c>-name
1010
/// case-insensitive resolution. Counterpart to the internal-only
1111
/// <c>CollationTests</c>, which retains the algorithm-contract tests for
@@ -62,14 +62,15 @@ public void DefaultCollation_OrderBy_AsciiLowerVsUpper_IsCaseInsensitive()
6262
}
6363

6464
/// <summary>
65-
/// Probe-confirmed against SQL Server 2025: the default collation
66-
/// applies <c>IgnoreSymbols</c> in sort — apostrophe drops out of the
67-
/// primary sort key, so "'Aiea" sorts as "Aiea" which is greater than
68-
/// "Aaronsburg". Equality keeps symbols significant — the direct
69-
/// asymmetry probe lives in <c>CollationTests</c>.
65+
/// Probe-confirmed against SQL Server 2025: apostrophe and hyphen carry
66+
/// only a secondary sort weight, so they drop out of the *primary* key —
67+
/// "'Aiea" sorts as "Aiea", which is greater than "Aaronsburg". (Other
68+
/// symbols keep a real primary weight and sort ahead of letters; the
69+
/// minimal-weight asymmetry and the secondary tie-break live in
70+
/// <c>CollationTests</c>.) Equality keeps every symbol significant.
7071
/// </summary>
7172
[TestMethod]
72-
public void DefaultCollation_OrderBy_ApostropheIsIgnored()
73+
public void DefaultCollation_OrderBy_ApostropheHasMinimalWeight()
7374
{
7475
var sim = new Simulation();
7576
_ = sim.ExecuteNonQuery(
@@ -81,6 +82,46 @@ public void DefaultCollation_OrderBy_ApostropheIsIgnored()
8182
CollectionAssert.AreEqual(new[] { "Aaronsburg", "'Aiea" }, rows);
8283
}
8384

85+
/// <summary>
86+
/// Probe-confirmed against SQL Server 2025: symbols other than hyphen /
87+
/// apostrophe keep a real primary weight that sorts them ahead of digits
88+
/// and letters, so MIN of ('#500-75', '00,', 'abc') is '#500-75'. (An
89+
/// earlier ignore-all-symbols sort stripped the '#' and mis-ranked
90+
/// "#500-75" among the digits as "50075" — the divergence this guards.)
91+
/// </summary>
92+
[TestMethod]
93+
public void DefaultCollation_OrderBy_NonMinimalSymbolsSortFirst()
94+
{
95+
var sim = new Simulation();
96+
_ = sim.ExecuteNonQuery(
97+
"create table t (v nvarchar(20)); insert t values ('00,'), ('abc'), ('#500-75')");
98+
AreEqual("#500-75", sim.ExecuteScalar("select min(v) from t"));
99+
using var reader = sim.CreateCommand("select v from t order by v").ExecuteReader();
100+
var rows = new List<string>();
101+
while (reader.Read())
102+
rows.Add(reader.GetString(0));
103+
CollectionAssert.AreEqual(new[] { "#500-75", "00,", "abc" }, rows);
104+
}
105+
106+
/// <summary>
107+
/// The minimal-weight marks break ties only against an otherwise-identical
108+
/// neighbor: "coop" sorts before "co-op" (probe-confirmed). MIN therefore
109+
/// picks the mark-free spelling.
110+
/// </summary>
111+
[TestMethod]
112+
public void DefaultCollation_OrderBy_MinimalWeightBreaksTieAfterPlainSpelling()
113+
{
114+
var sim = new Simulation();
115+
_ = sim.ExecuteNonQuery(
116+
"create table t (v nvarchar(20)); insert t values ('co-op'), ('coop')");
117+
AreEqual("coop", sim.ExecuteScalar("select min(v) from t"));
118+
using var reader = sim.CreateCommand("select v from t order by v").ExecuteReader();
119+
var rows = new List<string>();
120+
while (reader.Read())
121+
rows.Add(reader.GetString(0));
122+
CollectionAssert.AreEqual(new[] { "coop", "co-op" }, rows);
123+
}
124+
84125
/// <summary>
85126
/// DISTINCT relies on the comparer's hash/equality contract: case-
86127
/// folded equivalents collapse to a single bucket, accent-distinct

SqlServerSimulator/Collation.cs

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,12 @@ internal static (Collation Collation, Coercibility Coercibility)? Resolve(SqlTyp
227227
/// <see cref="CompareInfo"/> and a flag-derived
228228
/// <see cref="CompareOptions"/> set; routes <see cref="Compare"/> /
229229
/// <see cref="Equals"/> / <see cref="GetHashCode"/> through them.
230-
/// Sort options layer <see cref="CompareOptions.IgnoreSymbols"/> on
231-
/// top of the equality options, matching real SQL Server's sort-only
232-
/// ignore-symbols treatment (probe-confirmed across the SQL_*,
233-
/// Windows, and locale families).
230+
/// Hyphen and apostrophe get the two-pass minimal-weight treatment in
231+
/// <see cref="Compare"/> (sort as if absent at the primary level, break
232+
/// ties only against an otherwise-identical neighbor); every other symbol
233+
/// keeps its real primary weight and sorts ahead of digits and letters.
234+
/// Probe-confirmed identical across the SQL_*, Windows, and locale
235+
/// families on SQL Server 2025 — see <c>docs/claude/collations.md</c>.
234236
/// </summary>
235237
internal sealed class CultureCollation : Collation
236238
{
@@ -244,8 +246,6 @@ internal sealed class CultureCollation : Collation
244246

245247
private readonly CompareOptions equalityOptions;
246248

247-
private readonly CompareOptions sortOptions;
248-
249249
private readonly Encoding storageEncoding;
250250

251251
private readonly bool isSupplementaryCharacterAware;
@@ -268,11 +268,6 @@ internal CultureCollation(string name, string description, string cultureName, b
268268
if (!kanaTypeSensitive) baseOpts |= CompareOptions.IgnoreKanaType;
269269
if (!widthSensitive) baseOpts |= CompareOptions.IgnoreWidth;
270270
this.equalityOptions = baseOpts;
271-
// Sort layers IgnoreSymbols on top — probe-confirmed against
272-
// SQL Server 2025: every modeled family (SQL_*, Windows-style,
273-
// locale) treats apostrophe / hyphen as primary-weight-zero in
274-
// sort, while equality keeps them significant.
275-
this.sortOptions = baseOpts | CompareOptions.IgnoreSymbols;
276271
}
277272

278273
public override string Name => this.name;
@@ -285,10 +280,75 @@ internal CultureCollation(string name, string description, string cultureName, b
285280

286281
internal override bool IsSupplementaryCharacterAware => this.isSupplementaryCharacterAware;
287282

288-
public override int Compare(string? x, string? y) =>
289-
x is null
290-
? (y is null ? 0 : -1)
291-
: y is null ? 1 : this.compareInfo.Compare(x, y, this.sortOptions);
283+
public override int Compare(string? x, string? y)
284+
{
285+
if (x is null)
286+
return y is null ? 0 : -1;
287+
if (y is null)
288+
return 1;
289+
290+
// Hyphen and apostrophe are the two marks SQL Server's collations
291+
// weight only at a secondary level: at the primary level they sort
292+
// as if absent (so "co-op" ranks beside "coop", "'Aiea" beside
293+
// "Aiea"), while every other symbol keeps a real primary weight
294+
// that sorts ahead of digits and letters. .NET exposes neither
295+
// knob granularly — IgnoreSymbols drops *all* punctuation, the
296+
// default keeps it all — so the two minimal marks are stripped for
297+
// the primary pass and consulted only to break an otherwise-exact
298+
// tie (the copy carrying one sorts after: "coop" < "co-op").
299+
if (!ContainsMinimalPunctuation(x) && !ContainsMinimalPunctuation(y))
300+
return this.compareInfo.Compare(x, y, this.equalityOptions);
301+
var primary = this.compareInfo.Compare(
302+
StripMinimalPunctuation(x), StripMinimalPunctuation(y), this.equalityOptions);
303+
return primary != 0 ? primary : MinimalPunctuationTiebreak(x, y);
304+
}
305+
306+
private static bool IsMinimalPunctuation(char c) => c is '-' or '\'';
307+
308+
private static bool ContainsMinimalPunctuation(string s)
309+
{
310+
foreach (var c in s)
311+
{
312+
if (IsMinimalPunctuation(c))
313+
return true;
314+
}
315+
316+
return false;
317+
}
318+
319+
private static string StripMinimalPunctuation(string s)
320+
{
321+
var buffer = s.Length <= 256 ? stackalloc char[s.Length] : new char[s.Length];
322+
var count = 0;
323+
foreach (var c in s)
324+
{
325+
if (!IsMinimalPunctuation(c))
326+
buffer[count++] = c;
327+
}
328+
329+
return new string(buffer[..count]);
330+
}
331+
332+
// Primary-equal strings differ only in their hyphen / apostrophe
333+
// content; the copy carrying such a mark where the other has a real
334+
// character (or its end) sorts later, matching "coop" &lt; "co-op",
335+
// "cant" &lt; "can't", "A" &lt; "'A".
336+
private static int MinimalPunctuationTiebreak(string x, string y)
337+
{
338+
var i = 0;
339+
var j = 0;
340+
while (i < x.Length && j < y.Length)
341+
{
342+
var xMinimal = IsMinimalPunctuation(x[i]);
343+
var yMinimal = IsMinimalPunctuation(y[j]);
344+
if (xMinimal != yMinimal)
345+
return xMinimal ? 1 : -1;
346+
i++;
347+
j++;
348+
}
349+
350+
return (x.Length - i).CompareTo(y.Length - j);
351+
}
292352

293353
public override bool Equals(string? x, string? y) =>
294354
x is null

docs/claude/collations.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,26 @@ The override registry (`overrides` ConcurrentDictionary in `Collation.Parser.cs`
119119

120120
### Behavioral notes by family
121121

122-
- **SQL_\* family**: routed through invariant `CompareInfo` (unless the human-prefix description maps to a locale-specific culture, e.g., `SQL_Croatian_CP1250_CI_AS``hr-HR`); same IgnoreSymbols-in-sort treatment as the Windows family. Description carries the per-name SQL Server Sort Order number + Code Page (extracted from the `CP*` token).
123-
- **Windows-style Latin1_General**: invariant `CompareInfo`; IgnoreSymbols layered on sort options. `_BIN` engages the pre-2005 position-0-codeunit / position-1+-codepoint quirk; `_BIN2` is pure UTF-16 code-unit ordinal.
122+
- **SQL_\* family**: routed through invariant `CompareInfo` (unless the human-prefix description maps to a locale-specific culture, e.g., `SQL_Croatian_CP1250_CI_AS``hr-HR`); same two-pass minimal-punctuation sort treatment as the Windows family (see [Symbol sort weighting](#symbol-sort-weighting)). Description carries the per-name SQL Server Sort Order number + Code Page (extracted from the `CP*` token).
123+
- **Windows-style Latin1_General**: invariant `CompareInfo`; two-pass minimal-punctuation sort. `_BIN` engages the pre-2005 position-0-codeunit / position-1+-codepoint quirk; `_BIN2` is pure UTF-16 code-unit ordinal.
124124
- **`_UTF8` collations**: storage encoding flips from CP1252 to UTF-8 for varchar/char columns. `_BIN2_UTF8` substitutes `Utf8CodepointBinaryCollation` (codepoint-order = UTF-8 byte order) on varchar storage.
125125
- **`_SC_` collations** (and v140+ implicitly): set `IsSupplementaryCharacterAware` on the constructed instance, driving codepoint-aware LEN/SUBSTRING/etc. dispatch (see [`_SC_` function-semantics dispatch](#_sc_-function-semantics-dispatch)).
126126
- **`_KS_` / `_WS_` flags**: flip `CompareOptions.IgnoreKanaType` / `IgnoreWidth` off (default = both on).
127127
- **Locale prefixes** (Japanese, Chinese, Turkish, Korean, etc.): map to the closest .NET culture via `KnownPrefixes`; fall back to invariant when no clean .NET equivalent exists (Tamazight, Traditional_Spanish, Indic_General). Sort-parity caveat in [Locale-comparer sort-parity gap](#locale-comparer-sort-parity-gap) applies — equality / CI/CS / KS / WS folding align, secondary sort tiebreakers within equivalence classes may diverge.
128128

129+
## Symbol sort weighting
130+
131+
`CultureCollation.Compare` (the `CompareInfo`-routed comparer behind every SQL_\*, Windows, and locale family) gives hyphen (`-`) and apostrophe (`'`) the **minimal-weight** treatment SQL Server applies, while every other symbol keeps a real primary weight. Probe-confirmed identical across `SQL_Latin1_General_CP1_CI_AS`, `Latin1_General_100_CI_AS`, and `Latin1_General_CI_AS` on SQL Server 2025:
132+
133+
- **Non-minimal symbols (`#`, `+`, `,`, `!`, `~`, `_`, …) sort first** — ahead of digits and letters. So `MIN('#500-75', '00,', 'abc')` is `'#500-75'`. .NET's `CompareOptions.IgnoreSymbols` would *strip* these (mis-ranking `'#500-75'` among the digits as `50075`); plain `CompareInfo` without it keeps them, which is what the comparer uses.
134+
- **Hyphen and apostrophe drop out of the primary key**`'co-op'` ranks beside `'coop'`, `"'Aiea"` beside `'Aiea'` — but carry a secondary weight, so between two strings sharing a primary key the copy bearing the mark sorts *after*: `'coop' < 'co-op'`, `'cant' < "can't"`, `'A' < "'A"`.
135+
136+
Implementation: a fast path (`compareInfo.Compare(x, y, equalityOptions)`) when neither operand contains a minimal mark; otherwise a primary pass over hyphen/apostrophe-stripped copies, then `MinimalPunctuationTiebreak` (a two-pointer scan where a minimal mark sorts after a real character). Equality and `GetHashCode` keep *every* symbol significant (only trailing spaces fold, handled at the `SqlValue` layer) — so `'co-op' = 'coop'` is false, matching real SQL Server.
137+
138+
### Known gap: varchar symbol order under SQL_\* collations
139+
140+
`SQL_*` collations sort **non-Unicode (`varchar`/`char`) data through a CP1252 code-page sort table** that differs from their Unicode (`nvarchar`/`nchar`) rules — the same collation name orders the same characters differently by storage type. Probe-confirmed: under `SQL_Latin1_General_CP1_CI_AS`, `varchar` sorts `'+' < '/'` but `nvarchar` sorts `'/' < '+'`. The simulator routes both through one Unicode `CompareInfo`, so it matches the nvarchar order for both. Bites base64-bearing `varchar` columns (`+`/`/` are base64's two special chars) — e.g. AdventureWorks `Person.Password.PasswordHash` (`varchar`), where live `MIN(PasswordHash)` starts `++…` (code-page order) but the simulator picks `//…` (Unicode order). Closing it requires the CP1252 SQL sort-order weight table for varchar storage, which the simulator doesn't ship.
141+
129142
## Locale-comparer sort-parity gap
130143

131144
Probed against SQL Server 2025 with a curated word set per locale (mixed-case ASCII, accented Latin, hiragana / katakana / half-width katakana, common CJK characters and 2-character compounds). For each `(collation, storage)` pair, ORDER BY result vs `CompareInfo.Compare` ordering compared position-by-position:

0 commit comments

Comments
 (0)