Skip to content

Commit 50c1e5f

Browse files
committed
Made SQL_Latin1_General_CP1_CI_AS as accurate as possible through extensive analysis and reverse-engineering of the official implementation.
1 parent 6d68901 commit 50c1e5f

6 files changed

Lines changed: 498 additions & 73 deletions

File tree

SqlServerSimulator.Tests.Internal/CollationTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,40 @@ public void Sql_NullHandling()
5252
IsGreaterThan(0, Collation.Baseline.Compare("x", null));
5353
}
5454

55+
/// <summary>
56+
/// The default collation routes through the byte-exact
57+
/// <c>SqlLatin1Cp1CiAsCollation</c> override, which sorts varchar and
58+
/// nvarchar through different probe-extracted weight tables. The two
59+
/// storage families disagree on symbol order (varchar <c>'+'</c> &lt;
60+
/// <c>'/'</c>; nvarchar <c>'/'</c> &lt; <c>'+'</c>) and on ligature
61+
/// expansion (both expand <c>æ</c>, but only nvarchar expands <c>ß</c> to
62+
/// <c>ss</c> — varchar keeps it a single letter). <c>ForVarcharStorage</c>
63+
/// returns the varchar-flavored body. Probe-confirmed on SQL Server 2025.
64+
/// </summary>
65+
[TestMethod]
66+
public void Sql_VarcharAndNvarcharSortThroughDistinctTables()
67+
{
68+
var nvarchar = Collation.Baseline;
69+
var varchar = Collation.Baseline.ForVarcharStorage();
70+
71+
IsLessThan(0, varchar.Compare("+", "/"));
72+
IsGreaterThan(0, nvarchar.Compare("+", "/"));
73+
74+
// æ expands to "ae" in both: 'æx' (=> 'aex') sorts between 'ad' and 'af'.
75+
IsGreaterThan(0, nvarchar.Compare("æx", "ad"));
76+
IsLessThan(0, nvarchar.Compare("æx", "af"));
77+
IsGreaterThan(0, varchar.Compare("æx", "ad"));
78+
IsLessThan(0, varchar.Compare("æx", "af"));
79+
80+
// ß expands to "ss" only in nvarchar.
81+
AreEqual(0, nvarchar.Compare("ß", "ss"));
82+
AreNotEqual(0, varchar.Compare("ß", "ss"));
83+
84+
// Cedilla folds onto its base letter at the primary level in both.
85+
IsLessThan(0, varchar.Compare("Çm", "cn"));
86+
IsLessThan(0, nvarchar.Compare("Çm", "cn"));
87+
}
88+
5589
// ---- Latin1_General_100_CI_AS (Windows-style v100) ----
5690

5791
[TestMethod]

SqlServerSimulator.Tests/CollationBehaviorTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,49 @@ public void DefaultCollation_Distinct_HashContractAgreesWithEquals()
142142
AreEqual(2, sim.ExecuteScalar("select count(*) from (select distinct v from accent) d"));
143143
}
144144

