Skip to content

Commit d736d0b

Browse files
committed
Optimizations and refinement to collations.
1 parent 50c1e5f commit d736d0b

5 files changed

Lines changed: 177 additions & 89 deletions

File tree

SqlServerSimulator.Tests.Internal/CollationTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ namespace SqlServerSimulator;
2222
[TestClass]
2323
public sealed class CollationTests
2424
{
25-
private static readonly Collation Latin1General100CiAs = Collation.TryGet("Latin1_General_100_CI_AS")!;
25+
private static readonly Collation Latin1General100CiAs = Collation.Get("Latin1_General_100_CI_AS");
2626

27-
private static readonly Collation Latin1GeneralCiAs = Collation.TryGet("Latin1_General_CI_AS")!;
27+
private static readonly Collation Latin1GeneralCiAs = Collation.Get("Latin1_General_CI_AS");
2828

29-
private static readonly Collation Latin1GeneralCsAs = Collation.TryGet("Latin1_General_CS_AS")!;
29+
private static readonly Collation Latin1GeneralCsAs = Collation.Get("Latin1_General_CS_AS");
3030

31-
private static readonly Collation Latin1GeneralBin = Collation.TryGet("Latin1_General_BIN")!;
31+
private static readonly Collation Latin1GeneralBin = Collation.Get("Latin1_General_BIN");
3232

3333
// ---- SQL_Latin1_General_CP1_CI_AS — internal-only contracts ----
3434

SqlServerSimulator/Collation.Parser.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Concurrent;
2+
using System.Diagnostics;
23
using System.Diagnostics.CodeAnalysis;
34
using System.Globalization;
45
using System.Text;
@@ -65,6 +66,21 @@ private enum CollationFlags : ushort
6566
? hit
6667
: TryParse(name, out var parsed) ? interned.GetOrAdd(parsed.Name, parsed) : null;
6768

69+
/// <summary>
70+
/// Resolves a collation name guaranteed valid at the call site — a
71+
/// compile-time constant naming a collation real SQL Server ships. The
72+
/// <c>Debug.Assert</c> is a refactoring tripwire: a regression in the
73+
/// parser tables fails loud and early under the (Debug-built) test
74+
/// suite, where collation resolution is among the hottest paths. Use
75+
/// <see cref="TryGet"/> for any name that originates from SQL text.
76+
/// </summary>
77+
internal static Collation Get(string name)
78+
{
79+
var collation = TryGet(name);
80+
Debug.Assert(collation is not null, $"Collation '{name}' is expected to always resolve but did not.");
81+
return collation;
82+
}
83+
6884
/// <summary>
6985
/// True if <paramref name="name"/> is a recognized SQL Server collation
7086
/// name — grammatically parseable with a known prefix. Replaces the legacy

SqlServerSimulator/Collation.SqlLatin1Sort.cs

Lines changed: 110 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -247,24 +247,28 @@ public override int GetHashCode(string obj)
247247
{
248248
if (!AllCp1252(obj))
249249
return this.inner.GetHashCode(obj);
250-
var (primary, secondary, _) = this.BuildKey(obj);
250+
251+
// Hash the primary weight run, a separator, then the secondary run —
252+
// mirrors the order Compare consults them, so Compare-equal strings
253+
// (equal at both levels) always hash equal. Streamed through the
254+
// cursor so no weight lists are materialized.
251255
var hash = new HashCode();
252-
foreach (var weight in primary)
253-
hash.Add(weight);
256+
var primary = this.NewCursor(obj);
257+
while (primary.MoveNext())
258+
hash.Add(primary.Primary);
254259
hash.Add(-1);
255-
foreach (var weight in secondary)
256-
hash.Add(weight);
260+
var secondary = this.NewCursor(obj);
261+
while (secondary.MoveNext())
262+
hash.Add(secondary.Secondary);
257263
return hash.ToHashCode();
258264
}
259265

260266
private int CompareInRepertoire(string x, string y)
261267
{
262-
var (px, sx, tx) = this.BuildKey(x);
263-
var (py, sy, ty) = this.BuildKey(y);
264-
var primary = LexCompare(px, py);
268+
var primary = this.CompareLevel(x, y, Level.Primary);
265269
if (primary != 0)
266270
return primary;
267-
var secondary = LexCompare(sx, sy);
271+
var secondary = this.CompareLevel(x, y, Level.Secondary);
268272
if (secondary != 0)
269273
return secondary;
270274

@@ -273,58 +277,117 @@ private int CompareInRepertoire(string x, string y)
273277
// it has no minimal-weight characters. nvarchar treats a ligature as
274278
// equal to its expansion and instead breaks the tie on its
275279
// minimal-weight characters.
276-
return this.varcharStorage ? LexCompare(tx, ty) : NvarcharIgnorableTiebreak(x, y);
280+
return this.varcharStorage ? this.CompareLevel(x, y, Level.Tertiary) : NvarcharIgnorableTiebreak(x, y);
277281
}
278282

279-
// Builds the primary, secondary, and tertiary weight sequences (all
280-
// parallel). Ignorable characters (nvarchar only) contribute to none;
281-
// ligatures expand to their base letters' weights, and each expansion
282-
// element carries tertiary weight 1 (plain characters carry 0) so a
283-
// ligature outranks the identical run of plain letters.
284-
private (List<int> Primary, List<int> Secondary, List<int> Tertiary) BuildKey(string s)
283+
private enum Level
285284
{
286-
var weights = this.varcharStorage ? varcharWeights : nvarcharWeights;
287-
var expansions = this.varcharStorage ? varcharExpansion : nvarcharExpansion;
288-
var primary = new List<int>(s.Length);
289-
var secondary = new List<int>(s.Length);
290-
var tertiary = new List<int>(s.Length);
291-
foreach (var ch in s)
285+
Primary,
286+
Secondary,
287+
Tertiary,
288+
}
289+
290+
// Walks both operands through parallel weight cursors, comparing one
291+
// level at a time. The shorter weight run sorts first once a shared
292+
// prefix ties (matching the old list-length tie-break). The cursors are
293+
// ref structs over the source strings — no weight lists are allocated,
294+
// so the common single-pass primary comparison is allocation-free.
295+
private int CompareLevel(string x, string y, Level level)
296+
{
297+
var cx = this.NewCursor(x);
298+
var cy = this.NewCursor(y);
299+
while (true)
300+
{
301+
var hasX = cx.MoveNext();
302+
var hasY = cy.MoveNext();
303+
if (!hasX || !hasY)
304+
return (hasX ? 1 : 0) - (hasY ? 1 : 0);
305+
var wx = level switch { Level.Primary => cx.Primary, Level.Secondary => cx.Secondary, _ => cx.Tertiary };
306+
var wy = level switch { Level.Primary => cy.Primary, Level.Secondary => cy.Secondary, _ => cy.Tertiary };
307+
if (wx != wy)
308+
return wx < wy ? -1 : 1;
309+
}
310+
}
311+
312+
private WeightCursor NewCursor(string s) =>
313+
this.varcharStorage
314+
? new WeightCursor(s, varcharWeights, varcharExpansion, skipIgnorable: false)
315+
: new WeightCursor(s, nvarcharWeights, nvarcharExpansion, skipIgnorable: true);
316+
317+
// Yields the (primary, secondary, tertiary) weight of each collation
318+
// element of a string in order: ignorables (nvarchar) are skipped,
319+
// ligatures expand to their base letters with tertiary 1, plain
320+
// characters carry tertiary 0. One element per MoveNext; the expansion
321+
// of a ligature surfaces as consecutive elements.
322+
private ref struct WeightCursor
323+
{
324+
private readonly string s;
325+
326+
private readonly FrozenDictionary<char, (int Primary, int Secondary)> weights;
327+
328+
private readonly FrozenDictionary<char, string> expansions;
329+
330+
private readonly bool skipIgnorable;
331+
332+
private int index;
333+
334+
private string? expansion;
335+
336+
private int expansionPos;
337+
338+
internal int Primary;
339+
340+
internal int Secondary;
341+
342+
internal int Tertiary;
343+
344+
internal WeightCursor(string s, FrozenDictionary<char, (int Primary, int Secondary)> weights, FrozenDictionary<char, string> expansions, bool skipIgnorable)
345+
{
346+
this.s = s;
347+
this.weights = weights;
348+
this.expansions = expansions;
349+
this.skipIgnorable = skipIgnorable;
350+
}
351+
352+
internal bool MoveNext()
292353
{
293-
if (!this.varcharStorage && nvarcharIgnorable.Contains(ch))
294-
continue;
295-
if (expansions.TryGetValue(ch, out var expansion))
354+
if (this.expansion is not null)
296355
{
297-
foreach (var baseChar in expansion)
356+
this.Emit(this.expansion[this.expansionPos++], tertiary: 1);
357+
if (this.expansionPos >= this.expansion.Length)
358+
this.expansion = null;
359+
return true;
360+
}
361+
362+
while (this.index < this.s.Length)
363+
{
364+
var ch = this.s[this.index++];
365+
if (this.skipIgnorable && nvarcharIgnorable.Contains(ch))
366+
continue;
367+
if (this.expansions.TryGetValue(ch, out var exp))
298368
{
299-
var (basePrimary, baseSecondary) = weights[baseChar];
300-
primary.Add(basePrimary);
301-
secondary.Add(baseSecondary);
302-
tertiary.Add(1);
369+
this.expansion = exp;
370+
this.expansionPos = 0;
371+
this.Emit(exp[this.expansionPos++], tertiary: 1);
372+
if (this.expansionPos >= exp.Length)
373+
this.expansion = null;
374+
return true;
303375
}
304376

305-
continue;
377+
this.Emit(ch, tertiary: 0);
378+
return true;
306379
}
307380

308-
var (charPrimary, charSecondary) = weights[ch];
309-
primary.Add(charPrimary);
310-
secondary.Add(charSecondary);
311-
tertiary.Add(0);
381+
return false;
312382
}
313383

314-
return (primary, secondary, tertiary);
315-
}
316-
317-
private static int LexCompare(List<int> a, List<int> b)
318-
{
319-
var shared = Math.Min(a.Count, b.Count);
320-
for (var i = 0; i < shared; i++)
384+
private void Emit(char ch, int tertiary)
321385
{
322-
var delta = a[i] - b[i];
323-
if (delta != 0)
324-
return delta < 0 ? -1 : 1;
386+
var (primary, secondary) = this.weights[ch];
387+
this.Primary = primary;
388+
this.Secondary = secondary;
389+
this.Tertiary = tertiary;
325390
}
326-
327-
return a.Count.CompareTo(b.Count);
328391
}
329392

330393
// Reached only when the primary and secondary keys are equal: the two

SqlServerSimulator/Collation.cs

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ internal enum Coercibility : byte
3333
/// The SQL Server equivalent to .NET's <see cref="IComparer{T}"/> for strings.
3434
/// Concrete subclasses are constructed by the parser
3535
/// (<see cref="TryParse(string, out Collation?)"/>) from the grammatical
36-
/// shape of the collation name; hand-tuned bespoke bodies can be registered
37-
/// via the override slot for names whose behavior diverges from the
38-
/// grammar-derived default. See <c>docs/claude/collations.md</c> for the
39-
/// catalog, parser, and override-slot details.
36+
/// shape of the collation name. One name whose sort diverges from the
37+
/// grammar-derived default gets a hand-tuned body: the parser special-cases
38+
/// <c>SQL_Latin1_General_CP1_CI_AS</c> and wraps its comparer in the
39+
/// byte-exact <see cref="SqlLatin1Cp1CiAsCollation"/>. See
40+
/// <c>docs/claude/collations.md</c> for the catalog and parser details.
4041
/// </summary>
4142
internal abstract partial class Collation : IComparer<string>, IEqualityComparer<string>
4243
{
@@ -49,8 +50,9 @@ private protected Collation()
4950
/// <summary>
5051
/// Human-readable description of this collation — matches the format
5152
/// emitted by <c>sys.fn_helpcollations()</c>. Generated by
52-
/// <see cref="BuildDescription"/> for parser-derived instances;
53-
/// override-registry entries supply their own text.
53+
/// <see cref="BuildDescription"/> for parser-derived instances; the
54+
/// byte-exact <see cref="SqlLatin1Cp1CiAsCollation"/> delegates to the
55+
/// inner parser-built body for its text.
5456
/// </summary>
5557
public abstract string Description { get; }
5658

@@ -68,7 +70,7 @@ private protected Collation()
6870
/// <summary>
6971
/// The simulator's hardcoded baseline collation —
7072
/// <c>SQL_Latin1_General_CP1_CI_AS</c>. Resolved once via
71-
/// <see cref="TryGet"/>. Two consumers:
73+
/// <see cref="Get"/>. Two consumers:
7274
/// <list type="bullet">
7375
/// <item>The initial value of
7476
/// <see cref="Simulation.ServerCollation"/>, which in turn seeds every
@@ -100,7 +102,7 @@ private protected Collation()
100102
/// the simulator's catalog views (<c>sys.objects.type_desc</c>,
101103
/// <c>sys.database_permissions.permission_name</c>, the various
102104
/// <c>type</c> / <c>state</c> char(1) / char(2) enum codes, etc.).
103-
/// Resolved once via <see cref="TryGet"/> to
105+
/// Resolved once via <see cref="Get"/> to
104106
/// <c>Latin1_General_100_CI_AS_KS_WS_SC</c> — the contained-database
105107
/// catalog collation real SQL Server reports through
106108
/// <c>sys.fn_helpcollations()</c>.
@@ -143,10 +145,10 @@ private protected Collation()
143145
// and the textual-order guarantee for static-field initializers
144146
// doesn't cross those.
145147
private static readonly Lazy<Collation> baselineLazy =
146-
new(() => TryGet("SQL_Latin1_General_CP1_CI_AS") ?? throw new InvalidOperationException("Default collation failed to resolve."));
148+
new(() => Get("SQL_Latin1_General_CP1_CI_AS"));
147149

148150
private static readonly Lazy<Collation> catalogLazy =
149-
new(() => TryGet("Latin1_General_100_CI_AS_KS_WS_SC") ?? throw new InvalidOperationException("Catalog collation failed to resolve."));
151+
new(() => Get("Latin1_General_100_CI_AS_KS_WS_SC"));
150152

151153
/// <summary>
152154
/// SQL Server's collation-coercibility resolution for two operands.
@@ -222,6 +224,26 @@ internal static (Collation Collation, Coercibility Coercibility)? Resolve(SqlTyp
222224
/// </summary>
223225
internal virtual Encoding StorageEncoding => CharSqlType.Cp1252Encoder;
224226

227+
/// <summary>
228+
/// Reads the Unicode scalar at <paramref name="i"/>: a surrogate pair
229+
/// combines into one 32-bit code point (<paramref name="advance"/> = 2),
230+
/// otherwise the bare code unit (<paramref name="advance"/> = 1). Shared by
231+
/// the code-point-ordered binary bodies (pre-Bin2 positions 1+ and the
232+
/// UTF-8 body).
233+
/// </summary>
234+
private static int ScalarAt(string s, int i, out int advance)
235+
{
236+
var c = s[i];
237+
if (char.IsHighSurrogate(c) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1]))
238+
{
239+
advance = 2;
240+
return char.ConvertToUtf32(c, s[i + 1]);
241+
}
242+
243+
advance = 1;
244+
return c;
245+
}
246+
225247
/// <summary>
226248
/// Generic culture-driven collation. Pins a
227249
/// <see cref="CompareInfo"/> and a flag-derived
@@ -441,18 +463,6 @@ private static int ComparePreBin2(string x, string y)
441463
}
442464
return x.Length.CompareTo(y.Length);
443465
}
444-
445-
private static int ScalarAt(string s, int i, out int advance)
446-
{
447-
var c = s[i];
448-
if (char.IsHighSurrogate(c) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1]))
449-
{
450-
advance = 2;
451-
return char.ConvertToUtf32(c, s[i + 1]);
452-
}
453-
advance = 1;
454-
return c;
455-
}
456466
}
457467

