Skip to content

Commit acec49b

Browse files
committed
Database collation is now used instead of the "Default" collation where appropriate.
1 parent 87b64db commit acec49b

13 files changed

Lines changed: 219 additions & 69 deletions

SqlServerSimulator.Tests/NameComparisonRegimeTests.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,76 @@ public void Regime3_SpatialMethod_LowercaseTostring_NotResolved()
177177
public void Regime3_SpatialStatic_LowercasePoint_NotResolved()
178178
=> _ = Throws<Exception>(() => new Simulation().ExecuteScalar(
179179
"select cast(geography::point(0, 0, 4326) as varchar(50))"));
180+
181+
// ===== CS-database fidelity: regime 1 sites flip case-sensitivity =====
182+
// Probe-confirmed (2026-05-21): under a case-sensitive database
183+
// collation (SQL_Latin1_General_CP1_CS_AS), reserved-name checks,
184+
// system-proc dispatch, and CLR-type-prefix lookups all follow the
185+
// database collation. Width-folding still applies (IgnoreWidth stays
186+
// on under CS_AS — no _WS_ suffix). True system tokens routed through
187+
// BuiltInToken (INSERTED / DELETED / OBJECT_ID type filter /
188+
// sp_addextendedproperty arg names) stay invariant — those have
189+
// dedicated regime-1 coverage in the section above.
190+
191+
private static Simulation CsCollation()
192+
{
193+
var sim = new Simulation();
194+
_ = sim.ExecuteNonQuery("ALTER DATABASE simulated COLLATE SQL_Latin1_General_CP1_CS_AS");
195+
return sim;
196+
}
197+
198+
[TestMethod]
199+
public void CsDatabase_CreateSchemaUppercaseDBO_PassesReservedCheck()
200+
{
201+
// The reserved-name check follows database collation: under CS,
202+
// `DBO` doesn't case-equal the reserved `dbo`, so the check
203+
// passes (the schema-creation attempt then proceeds). The
204+
// Schemas dict's construction-time CI comparer doesn't rebuild
205+
// on ALTER COLLATE (documented fidelity gap on
206+
// <see cref="Database.Schemas"/>), so the TryAdd collides with
207+
// the pre-seeded `dbo` and raises Msg 2714 "already exists"
208+
// instead. Surfacing Msg 2714 (rather than Msg 2760 "reserved")
209+
// confirms the reserved-name check now follows DB collation.
210+
var ex = CsCollation().AssertSqlError("CREATE SCHEMA DBO", 2714);
211+
Assert.Contains("already an object", ex.Message);
212+
}
213+
214+
[TestMethod]
215+
public void CsDatabase_CreateSchemaCanonicalDbo_StillRejectedAsReserved()
216+
=> _ = CsCollation().AssertSqlError("CREATE SCHEMA dbo", 2760);
217+
218+
[TestMethod]
219+
public void CsDatabase_CreateSchemaFullwidthSys_StillRejectedAsReserved()
220+
{
221+
// Width-folding stays on under CS_AS (no _WS_ suffix), so sys
222+
// folds to sys and matches the reserved set.
223+
_ = CsCollation().AssertSqlError("CREATE SCHEMA sys", 2760);
224+
}
225+
226+
[TestMethod]
227+
public void CsDatabase_SystemProcSpExecuteSql_UppercaseCase_NotFound()
228+
// `SP_EXECUTESQL` doesn't case-equal `sp_executesql` under CS, so
229+
// the dispatch falls through to generic user-proc lookup which
230+
// also misses → Msg 2812 "Could not find stored procedure".
231+
=> _ = CsCollation().AssertSqlError("EXEC SP_EXECUTESQL N'select 1'", 2812);
232+
233+
[TestMethod]
234+
public void CsDatabase_SystemProcSpExecuteSql_FullwidthCase_Dispatches()
235+
// Probe-confirmed: sp_executesql width-folds to sp_executesql
236+
// under CS_AS (IgnoreWidth stays on), so dispatch still routes
237+
// through the simulator's sp_executesql handler.
238+
=> AreEqual(1, CsCollation().ExecuteScalar("EXEC sp_executesql N'select 1'"));
239+
240+
[TestMethod]
241+
public void CsDatabase_HierarchyIdTypePrefix_UppercaseHIERARCHYID_NotResolved()
242+
// hierarchyid:: is the canonical lowercase form; HIERARCHYID
243+
// doesn't case-equal it under CS, so the static-call dispatch
244+
// misses and the parser raises a syntax error.
245+
=> _ = Throws<Exception>(() => CsCollation().ExecuteScalar(
246+
"SELECT HIERARCHYID::GetRoot()"));
247+
248+
[TestMethod]
249+
public void CsDatabase_HierarchyIdTypePrefix_CanonicalCase_Resolves()
250+
=> AreEqual("/", CsCollation().ExecuteScalar(
251+
"SELECT hierarchyid::GetRoot().ToString()"));
180252
}

