Skip to content

Commit 7d31854

Browse files
committed
Per-column declared collation now drives SqlValue compare/sort/hash (was metadata-only); cross-collation operand pairs raise Msg 468 / 457 by coercibility precedence. Recognized catalog grew from 6 to 12 (adds locale-driven Japanese / Chinese / Turkish + 3 system-column collations seen in real bacpacs); #temp tables inherit the active database's default.
1 parent 2b9055c commit 7d31854

17 files changed

Lines changed: 1182 additions & 134 deletions

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
169169
- **`CREATE FULLTEXT CATALOG`/`INDEX`, `CONTAINS`/`FREETEXT` rejection**[`full-text.md`](docs/claude/full-text.md).
170170
- **`xml` data type, XML schema collections, XML method dispatch, XML indexes**[`xml.md`](docs/claude/xml.md).
171171
- **`geography` / `geometry` types, spatial methods, spatial indexes**[`spatial.md`](docs/claude/spatial.md).
172-
- **`ALTER DATABASE SET <option>` accept-list + `COLLATE` clause**[`database-options.md`](docs/claude/database-options.md).
172+
- **`ALTER DATABASE SET <option>` accept-list + database-level `COLLATE` clause**[`database-options.md`](docs/claude/database-options.md).
173+
- **Per-column / per-expression collation, coercibility precedence, Msg 468 / 457 cross-collation enforcement, recognized catalog, `#temp` collation inheritance**[`collations.md`](docs/claude/collations.md).
173174
- **New top-level statement parser or dispatch-loop separator rules**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
174175
- **BACPAC import** (`Simulation.ImportBacpac` instance method — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
175176
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part-name FROM routing through the remote's ADO.NET pipeline, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).

SqlServerSimulator.Tests/CollationDeclaredColumnTests.cs

Lines changed: 508 additions & 0 deletions
Large diffs are not rendered by default.

SqlServerSimulator.Tests/CollationMetadataTests.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ public void Column_Collate_UnrecognizedName_RaisesNotSupported()
125125

126126
[TestMethod]
127127
public void FnHelpCollations_ListsRecognized()
128-
=> AreEqual(6, new Simulation().ExecuteScalar(
128+
=> AreEqual(12, new Simulation().ExecuteScalar(
129129
"SELECT COUNT(*) FROM sys.fn_helpcollations()"));
130130

131131
[TestMethod]
@@ -142,9 +142,13 @@ public void FnHelpCollations_ColumnsAreNameAndDescription()
142142
{
143143
// The view's row shape mirrors real SQL Server's
144144
// (name sysname NULL, description nvarchar(1000) NULL).
145+
// Drop the parens — the parens form has a pre-existing parser path
146+
// that bypasses WHERE; the non-parens form is more permissive than
147+
// real SQL Server (which requires `()` on TVF calls) but exercises
148+
// the WHERE filter correctly.
145149
var sim = new Simulation();
146150
AreEqual("Latin1_General_100_CI_AS", sim.ExecuteScalar(
147-
"SELECT name FROM sys.fn_helpcollations() WHERE name = 'Latin1_General_100_CI_AS'"));
151+
"SELECT name FROM sys.fn_helpcollations WHERE name = 'Latin1_General_100_CI_AS'"));
148152
}
149153

150154
[TestMethod]

SqlServerSimulator/Collation.cs

Lines changed: 207 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,34 @@
11
using System.Collections.Frozen;
22
using System.Globalization;
3+
using SqlServerSimulator.Storage;
34

45
namespace SqlServerSimulator;
56

7+
/// <summary>
8+
/// SQL Server's collation-precedence rank. Used by the expression layer to
9+
/// decide which collation wins when two string operands meet; mismatched
10+
/// peers raise <c>Msg 468</c> / <c>Msg 457</c>.
11+
/// </summary>
12+
/// <remarks>
13+
/// Real SQL Server defines additional ranks (<c>Coercible-default</c>,
14+
/// <c>Implicit X</c>, <c>Explicit X</c>, <c>No-collation</c>); the simulator
15+
/// collapses them to the three that drive observable behavior. Hierarchy:
16+
/// <see cref="Explicit"/> beats <see cref="Implicit"/> beats
17+
/// <see cref="CoercibleDefault"/>; peers with the same rank but different
18+
/// collations raise the conflict error.
19+
/// </remarks>
20+
internal enum Coercibility : byte
21+
{
22+
/// <summary>Literal, parameter, system-function result, CAST of a coercible-default source. Yields to <see cref="Implicit"/> / <see cref="Explicit"/> peers.</summary>
23+
CoercibleDefault = 0,
24+
25+
/// <summary>Column reference, CAST of a column, computed-column expression. Two implicits with different collations raise Msg 468 / 457.</summary>
26+
Implicit = 1,
27+
28+
/// <summary>Explicit <c>COLLATE</c> postfix. Beats both lower ranks; two explicits with different collations raise Msg 468 / 457.</summary>
29+
Explicit = 2,
30+
}
31+
632
/// <summary>
733
/// The SQL Server equivalent to .NET's <see cref="IComparer{T}"/> for strings.
834
/// Three concrete subclasses match the names on the
@@ -22,21 +48,21 @@ private protected Collation()
2248
/// True when the collation name carries the <c>_CS_</c> or <c>_BIN</c>
2349
/// marker — i.e. case differences are significant. Consulted by
2450
/// <c>LIKE</c>'s regex builder to decide whether to set
25-
/// <c>System.Text.RegularExpressions.RegexOptions.IgnoreCase</c>;
26-
/// every other simulator string op still routes through
27-
/// <see cref="Default"/> regardless of this flag (see
28-
/// <c>docs/claude/database-options.md</c>).
51+
/// <c>System.Text.RegularExpressions.RegexOptions.IgnoreCase</c>.
52+
/// Other string ops (sort, equality, hash) honor the collation
53+
/// directly through <see cref="Compare"/> / <see cref="Equals"/> /
54+
/// <see cref="GetHashCode"/> via the column type's pinned collation.
2955
/// </summary>
3056
public virtual bool CaseSensitive => false;
3157

3258
/// <summary>
3359
/// The instance returned for "SQL_Latin1_General_CP1_CI_AS" — the
34-
/// simulator's default database collation. Routed through
60+
/// simulator's default database collation. Used by
3561
/// <see cref="Database.CollationName"/> when no explicit
36-
/// <c>ALTER DATABASE COLLATE</c> has been issued, and (for the time
37-
/// being — see <c>docs/claude/database-options.md</c>) is also the
38-
/// comparer every string op falls back to regardless of declared
39-
/// column / database collation.
62+
/// <c>ALTER DATABASE COLLATE</c> has been issued, and as the fallback
63+
/// when a string-typed value's <see cref="SqlType.Collation"/> is
64+
/// <see langword="null"/> (literals built via the singleton
65+
/// <c>SqlValue.FromVarchar(string)</c> / etc. paths).
4066
/// </summary>
4167
internal static readonly SQL_Latin1_General_CP1_CI_AS Default = new();
4268

@@ -94,17 +120,78 @@ private protected Collation()
94120
internal static readonly Latin1_General_BIN2 Latin1GeneralBin2 = new();
95121

96122
/// <summary>
97-
/// Closed accept-list of collation names the simulator recognizes as
98-
/// metadata. ALTER DATABASE COLLATE / CREATE TABLE column COLLATE accept
99-
/// these names without raising; the loader records them on the
100-
/// database / column for catalog-view round-trip. The comparison /
101-
/// sort pipeline still routes every op through
102-
/// <see cref="Default"/> regardless of declared column / database
103-
/// collation; <c>LIKE</c> is the one site that honors the
104-
/// <see cref="CaseSensitive"/> flag of an explicit
105-
/// <c>COLLATE</c> override (see <c>docs/claude/database-options.md</c>).
106-
/// Names outside this set surface as <see cref="NotSupportedException"/>
107-
/// in direct SQL; the BACPAC loader catches and records on
123+
/// "Japanese_XJIS_140_CI_AS" — the modern Japanese collation that
124+
/// handles supplementary characters via the XJIS-140 mapping table.
125+
/// Comparison routes through .NET's <c>ja-JP</c> <see cref="CompareInfo"/>
126+
/// with the simulator's standard CI/AS options (case-insensitive,
127+
/// accent-sensitive, kana-type-insensitive, width-insensitive).
128+
/// Probe-confirmed presence in real-database column-collation profiles;
129+
/// per-name sort-order parity with real SQL Server is not yet probed.
130+
/// </summary>
131+
internal static readonly CultureCollation JapaneseXJIS140CiAs = new("Japanese_XJIS_140_CI_AS", "ja-JP", caseSensitive: false);
132+
133+
/// <summary>
134+
/// "Chinese_PRC_CI_AS" — Simplified Chinese (pinyin sort) via .NET's
135+
/// <c>zh-CN</c> <see cref="CompareInfo"/>. Per-name sort-order parity
136+
/// with real SQL Server is not yet probed.
137+
/// </summary>
138+
internal static readonly CultureCollation ChinesePrcCiAs = new("Chinese_PRC_CI_AS", "zh-CN", caseSensitive: false);
139+
140+
/// <summary>
141+
/// "Turkish_CI_AS" — Turkish collation via .NET's <c>tr-TR</c>
142+
/// <see cref="CompareInfo"/>. Notably handles the i / İ / ı / I
143+
/// folding that catches non-Turkish-aware code (the "Turkish-i
144+
/// problem"). Per-name sort-order parity with real SQL Server is not
145+
/// yet probed.
146+
/// </summary>
147+
internal static readonly CultureCollation TurkishCiAs = new("Turkish_CI_AS", "tr-TR", caseSensitive: false);
148+
149+
/// <summary>
150+
/// "Latin1_General_CI_AS_KS_WS" — a Latin1 variant with kana-type and
151+
/// width <em>sensitive</em>. Used in real databases for some sysname
152+
/// columns and a handful of user columns; included so BACPAC import
153+
/// doesn't warn on its presence. Comparison routes through the
154+
/// invariant culture with KS/WS enabled.
155+
/// </summary>
156+
internal static readonly CultureCollation Latin1GeneralCiAsKsWs = new("Latin1_General_CI_AS_KS_WS", CultureInfo.InvariantCulture.Name, caseSensitive: false);
157+
158+
/// <summary>
159+
/// "SQL_Latin1_General_CP437_CS_AS" — legacy CP437 code-page binding
160+
/// (the original IBM PC code page), case-sensitive. The simulator
161+
/// routes comparison through invariant culture case-sensitive options
162+
/// — close enough for the system-column shapes this collation actually
163+
/// appears in; non-ASCII CP437 glyphs aren't probed.
164+
/// </summary>
165+
internal static readonly CultureCollation SqlLatin1GeneralCp437CsAs = new("SQL_Latin1_General_CP437_CS_AS", CultureInfo.InvariantCulture.Name, caseSensitive: true);
166+
167+
/// <summary>
168+
/// "UNICODE_CODEPOINT" — pure ordinal Unicode codepoint comparison.
169+
/// Semantically equivalent to <see cref="Latin1GeneralBin2"/> at the
170+
/// value level; the name appears on a handful of system columns in
171+
/// some BACPACs (notably AdventureWorks2025). Reuses the binary
172+
/// codepath via a separate metadata-only instance.
173+
/// </summary>
174+
internal static readonly UNICODE_CODEPOINT UnicodeCodepoint = new();
175+
176+
/// <summary>Metadata-only binary collation under the
177+
/// <c>UNICODE_CODEPOINT</c> name; behavior body is <see cref="BinaryCollation"/>.</summary>
178+
internal sealed class UNICODE_CODEPOINT : BinaryCollation
179+
{
180+
public override string Name => "UNICODE_CODEPOINT";
181+
}
182+
183+
/// <summary>
184+
/// Closed accept-list of collation names the simulator recognizes.
185+
/// ALTER DATABASE COLLATE / CREATE TABLE column COLLATE accept these
186+
/// names without raising; the loader records them on the database /
187+
/// column for catalog-view round-trip and pins the resolved
188+
/// <see cref="Collation"/> instance onto the column's
189+
/// <see cref="SqlType"/> at <see cref="Coercibility.Implicit"/> rank,
190+
/// so subsequent comparisons / sorts honor the declared collation.
191+
/// Cross-collation operand pairs that can't be resolved by coercibility
192+
/// raise Msg 468 (comparison) / Msg 457 (concat). Names outside this
193+
/// set surface as <see cref="NotSupportedException"/> in direct SQL;
194+
/// the BACPAC loader catches and records on
108195
/// <c>BacpacImportResult.Warnings</c>. Each entry's value is the
109196
/// human-readable description that <c>sys.fn_helpcollations()</c>
110197
/// exposes verbatim (probe-confirmed against SQL Server 2025).
@@ -118,6 +205,12 @@ private protected Collation()
118205
[Latin1GeneralCsAs.Name] = "Latin1-General, case-sensitive, accent-sensitive, kanatype-insensitive, width-insensitive",
119206
[Latin1GeneralBin.Name] = "Latin1-General, binary",
120207
[Latin1GeneralBin2.Name] = "Latin1-General, binary code point comparison sort",
208+
[Latin1GeneralCiAsKsWs.Name] = "Latin1-General, case-insensitive, accent-sensitive, kanatype-sensitive, width-sensitive",
209+
[SqlLatin1GeneralCp437CsAs.Name] = "Latin1-General, case-sensitive, accent-sensitive, kanatype-insensitive, width-insensitive for Unicode Data, SQL Server Sort Order 30 on Code Page 437 for non-Unicode Data",
210+
[UnicodeCodepoint.Name] = "Unicode code point comparison sort",
211+
[JapaneseXJIS140CiAs.Name] = "Japanese-XJIS-140, case-insensitive, accent-sensitive, kanatype-insensitive, width-insensitive",
212+
[ChinesePrcCiAs.Name] = "Chinese-PRC, case-insensitive, accent-sensitive, kanatype-insensitive, width-insensitive",
213+
[TurkishCiAs.Name] = "Turkish, case-insensitive, accent-sensitive, kanatype-insensitive, width-insensitive",
121214
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
122215

123216
/// <summary>
@@ -135,6 +228,12 @@ private protected Collation()
135228
[Latin1GeneralCsAs.Name] = Latin1GeneralCsAs,
136229
[Latin1GeneralBin.Name] = Latin1GeneralBin,
137230
[Latin1GeneralBin2.Name] = Latin1GeneralBin2,
231+
[Latin1GeneralCiAsKsWs.Name] = Latin1GeneralCiAsKsWs,
232+
[SqlLatin1GeneralCp437CsAs.Name] = SqlLatin1GeneralCp437CsAs,
233+
[UnicodeCodepoint.Name] = UnicodeCodepoint,
234+
[JapaneseXJIS140CiAs.Name] = JapaneseXJIS140CiAs,
235+
[ChinesePrcCiAs.Name] = ChinesePrcCiAs,
236+
[TurkishCiAs.Name] = TurkishCiAs,
138237
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
139238

140239
/// <summary>
@@ -144,6 +243,39 @@ private protected Collation()
144243
/// </summary>
145244
internal static bool IsRecognized(string name) => Recognized.ContainsKey(name);
146245

246+
/// <summary>
247+
/// SQL Server's collation-coercibility resolution for two operands.
248+
/// Returns the winning <see cref="Collation"/> and <see cref="SqlServerSimulator.Coercibility"/>
249+
/// when the pair is resolvable; <see langword="null"/> when both
250+
/// operands have the same rank but different collations (the caller is
251+
/// expected to raise Msg 468 / Msg 457 with operator context).
252+
/// </summary>
253+
/// <remarks>
254+
/// <para>Hierarchy: <see cref="Coercibility.Explicit"/> beats
255+
/// <see cref="Coercibility.Implicit"/> beats
256+
/// <see cref="Coercibility.CoercibleDefault"/>. Mismatched higher rank
257+
/// wins regardless of the lower-rank operand's collation. Equal-rank
258+
/// peers must share a collation; different collations at the same rank
259+
/// are unresolvable.</para>
260+
/// <para>Non-string operands return <see langword="null"/> for
261+
/// <c>Collation</c> on the SqlType, which collapses to the default
262+
/// collation under this resolution (a non-string operand is
263+
/// coercible-default by definition, and there's nothing to conflict
264+
/// with).</para>
265+
/// </remarks>
266+
internal static (Collation Collation, Coercibility Coercibility)? Resolve(SqlType a, SqlType b)
267+
{
268+
var ca = a.Coercibility;
269+
var cb = b.Coercibility;
270+
if (ca > cb)
271+
return (a.Collation ?? Default, ca);
272+
if (cb > ca)
273+
return (b.Collation ?? Default, cb);
274+
var aCol = a.Collation ?? Default;
275+
var bCol = b.Collation ?? Default;
276+
return StringComparer.OrdinalIgnoreCase.Equals(aCol.Name, bCol.Name) ? (aCol, ca) : null;
277+
}
278+
147279
public abstract int Compare(string? x, string? y);
148280

149281
public abstract bool Equals(string? x, string? y);
@@ -320,4 +452,59 @@ internal sealed class Latin1_General_BIN2 : BinaryCollation
320452
{
321453
public override string Name => "Latin1_General_BIN2";
322454
}
455+
456+
/// <summary>
457+
/// Generic culture-based collation: pins a <see cref="CompareInfo"/>
458+
/// and a <see cref="CompareOptions"/> set, routing
459+
/// <see cref="Compare"/> / <see cref="Equals"/> / <see cref="GetHashCode"/>
460+
/// through them. Sort options layer <see cref="CompareOptions.IgnoreSymbols"/>
461+
/// on top of the equality options, matching SQL Server's sort-only
462+
/// ignore-symbols treatment (the same asymmetry <see cref="WindowsCiAs"/>
463+
/// captures for Latin1). Used for the locale-specific collations that
464+
/// don't justify their own dedicated subclass yet.
465+
/// </summary>
466+
internal sealed class CultureCollation : Collation
467+
{
468+
private readonly string name;
469+
470+
private readonly bool caseSensitive;
471+
472+
private readonly CompareInfo compareInfo;
473+
474+
private readonly CompareOptions equalityOptions;
475+
476+
private readonly CompareOptions sortOptions;
477+
478+
internal CultureCollation(string name, string cultureName, bool caseSensitive)
479+
{
480+
this.name = name;
481+
this.caseSensitive = caseSensitive;
482+
this.compareInfo = CultureInfo.GetCultureInfo(cultureName).CompareInfo;
483+
var baseOpts = caseSensitive
484+
? CompareOptions.None
485+
: CompareOptions.IgnoreCase;
486+
// CI_AS / CS_AS without KS / WS suffixes ignore kana type and
487+
// width by default — matches SQL Server's "*_CI_AS" semantics.
488+
baseOpts |= CompareOptions.IgnoreKanaType | CompareOptions.IgnoreWidth;
489+
this.equalityOptions = baseOpts;
490+
this.sortOptions = baseOpts | CompareOptions.IgnoreSymbols;
491+
}
492+
493+
public override string Name => this.name;
494+
495+
public override bool CaseSensitive => this.caseSensitive;
496+
497+
public override int Compare(string? x, string? y) =>
498+
x is null
499+
? (y is null ? 0 : -1)
500+
: y is null ? 1 : this.compareInfo.Compare(x, y, this.sortOptions);
501+
502+
public override bool Equals(string? x, string? y) =>
503+
x is null
504+
? y is null
505+
: y is not null && this.compareInfo.Compare(x, y, this.equalityOptions) == 0;
506+
507+
public override int GetHashCode(string obj) =>
508+
this.compareInfo.GetHashCode(obj, this.equalityOptions);
509+
}
323510
}