458468
/// <summary>
@@ -494,14 +504,23 @@ x is null
494504

495505
public override int GetHashCode(string obj)
496506
{
497-
var bytes = CharSqlType.Cp1252Encoder.GetBytes(obj);
507+
// CP1252 is single-byte, so the byte count equals the char count.
508+
var buffer = obj.Length <= 256 ? stackalloc byte[obj.Length] : new byte[obj.Length];
509+
var length = CharSqlType.Cp1252Encoder.GetBytes(obj, buffer);
498510
var hash = default(HashCode);
499-
hash.AddBytes(bytes);
511+
hash.AddBytes(buffer[..length]);
500512
return hash.ToHashCode();
501513
}
502514

503-
private static int CompareBytes(string x, string y) =>
504-
CharSqlType.Cp1252Encoder.GetBytes(x).AsSpan().SequenceCompareTo(CharSqlType.Cp1252Encoder.GetBytes(y));
515+
private static int CompareBytes(string x, string y)
516+
{
517+
var encoding = CharSqlType.Cp1252Encoder;
518+
var xBuffer = x.Length <= 256 ? stackalloc byte[x.Length] : new byte[x.Length];
519+
var yBuffer = y.Length <= 256 ? stackalloc byte[y.Length] : new byte[y.Length];
520+
var xLength = encoding.GetBytes(x, xBuffer);
521+
var yLength = encoding.GetBytes(y, yBuffer);
522+
return xBuffer[..xLength].SequenceCompareTo(yBuffer[..yLength]);
523+
}
505524
}
506525