SqlServerSimulator/BuiltInResources.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1410,7 +1410,7 @@ private static IEnumerable<SqlValue[]> EnumerateSysCheckConstraints(Parser.Batch
14101410
{
14111411
for (var i = 0; i < table.Columns.Length; i++)
14121412
{
1413-
if (Collation.Default.Equals(table.Columns[i].Name, inlineCol))
1413+
if (database.Collation.Equals(table.Columns[i].Name, inlineCol))
14141414
{
14151415
parentColumnId = i + 1;
14161416
break;

SqlServerSimulator/BuiltInToken.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,13 @@ public static bool EqualsAny(string? value, params ReadOnlySpan<string> options)
7474
}
7575
return false;
7676
}
77+
78+
/// <summary>
79+
/// Hash code consistent with <see cref="Equals(string?, string?)"/>:
80+
/// two strings that compare equal under the built-in-token options
81+
/// hash to the same value. Required when a built-in-token-keyed type
82+
/// participates in a dictionary or hashset.
83+
/// </summary>
84+
public static int GetHashCode(string value) =>
85+
compareInfo.GetHashCode(value, Options);
7786
}

SqlServerSimulator/Collation.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,19 @@ private protected Collation()
6666
public virtual bool CaseSensitive => false;
6767

6868
/// <summary>
69-
/// The simulator's default collation — <c>SQL_Latin1_General_CP1_CI_AS</c>.
70-
/// Resolved by <see cref="TryGet"/> through the parser; used by
71-
/// <see cref="Database.CollationName"/> when no explicit
72-
/// <c>ALTER DATABASE COLLATE</c> has landed, and as the fallback when
73-
/// a string-typed value's <see cref="SqlType.Collation"/> is
74-
/// <see langword="null"/>.
69+
/// The simulator's hardcoded baseline collation —
70+
/// <c>SQL_Latin1_General_CP1_CI_AS</c>. Resolved once via
71+
/// <see cref="TryGet"/>. Used as the seed for new
72+
/// <see cref="Database.Collation"/> instances, as the fallback for
73+
/// string-typed values whose <see cref="SqlType.Collation"/> is
74+
/// <see langword="null"/>, and as the default for the storage-layer
75+
/// <see cref="VarcharSqlType"/> / <see cref="NVarcharSqlType"/> /
76+
/// <see cref="CharSqlType"/> / <see cref="NCharSqlType"/> singletons.
77+
/// Identifier-resolution sites should route through
78+
/// <see cref="Database.Collation"/> (which defaults here but may
79+
/// diverge under <c>ALTER DATABASE COLLATE</c>); only sites that
80+
/// genuinely need "the simulator's baseline regardless of database"
81+
/// belong on this property.
7582
/// </summary>
7683
internal static Collation Default => defaultLazy.Value;
7784

SqlServerSimulator/Database.cs

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ internal sealed class Database
2626
/// default <c>dbo</c> schema; <c>CREATE SCHEMA &lt;name&gt;</c> adds more.
2727
/// Schema-qualified table references (<c>SELECT * FROM audit.t</c>) route
2828
/// through here; unqualified references fall back to
29-
/// <see cref="DefaultSchemaName"/>.
29+
/// <see cref="DefaultSchemaName"/>. Comparer is <see cref="Collation"/>
30+
/// at the database's construction time; doesn't rebuild if a later
31+
/// <c>ALTER DATABASE COLLATE</c> shifts <see cref="Collation"/>.
3032
/// </summary>
31-
public readonly ConcurrentDictionary<string, Schema> Schemas = new(Collation.Default);
33+
public readonly ConcurrentDictionary<string, Schema> Schemas;
3234

3335
/// <summary>
3436
/// Schema-id of the default <c>dbo</c> schema. Matches real SQL Server's
@@ -44,8 +46,19 @@ internal sealed class Database
4446
public const int SysSchemaId = 4;
4547

4648
public Database(string name)
49+
: this(name, Collation.Default)
50+
{
51+
}
52+
53+
public Database(string name, Collation collation)
4754
{
4855
this.Name = name;
56+
this.Collation = collation;
57+
this.CollationName = collation.Name;
58+
this.Schemas = new(collation);
59+
this.DdlTriggers = new(collation);
60+
this.Principals = new(collation);
61+
this.FullTextCatalogs = new(collation);
4962
this.Schemas[DefaultSchemaName] = new Schema(this, DefaultSchemaName, DboSchemaId);
5063
this.Schemas["INFORMATION_SCHEMA"] = new Schema(this, "INFORMATION_SCHEMA", InformationSchemaId);
5164
this.Schemas["sys"] = new Schema(this, "sys", SysSchemaId);
@@ -87,18 +100,32 @@ public Database(string name)
87100
public CompatibilityLevel CompatibilityLevel = CompatibilityLevel.Sql170;
88101

89102
/// <summary>
90-
/// Database-scope <c>COLLATE</c> declaration. The simulator routes every
91-
/// comparison / sort / LIKE through <see cref="Collation.Default"/>
92-
/// regardless of what this names — it's a metadata field for BACPAC
93-
/// round-trip and catalog-view fidelity (<c>sys.databases.collation_name</c>,
94-
/// <c>DATABASEPROPERTYEX(name, 'Collation')</c>,
95-
/// <c>INFORMATION_SCHEMA.COLUMNS.COLLATION_NAME</c>). Whitelist of accepted
96-
/// names lives in <see cref="Collation.IsRecognized"/>; <c>ALTER DATABASE
97-
/// name COLLATE name</c> raises <see cref="NotSupportedException"/> on an
98-
/// unrecognized name. Defaults to the simulator's modeled collation
99-
/// (<see cref="Collation.Default"/>.Name).
103+
/// Database identifier-resolution collation. Drives every catalog dict
104+
/// comparer in this database (<see cref="Schemas"/>,
105+
/// <see cref="Schema.HeapTables"/>, <see cref="Schema.Functions"/>, …)
106+
/// and every <c>BatchContext</c> / <c>Schema</c> identifier-equality
107+
/// site. Set once at construction; <c>ALTER DATABASE COLLATE</c>
108+
/// updates this so subsequent identifier compares route through the
109+
/// new collation, but the construction-time dict comparers don't
110+
/// rebuild (matches real SQL Server's behavior: existing objects keep
111+
/// their identifier registration, new ones bind under the new
112+
/// collation). Defaults to <see cref="Collation.Default"/>.
113+
/// </summary>
114+
public Collation Collation;
115+
116+
/// <summary>
117+
/// Database-scope <c>COLLATE</c> declaration as a string. Surfaces in
118+
/// <c>sys.databases.collation_name</c>,
119+
/// <c>DATABASEPROPERTYEX(name, 'Collation')</c>, and
120+
/// <c>INFORMATION_SCHEMA.COLUMNS.COLLATION_NAME</c>. Kept in sync with
121+
/// <see cref="Collation"/>.Name on every <c>ALTER DATABASE COLLATE</c>;
122+
/// also seeds the per-column default collation for new columns that
123+
/// don't carry an explicit <c>COLLATE</c> clause. Whitelist of accepted
124+
/// names lives in <see cref="Collation.IsRecognized"/>; an unrecognized
125+
/// name raises <see cref="NotSupportedException"/> from
126+
/// <c>ALTER DATABASE COLLATE</c>.
100127
/// </summary>
101-
public string CollationName = Collation.Default.Name;
128+
public string CollationName;
102129

103130
/// <summary>
104131
/// Explicit override of the per-database <c>VERBOSE_TRUNCATION_WARNINGS</c>
@@ -229,7 +256,7 @@ public Database(string name)
229256
/// and <c>sys.sql_modules</c>. <strong>Not fired</strong> — see
230257
/// <see cref="DdlTrigger"/> for the no-enforcement rationale.
231258
/// </summary>
232-
public readonly ConcurrentDictionary<string, DdlTrigger> DdlTriggers = new(Collation.Default);
259+
public readonly ConcurrentDictionary<string, DdlTrigger> DdlTriggers;
233260

234261
/// <summary>
235262
/// Per-database principals (users + roles). Pre-seeded with the fixed
@@ -240,7 +267,7 @@ public Database(string name)
240267
/// model; this dict exists for catalog-view round-trip and for
241268
/// resolving <c>GRANT … TO &lt;name&gt;</c> at parse time.
242269
/// </summary>
243-
public readonly ConcurrentDictionary<string, DatabasePrincipal> Principals = new(Collation.Default);
270+
public readonly ConcurrentDictionary<string, DatabasePrincipal> Principals;
244271

245272
/// <summary>
246273
/// Per-database permission grants/denies. Populated by
@@ -277,7 +304,7 @@ public Database(string name)
277304
/// visibility — query-time CONTAINS / FREETEXT predicates raise
278305
/// <see cref="NotSupportedException"/> rather than evaluate.
279306
/// </summary>
280-
public readonly ConcurrentDictionary<string, FullTextCatalog> FullTextCatalogs = new(Collation.Default);
307+
public readonly ConcurrentDictionary<string, FullTextCatalog> FullTextCatalogs;
281308

282309
private int nextFullTextCatalogId;
283310

@@ -326,12 +353,12 @@ public bool Equals(ExtendedPropertyKey other) =>
326353
this.Class == other.Class
327354
&& this.MajorId == other.MajorId
328355
&& this.MinorId == other.MinorId
329-
&& Collation.Default.Equals(this.Name, other.Name);
356+
&& BuiltInToken.Equals(this.Name, other.Name);
330357

331358
public override bool Equals(object? obj) => obj is ExtendedPropertyKey other && this.Equals(other);
332359

333360
public override int GetHashCode() =>
334-
HashCode.Combine(this.Class, this.MajorId, this.MinorId, Collation.Default.GetHashCode(this.Name));
361+
HashCode.Combine(this.Class, this.MajorId, this.MinorId, BuiltInToken.GetHashCode(this.Name));
335362

336363
public static bool operator ==(ExtendedPropertyKey left, ExtendedPropertyKey right) => left.Equals(right);
337364
public static bool operator !=(ExtendedPropertyKey left, ExtendedPropertyKey right) => !left.Equals(right);

SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1309,7 +1309,7 @@ public bool TryResolveTable(MultiPartName name, [System.Diagnostics.CodeAnalysis
13091309
/// </summary>
13101310
public void RejectCrossDatabaseMutation(MultiPartName name)
13111311
{
1312-
if (name.Count == 3 && !Collation.Default.Equals(name[0], this.CurrentDatabase.Name))
1312+
if (name.Count == 3 && !this.CurrentDatabase.Collation.Equals(name[0], this.CurrentDatabase.Name))
13131313
{
13141314
throw new NotSupportedException(
13151315
$"Cross-database write to '{name}' isn't modeled. The simulator routes 3-part-name reads (SELECT / JOIN / catalog views) across databases but defers cross-database INSERT / UPDATE / DELETE / MERGE / TRUNCATE / DDL — trigger scope swapping, identity allocation routing, and FK validation across DB boundaries are pending. Issue USE [{name[0]}] from this connection and reference the target with a 1- or 2-part name.");

SqlServerSimulator/Parser/Expression.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,12 @@ public static Expression Parse(ParserContext context)
220220
if (secondColon is not Operator { Character: ':' })
221221
throw SimulatedSqlException.SyntaxErrorNear(context);
222222
context.MoveNextRequired();
223-
expression = Collation.Default.Equals(typeName, "hierarchyid")
223+
var typePrefixCollation = context.Batch.CurrentDatabase.Collation;
224+
expression = typePrefixCollation.Equals(typeName, "hierarchyid")
224225
? HierarchyIdStaticCall.Parse(context)
225-
: Collation.Default.Equals(typeName, "geography")
226+
: typePrefixCollation.Equals(typeName, "geography")
226227
? SpatialStaticCall.Parse(SqlType.Geography, context)
227-
: Collation.Default.Equals(typeName, "geometry")
228+
: typePrefixCollation.Equals(typeName, "geometry")
228229
? SpatialStaticCall.Parse(SqlType.Geometry, context)
229230
: throw SimulatedSqlException.SyntaxErrorNear(context);
230231
continue;

0 commit comments

Comments
 (0)