145+
/// <summary>
146+
/// The default collation sorts at two levels: primary (accent-folded base
147+
/// letter) then a secondary accent tie-break. So <c>'à'</c> orders before
148+
/// <c>'Ao'</c> (base <c>a</c> precedes <c>Ao</c>) even though the accented
149+
/// letter sorts after its plain form within a tie (<c>'az'</c> &lt;
150+
/// <c>'àz'</c>). Probe-confirmed against SQL Server 2025; this is the level
151+
/// a single-rank table can't express.
152+
/// </summary>
153+
[TestMethod]
154+
public void DefaultCollation_OrderBy_AccentIsSecondaryWeight()
155+
{
156+
var sim = new Simulation();
157+
_ = sim.ExecuteNonQuery(
158+
"create table t (v nvarchar(20)); insert t values ('Ao'), ('à'), ('az'), ('àz')");
159+
using var reader = sim.CreateCommand("select v from t order by v").ExecuteReader();
160+
var rows = new List<string>();
161+
while (reader.Read())
162+
rows.Add(reader.GetString(0));
163+
CollectionAssert.AreEqual(new[] { "à", "Ao", "az", "àz" }, rows);
164+
}
165+
166+
/// <summary>
167+
/// nvarchar expands the Latin ligatures to their base letters (probe-
168+
/// confirmed <c>'æ' = 'ae'</c>, <c>'ß' = 'ss'</c>); varchar's legacy sort
169+
/// order expands only <c>æ</c>/<c>Æ</c> and treats <c>œ</c>/<c>ß</c> as
170+
/// distinct single-weight letters. Here MIN under nvarchar collapses
171+
/// <c>'æ'</c> against <c>'ae'</c> and orders it between <c>'ad'</c> and
172+
/// <c>'af'</c>.
173+
/// </summary>
174+
[TestMethod]
175+
public void DefaultCollation_OrderBy_NvarcharExpandsLigatures()
176+
{
177+
var sim = new Simulation();
178+
_ = sim.ExecuteNonQuery(
179+
"create table t (v nvarchar(20)); insert t values ('ad'), ('af'), ('æx')");
180+
using var reader = sim.CreateCommand("select v from t order by v").ExecuteReader();
181+
var rows = new List<string>();
182+
while (reader.Read())
183+
rows.Add(reader.GetString(0));
184+
// 'æx' expands to 'aex', which sorts between 'ad' and 'af'.
185+
CollectionAssert.AreEqual(new[] { "ad", "æx", "af" }, rows);
186+
}
187+
145188
[TestMethod]
146189
public void CollationName_LookupIsCaseInsensitive()
147190
{

SqlServerSimulator/Collation.Parser.cs

Lines changed: 18 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,6 @@ private enum CollationFlags : ushort
4141
VariationSelectorSensitive = 1 << 10,
4242
}
4343

44-
/// <summary>
45-
/// Per-name override registry. Populated by <see cref="RegisterOverride"/>
46-
/// at static-init time. Names in this map bypass <see cref="TryParse"/>
47-
/// and return the registered instance directly — the extension point
48-
/// for hand-tuned bodies with quirky semantics that the parser's
49-
/// generic <see cref="CultureCollation"/> / <see cref="BinaryCollationBody"/>
50-
/// constructions don't capture.
51-
/// </summary>
52-
private static readonly ConcurrentDictionary<string, Collation> overrides =
53-
new(StringComparer.OrdinalIgnoreCase);
54-
5544
/// <summary>
5645
/// Interning cache for parser-derived collation instances. Keyed by the
5746
/// canonical (uppercase) name; <see cref="TryGet"/> inserts on first
@@ -64,41 +53,29 @@ private enum CollationFlags : ushort
6453
/// <summary>
6554
/// Returns the recognized <see cref="Collation"/> for <paramref name="name"/>,
6655
/// or <see langword="null"/> if the name isn't grammatically valid +
67-
/// doesn't carry a known prefix. Override-registry entries take
68-
/// precedence over the parser; subsequent calls with the same name
69-
/// return the same reference (interned).
56+
/// doesn't carry a known prefix. Subsequent calls with the same name
57+
/// return the same reference (interned). The default collation name is
58+
/// special-cased inside <see cref="CreateInstance"/> to wrap its comparer
59+
/// in the byte-exact <see cref="SqlLatin1Cp1CiAsCollation"/>.
7060
/// </summary>
7161
internal static Collation? TryGet(string name) =>
7262
string.IsNullOrEmpty(name)
7363
? null
74-
: overrides.TryGetValue(name, out var ov)
75-
? ov
76-
: interned.TryGetValue(name, out var hit)
77-
? hit
78-
: TryParse(name, out var parsed) ? interned.GetOrAdd(parsed.Name, parsed) : null;
79-
80-
/// <summary>
81-
/// Adds <paramref name="specialized"/> to the override registry under
82-
/// its <see cref="Name"/>. Overrides take precedence over parser-derived
83-
/// instances; the slot supports per-name behavior tuning when the
84-
/// parser's generic body doesn't match real SQL Server's quirks for
85-
/// that specific collation.
86-
/// </summary>
87-
private static void RegisterOverride(Collation specialized) =>
88-
overrides[specialized.Name] = specialized;
64+
: interned.TryGetValue(name, out var hit)
65+
? hit
66+
: TryParse(name, out var parsed) ? interned.GetOrAdd(parsed.Name, parsed) : null;
8967