507526
/// <summary>
@@ -561,17 +580,5 @@ public override int GetHashCode(string obj)
561580
hash.AddBytes(bytes);
562581
return hash.ToHashCode();
563582
}
564-
565-
private static int ScalarAt(string s, int i, out int advance)
566-
{
567-
var c = s[i];
568-
if (char.IsHighSurrogate(c) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1]))
569-
{
570-
advance = 2;
571-
return char.ConvertToUtf32(c, s[i + 1]);
572-
}
573-
advance = 1;
574-
return c;
575-
}
576583
}
577584
}

docs/claude/collations.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ Why a bespoke body instead of `CompareInfo`: real SQL Server sorts this collatio
139139

140140
One hand-adjustment in the data: the legacy varchar CI_AI form classifies cedilla (`Ç`/`ç`) as a distinct primary letter, but its CI_AS *sort* folds it onto `c` (probe-confirmed `'Çm' < 'cn'`), so those two primary entries are pinned to `c`'s rank. Strings containing a **non-CP1252** character fall back to the inner `CultureCollation`'s `CompareInfo` two-pass (below) — close for arbitrary Unicode, exact for the CP1252 universe.
141141

142+
Implementation: this is the engine's hottest string path (it backs `Baseline`), so `Compare` / `GetHashCode` stream each operand's weights through a `WeightCursor` `ref struct` — one element per `MoveNext`, ignorables skipped and ligatures expanded inline — rather than materializing weight lists. A comparison walks the cursors at the primary level, dropping to a second walk only on a primary tie and a third only on a secondary tie; the common case resolves in one pass with **zero allocation**. Keep the `AllCp1252` gate ahead of the streaming path: the "any non-CP1252 char ⇒ `CompareInfo` fallback for the whole pair" contract requires scanning both operands before choosing the repertoire path, which a bail-mid-walk detector would break.
143+
142144
## Symbol sort weighting (other SQL_\* / Windows / locale families)
143145

144146
`CultureCollation.Compare` (the `CompareInfo`-routed comparer behind every collation **other than the default**) gives hyphen (`-`) and apostrophe (`'`) the **minimal-weight** treatment SQL Server applies, while every other symbol keeps a real primary weight:

0 commit comments

Comments
 (0)