Skip to content

Commit ed43fb8

Browse files
committed
Made the default collation's GetHashCode consistent with Equals, introduced VariableNameComparer for proper accuracy at that task.
1 parent 88ae7b1 commit ed43fb8

17 files changed

Lines changed: 701 additions & 61 deletions

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
174174
- **`xml` type, XML schema collections, XML methods (`.value()` / `.nodes()` / `.query()` / `.exist()` via an XQuery-subset evaluator; `.modify()` XML-DML skip-with-diagnostic), XML indexes**[`xml.md`](docs/claude/xml.md).
175175
- **`geography` / `geometry` types, spatial methods, spatial indexes**[`spatial.md`](docs/claude/spatial.md).
176176
- **`ALTER DATABASE SET <option>` accept-list + database-level `COLLATE` clause**[`database-options.md`](docs/claude/database-options.md).
177-
- **Per-column / per-expression collation, coercibility precedence, Msg 468 / 457 cross-collation enforcement, recognized catalog, `#temp` collation inheritance**[`collations.md`](docs/claude/collations.md).
177+
- **Per-column / per-expression collation, coercibility precedence, Msg 468/457 cross-collation enforcement, recognized catalog, `#temp` inheritance, name regimes (fullwidth/decomposed fold, hash canonicalization, variable names)**[`collations.md`](docs/claude/collations.md).
178178
- **New top-level statement parser or dispatch-loop separator rules**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
179179
- **BACPAC import** (`Simulation.ImportBacpac` — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
180180
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part FROM routing through the remote's ADO.NET pipeline, `OPENQUERY(server,'query')` ad-hoc pass-through + compile-time schema discovery, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
@@ -183,7 +183,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
183183

184184
- **Key-range locks** — sole remaining phase 4+ deferral. See [`locking.md`](docs/claude/locking.md) for what does ship (full 8-mode matrix, SNAPSHOT/RCSI, DMVs, Msg 1205/1222/3952/3960).
185185
- `BEGIN DISTRIBUTED TRANSACTION``NotSupportedException` at dispatch. `BEGIN TRANSACTION <name> WITH MARK 'm'`**Msg 319** at parse (`WITH` not accepted here); bare named transactions (`BEGIN TRAN t1`) ship.
186-
- **`CREATE DATABASE`** / **`CREATE ASSEMBLY`** — Msg 102 at parse. Adding databases routes through `Simulation.ImportBacpac`; CLR assemblies aren't modeled.
186+
- **`CREATE DATABASE`** / **`CREATE ASSEMBLY`** — Msg 102 at parse; databases arrive via `Simulation.ImportBacpac`, CLR isn't modeled.
187187
- **Cross-database / cross-server DML** (`INSERT`/`UPDATE`/`DELETE`/`MERGE` through a 3-/4-part name) → `NotSupportedException` via `BatchContext.RejectCrossDatabaseMutation``USE <db>` first. Cross-DB / four-part *reads* (SELECT/JOIN) ship; catalog-view reads via four-part names (`srv.db.sys.tables`) fall to Msg 208. See [`schemas.md`](docs/claude/schemas.md), [`linked-servers.md`](docs/claude/linked-servers.md).
188188
- **`SET <option>` accept-list** (`Simulation.Set.cs`): XACT_ABORT, all ANSI/session-state toggles, `STATISTICS {IO|TIME|XML|PROFILE}`, value-taking options (`TEXTSIZE`/`DATEFIRST`/etc.) — all parse-and-discard. Unknown SET → Msg 195. `SET @v`, `IDENTITY_INSERT`, `NOCOUNT`, `LOCK_TIMEOUT`, `TRANSACTION ISOLATION LEVEL` carry semantic effect.
189189
- **`ALTER DATABASE … SET` / `COLLATE`** — see [`database-options.md`](docs/claude/database-options.md). Most options parse-and-discard; `COMPATIBILITY_LEVEL`, `ALLOW_SNAPSHOT_ISOLATION`, `READ_COMMITTED_SNAPSHOT` are load-bearing.
@@ -199,7 +199,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
199199
- **`CREATE SCHEMA sys` / `INFORMATION_SCHEMA`** + **`CREATE TABLE sys.*` / `INFORMATION_SCHEMA.*`** — both raise Msg 2760 (real's permission framing). The schemas exist as catalog-view hosts.
200200
- T-SQL `GOTO` / labels — `IF` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` ship; unconditional jumps don't.
201201
- **Programmable-object top-level gaps**: CLR functions, logon triggers, INSTEAD OF UPDATE/DELETE on non-updatable views, JOIN-view single-base UPDATE/DELETE, OUTPUT through views, multi-source alias-form UPDATE/DELETE through views (Msg 4405). Natively-compiled + CLR procedures ship at parser-fidelity only (ATOMIC boundary → session isolation; CLR bodies parse but `EXEC` no-ops). See [`programmable.md`](docs/claude/programmable.md).
202-
- **PRINT semantic gaps** — Msg 1046 subquery-in-operand not raised; non-string formatting uses `CoerceTo(varchar(8000))` not PRINT style 0; 8000/4000-byte truncation not enforced. The `InfoMessage` surface itself ships (`SimulatedDbConnection.InfoMessage`); these are the residual gaps.
202+
- **PRINT semantic gaps** — Msg 1046 subquery-in-operand not raised; non-string formatting uses `CoerceTo(varchar(8000))` not PRINT style 0; 8000/4000-byte truncation not enforced. The `InfoMessage` surface ships (`SimulatedDbConnection.InfoMessage`).
203203
- **`ALTER TABLE` out-of-scope**: DROP PERIOD FOR SYSTEM_TIME, REBUILD, SWITCH PARTITION, `ALTER COLUMN ADD/DROP {PERSISTED|MASKED|ROWGUIDCOL|SPARSE}`, multi-constraint ADD. (ALTER COLUMN of IDENTITY to non-integer → Msg 2749; of a period column → Msg 13599.) Modeled shapes in [`alter-table.md`](docs/claude/alter-table.md).
204204
- **`hierarchyid` / `geography` / `geometry` byte-identical CAST encoding** — currently simulator-native; cross-engine byte transfer deferred. See [`hierarchyid.md`](docs/claude/hierarchyid.md), [`spatial.md`](docs/claude/spatial.md).
205205
- **Query hints gaps**: FROM-source `(unknown)` without alias falls to Msg 102 (real raises Msg 207/321); `FORCESEEK(name(cols))` nested-form name validation not run. Surface in [`query-hints.md`](docs/claude/query-hints.md).
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using SqlServerSimulator.Storage;
4+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
5+
6+
namespace SqlServerSimulator;
7+
8+
/// <summary>
9+
/// Guards the <c>IEqualityComparer&lt;string&gt;</c> hash contract on
10+
/// <see cref="Collation.SqlLatin1Cp1CiAsCollation"/>: whenever
11+
/// <c>Equals(x, y)</c> is true — including across the repertoire boundary,
12+
/// where equality routes through the inner <see cref="CompareInfo"/> —
13+
/// <c>GetHashCode</c> must agree. The hybrid hashes in-repertoire strings
14+
/// by SQL sort-weight runs and everything else through canonicalization +
15+
/// the inner hash, so the boundary is where inconsistency would hide.
16+
/// Derivation notes live in <c>docs/claude/collations.md</c>.
17+
/// </summary>
18+
[TestClass]
19+
public sealed class CollationHashConsistencyTests
20+
{
21+
private static readonly Collation Nvarchar = Collation.Get(Collation.SqlLatin1Cp1CiAsCollation.CollationName);
22+
23+
private static readonly Collation Varchar = Nvarchar.ForVarcharStorage();
24+
25+
private static void CollectHashMismatch(Collation collation, string x, string y, List<string> mismatches)
26+
{
27+
if (collation.Equals(x, y) && collation.GetHashCode(x) != collation.GetHashCode(y))
28+
mismatches.Add($"{Escape(x)} vs {Escape(y)} ({(ReferenceEquals(collation, Varchar) ? "varchar" : "nvarchar")})");
29+
}
30+
31+
private static string Escape(string s) =>
32+
string.Concat(s.Select(c => c is >= ' ' and <= '~' ? c.ToString() : $"\\u{(int)c:X4}"));
33+
34+
private static void AssertEqualAndHashEqual(Collation collation, string x, string y)
35+
{
36+
IsTrue(collation.Equals(x, y), $"expected Equals: '{x}' vs '{y}'");
37+
AreEqual(collation.GetHashCode(x), collation.GetHashCode(y), $"hash differs: '{x}' vs '{y}'");
38+
}
39+
40+
[TestMethod]
41+
public void FullwidthSpelling_HashesLikeAsciiTarget()
42+
{
43+
foreach (var collation in (Collation[])[Nvarchar, Varchar])
44+
{
45+
AssertEqualAndHashEqual(collation, "sp_executesql", "sp_executesql");
46+
AssertEqualAndHashEqual(collation, "table1", "table1");
47+
AssertEqualAndHashEqual(collation, "ABC", "abc");
48+
}
49+
}
50+
51+
[TestMethod]
52+
public void DecomposedAccent_HashesLikeComposed()
53+
{
54+
foreach (var collation in (Collation[])[Nvarchar, Varchar])
55+
AssertEqualAndHashEqual(collation, "café", "cafe\u0301");
56+
}
57+
58+
[TestMethod]
59+
public void CapitalSharpS_HashesLikeSharpS()
60+
{
61+
foreach (var collation in (Collation[])[Nvarchar, Varchar])
62+
AssertEqualAndHashEqual(collation, "straße", "straẞe");
63+
}
64+
65+
[TestMethod]
66+
public void IcuIgnorableCharacters_HashLikeAbsent()
67+
{
68+
foreach (var collation in (Collation[])[Nvarchar, Varchar])
69+
{
70+
AssertEqualAndHashEqual(collation, "ab", "a\u200Bb");
71+
AssertEqualAndHashEqual(collation, "ab", "a\uFEFFb");
72+
// A control character is in-repertoire, so a pure-CP1252 pair
73+
// stays weight-compared (unequal); ICU ignores it only where
74+
// equality routes through the inner collation. The all-CP1252
75+
// spelling still shares the hash — a legal collision the
76+
// fullwidth triangle requires.
77+
AssertEqualAndHashEqual(collation, "a\u0001b", "ab");
78+
AreEqual(collation.GetHashCode("a\u0001b"), collation.GetHashCode("ab"));
79+
}
80+
}
81+
82+
/// <summary>
83+
/// An out-of-repertoire spelling can be Equals-equal to two
84+
/// in-repertoire strings that are unequal to each other (fullwidth
85+
/// <c>2</c> equals both <c>2</c> and superscript <c>²</c> through the
86+
/// inner collation), so all three must share a hash — the
87+
/// in-repertoire pair's shared hash is a legal collision of unequal
88+
/// strings.
89+
/// </summary>
90+
[TestMethod]
91+
public void CrossBoundaryTriangles_ShareOneHash()
92+
{
93+
AssertEqualAndHashEqual(Nvarchar, "x2y", "x2y");
94+
AssertEqualAndHashEqual(Nvarchar, "x²y", "x2y");
95+
AreEqual(Nvarchar.GetHashCode("x2y"), Nvarchar.GetHashCode("x²y"));
96+
97+
AssertEqualAndHashEqual(Nvarchar, "a b", "a\u3000b");
98+
AssertEqualAndHashEqual(Nvarchar, "a\u00A0b", "a\u3000b");
99+
AreEqual(Nvarchar.GetHashCode("a b"), Nvarchar.GetHashCode("a\u00A0b"));
100+
101+
// Thai SARA AM: ICU equates the composed U+0E33 with NIKHAHIT +
102+
// SARA AA while the weight tables keep the spellings unequal.
103+
AssertEqualAndHashEqual(Nvarchar, "กําx", "กำx");
104+
AssertEqualAndHashEqual(Nvarchar, "กำx", "กำx");
105+
AreEqual(Nvarchar.GetHashCode("กำx"), Nvarchar.GetHashCode("กําx"));
106+
}
107+
108+
/// <summary>
109+
/// Completeness sweep: groups every repertoire character by its inner
110+
/// (invariant, CI + width/kana-insensitive) sort key and asserts each
111+
/// group's members hash identically in context — the behavioral form
112+
/// of "the in-repertoire hash-fold table covers every ICU-equal
113+
/// class" (case mates, superscripts, ordinal indicators, NBSP, Thai
114+
/// digits, ignorable controls).
115+
/// </summary>
116+
[TestMethod]
117+
public void InRepertoireIcuEqualClasses_ShareHashes()
118+
{
119+
const CompareOptions Options =
120+
CompareOptions.IgnoreCase | CompareOptions.IgnoreKanaType | CompareOptions.IgnoreWidth;
121+
var compareInfo = CultureInfo.InvariantCulture.CompareInfo;
122+
123+
var groups = new Dictionary<string, List<char>>(StringComparer.Ordinal);
124+
foreach (var ch in Repertoire())
125+
{
126+
var key = Convert.ToBase64String(compareInfo.GetSortKey(ch.ToString(), Options).KeyData);
127+
if (!groups.TryGetValue(key, out var members))
128+
groups[key] = members = [];
129+
members.Add(ch);
130+
}
131+
132+
var mismatches = new List<string>();
133+
foreach (var members in groups.Values)
134+
{
135+
for (var i = 1; i < members.Count; i++)
136+
{
137+
foreach (var collation in (Collation[])[Nvarchar, Varchar])
138+
{
139+
if (collation.GetHashCode($"x{members[0]}z") != collation.GetHashCode($"x{members[i]}z"))
140+
mismatches.Add($"U+{(int)members[0]:X4} vs U+{(int)members[i]:X4} ({(ReferenceEquals(collation, Varchar) ? "varchar" : "nvarchar")})");
141+
}
142+
}
143+
}
144+
145+
IsEmpty(mismatches, string.Join("; ", mismatches));
146+
}
147+
148+
/// <summary>
149+
/// Seeded fuzz over ICU-equivalence substitution pools: every generated
150+
/// pair that Equals reports equal must hash equal, under both storage
151+
/// bodies. Pools mix case flips, fullwidth forms, ignorables,
152+
/// decomposition, ligature case-mates, digit homoglyphs, and Thai.
153+
/// </summary>
154+
[TestMethod]
155+
public void SubstitutionFuzz_EqualsImpliesHashEqual()
156+
{
157+
string[][] pools =
158+
[
159+
["s", "S", "s", "S"],
160+
["2", "²", "๒", "2"],
161+
["ß", "ẞ", "ss"],
162+
["æ", "Æ", "ae"],
163+
["é", "e\u0301", "É"],
164+
["", "\u200B", "\u0001", "\u00AD"],
165+
[" ", "\u00A0", "\u3000"],
166+
["ำ", "ํา"],
167+
["-", "'", "_", "9", "ก", "z"],
168+
["µ", "μ"],
169+
];
170+
171+
var mismatches = new List<string>();
172+
var random = new Random(20260713);
173+
for (var iteration = 0; iteration < 500; iteration++)
174+
{
175+
var length = random.Next(0, 6);
176+
var builderX = new StringBuilder();
177+
var builderY = new StringBuilder();
178+
for (var i = 0; i < length; i++)
179+
{
180+
var pool = pools[random.Next(pools.Length)];
181+
_ = builderX.Append(pool[random.Next(pool.Length)]);
182+
_ = builderY.Append(pool[random.Next(pool.Length)]);
183+
}
184+
185+
var x = builderX.ToString();
186+
var y = builderY.ToString();
187+
CollectHashMismatch(Nvarchar, x, y, mismatches);
188+
CollectHashMismatch(Varchar, x, y, mismatches);
189+
}
190+
191+
IsEmpty(mismatches, string.Join("; ", mismatches));
192+
}
193+
194+
/// <summary>
195+
/// Systematic single-character sweep: for each BMP character in the
196+
/// blocks the fold machinery covers, derive normalization / case
197+
/// variants of a carrier string and assert the hash contract wherever
198+
/// Equals holds.
199+
/// </summary>
200+
[TestMethod]
201+
public void NormalizationVariantSweep_EqualsImpliesHashEqual()
202+
{
203+
(int Start, int End)[] blocks =
204+
[
205+
(0x0020, 0x02FF), (0x0E00, 0x0E7F), (0x1E00, 0x1EFF),
206+
(0x2000, 0x20AF), (0x2100, 0x214F), (0xFB00, 0xFB06), (0xFF00, 0xFFEF),
207+
];
208+
var mismatches = new List<string>();
209+
foreach (var (start, end) in blocks)
210+
{
211+
for (var cp = start; cp <= end; cp++)
212+
{
213+
var carrier = $"x{(char)cp}z";
214+
foreach (var variant in Variants(carrier))
215+
{
216+
if (variant == carrier)
217+
continue;
218+
CollectHashMismatch(Nvarchar, carrier, variant, mismatches);
219+
CollectHashMismatch(Varchar, carrier, variant, mismatches);
220+
}
221+
}
222+
}
223+
224+
IsEmpty(mismatches, string.Join("; ", mismatches));
225+
}
226+
227+
private static IEnumerable<string> Variants(string s)
228+
{
229+
yield return s.ToUpperInvariant();
230+
string[] normalized;
231+
try
232+
{
233+
normalized =
234+
[
235+
s.Normalize(NormalizationForm.FormC),
236+
s.Normalize(NormalizationForm.FormD),
237+
s.Normalize(NormalizationForm.FormKC),
238+
s.Normalize(NormalizationForm.FormKD),
239+
];
240+
}
241+
catch (ArgumentException)
242+
{
243+
yield break;
244+
}
245+
246+
foreach (var form in normalized)
247+
yield return form;
248+
}
249+
250+
private static IEnumerable<char> Repertoire()
251+
{
252+
var encoding = CharSqlType.Cp1252Encoder;
253+
var buffer = new byte[1];
254+
for (var b = 1; b <= 255; b++)
255+
{
256+
buffer[0] = (byte)b;
257+
var decoded = encoding.GetString(buffer);
258+
if (decoded.Length == 1)
259+
yield return decoded[0];
260+
}
261+
262+
for (var cp = 0x0E00; cp <= 0x0E7F; cp++)
263+
yield return (char)cp;
264+
}
265+
}

SqlServerSimulator.Tests/CollationBehaviorTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,28 @@ public void DefaultCollation_OrderBy_NvarcharExpandsLigatures()
213213
CollectionAssert.AreEqual(new[] { "ad", "æx", "af" }, rows);
214214
}
215215

216+
/// <summary>
217+
/// Apostrophe and hyphen are both minimal-weight marks but remain
218+
/// distinct from each other, including when a fullwidth character
219+
/// pushes the comparison onto the non-CP1252 fallback path —
220+
/// probe-confirmed (2026-07-13): real SQL Server returns 'neq' for
221+
/// both forms and keeps the values in separate GROUP BY buckets.
222+
/// </summary>
223+
[TestMethod]
224+
[DataRow("N'ab''c' = N'ab-c'", 0)]
225+
[DataRow("N'ab''cX' = N'ab-cX'", 0)]
226+
[DataRow("N'coopX' = N'co-opX'", 0)]
227+
public void DefaultCollation_MinimalMarks_StayDistinct(string condition, int expected)
228+
=> AreEqual(expected, new Simulation().ExecuteScalar($"select case when {condition} then 1 else 0 end"));
229+
230+
[TestMethod]
231+
public void DefaultCollation_MinimalMarks_GroupSeparately()
232+
=> AreEqual(2, new Simulation().ExecuteScalar("""
233+
create table dbo.marks (v nvarchar(10));
234+
insert dbo.marks values (N'ab''cX'), (N'ab-cX');
235+
select count(*) from (select v from dbo.marks group by v) g
236+
"""));
237+
216238
[TestMethod]
217239
public void CollationName_LookupIsCaseInsensitive()
218240
{

0 commit comments

Comments
 (0)