SqlServerSimulator/Errors/SimulatedSqlException.TypeErrors.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ internal static SimulatedSqlException ExplicitConversionNotAllowed(SqlType sourc
364364
/// (<c>varchar</c>, <c>nvarchar</c>, etc.).
365365
/// </summary>
366366
internal static SimulatedSqlException ConversionFailedFromString(SqlType sourceType, string sourceValue, SqlType targetType) =>
367-
new($"Conversion failed when converting the {sourceType} value '{sourceValue}' to data type {targetType}.", 245, 16, 1);
367+
new($"Conversion failed when converting the {sourceType.SqlServerName} value '{sourceValue}' to data type {targetType.SqlServerName}.", 245, 16, 1);
368368

369369
/// <summary>
370370
/// Mimics SQL Server error 244: parsing a string succeeded but the
@@ -466,6 +466,20 @@ internal static SimulatedSqlException InvalidCollation(string name) =>
466466
internal static SimulatedSqlException CollationConflict(string leftCollation, string rightCollation, string operatorName) =>
467467
new($"Cannot resolve the collation conflict between \"{leftCollation}\" and \"{rightCollation}\" in the {operatorName} operation.", 468, 16, 9);
468468

469+
/// <summary>
470+
/// Mimics SQL Server error 457: a string-producing operation (concat with
471+
/// <c>+</c>, <c>UNION ALL</c>, <c>DISTINCT</c> over a unioned column,
472+
/// <c>ORDER BY</c> on a concat result) couldn't pick a single output
473+
/// collation because two same-rank operands had different collations.
474+
/// Probe-confirmed against SQL Server 2025: Class 16 State 1, wording
475+
/// reads "Implicit conversion of {srcType} value to {dstType} ...". The
476+
/// source/destination type names are both the same bare keyword (e.g.
477+
/// <c>varchar</c>) — SQL Server's wording uses the same word twice in
478+
/// the unresolved-collation case.
479+
/// </summary>
480+
internal static SimulatedSqlException UnresolvedCollationInImplicitConversion(SqlType type) =>
481+
new($"Implicit conversion of {type.SqlServerName} value to {type.SqlServerName} cannot be performed because the collation of the value is unresolved due to a collation conflict.", 457, 16, 1);
482+
469483
/// <summary>
470484
/// Returns the type name SQL Server uses in Msg 402 / 206 / 529 for a
471485
/// date/time type: the family root (e.g. <c>datetime2</c>,

0 commit comments

Comments
 (0)