9068
/// <summary>
9169
/// True if <paramref name="name"/> is a recognized SQL Server collation
92-
/// name — either in the override registry or grammatically parseable
93-
/// with a known prefix. Replaces the legacy whitelist check.
70+
/// name — grammatically parseable with a known prefix. Replaces the legacy
71+
/// whitelist check.
9472
/// </summary>
9573
internal static bool IsRecognized(string name) => TryGet(name) is not null;
9674

9775
/// <summary>
9876
/// Enumerates every recognized collation name with its description
9977
/// (the form <c>sys.fn_helpcollations()</c> exposes). Crosses the
100-
/// SQL_* sort-order table, the per-prefix tail-set patterns, and any
101-
/// override-registry entries that fall outside those (rare). Ordering
78+
/// SQL_* sort-order table and the per-prefix tail-set patterns. Ordering
10279
/// is the caller's responsibility.
10380
/// </summary>
10481
internal static IEnumerable<(string Name, string Description)> EnumerateRecognized()
@@ -121,33 +98,6 @@ private static void RegisterOverride(Collation specialized) =>
12198
yield return (name, c.Description);
12299
}
123100
}
124-
125-
// Override entries whose names don't appear in either slice.
126-
foreach (var (name, c) in overrides)
127-
{
128-
if (!SqlServerSortOrders.ContainsKey(name) && !IsInPatternCatalog(name))
129-
yield return (name, c.Description);
130-
}
131-
}
132-
133-
/// <summary>
134-
/// Tests whether <paramref name="name"/> falls in the per-prefix tail
135-
/// catalog — used to avoid double-emitting an override that the
136-
/// pattern enumeration would also produce.
137-
/// </summary>
138-
private static bool IsInPatternCatalog(string name)
139-
{
140-
foreach (var (prefix, patternIdx) in PrefixToPattern)
141-
{
142-
if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
143-
&& name.Length > prefix.Length
144-
&& name[prefix.Length] == '_'
145-
&& GetPatternTails(patternIdx).Contains(name[prefix.Length..]))
146-
{
147-
return true;
148-
}
149-
}
150-
return false;
151101
}
152102

153103
/// <summary>
@@ -338,7 +288,14 @@ private static Collation CreateInstance(string name, string prefix, PrefixInfo p
338288
// SC behavior is engaged when the _SC_ flag is set explicitly or
339289
// when the version is v140+ (where SC is implicit / default).
340290
var scAware = flags.HasFlag(CollationFlags.SupplementaryCharacters) || version is >= 140;
341-
return new CultureCollation(name, description, prefixInfo.CultureName, caseSensitive, kanaSensitive, widthSensitive, storageEncoding, scAware);
291+
var cultureBody = new CultureCollation(name, description, prefixInfo.CultureName, caseSensitive, kanaSensitive, widthSensitive, storageEncoding, scAware);
292+
293+
// The default collation gets a byte-exact sort body wrapping the
294+
// generic culture comparer (which still supplies metadata + the
295+
// non-CP1252 fallback). See Collation.SqlLatin1Sort.cs.
296+
return name.Equals(SqlLatin1Cp1CiAsCollation.CollationName, StringComparison.OrdinalIgnoreCase)
297+
? new SqlLatin1Cp1CiAsCollation(cultureBody)
298+
: cultureBody;
342299
}
343300

344301
/// <summary>

0 commit comments

Comments
 (0)