Skip to content

Commit ce454c7

Browse files
committed
Introduced server-level collation simulation via the new ServerCollationName property to eliminate more uses of Collation.Default.
1 parent 3075ce6 commit ce454c7

11 files changed

Lines changed: 136 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Readonly struct, up to 4 inline slots (SQL Server's grammar limit). API: `Leaf`,
6161
### Context layering
6262
Six scopes, one home each. **Add new state to whichever class matches its true scope** — when in doubt, ask who outlives whom. The field roster on each class lives in the source XML docs; this section captures only the identity + load-bearing contracts.
6363

64-
- **`Simulation`** = server / instance. Holds `SystemHeapTables`, NEWSEQUENTIALID anchor, the `Databases` dict. Public surface (`Simulation` ctor + `CreateDbConnection()`) is the entire external API.
64+
- **`Simulation`** = server / instance. Holds `SystemHeapTables`, NEWSEQUENTIALID anchor, the `Databases` dict, `ServerCollationName` (string-typed `init`-only knob; defaults to `SQL_Latin1_General_CP1_CI_AS`; mirrors `model.collation` — install-time seed for every freshly-created `Database`, both the lazy `"simulated"` seed and bacpac imports without their own collation declaration; `init` reflects real SQL Server's immutability and Azure's outright block on changing it). Public surface (`Simulation` ctor + `CreateDbConnection()` + `ImportBacpac()` + `AddRemoteSimulation()` + `ServerCollationName`) is the entire external API.
6565
- **`Database`** (internal) = one database in the instance. Holds `Schemas`, `CompatibilityLevel`, `CollationName`, `Principals`, `Permissions`, `ExtendedProperties`, `FullTextCatalogs`, `DdlTriggers`, the rowversion counter (`@@DBTS`), MVCC version store. `Simulation.Databases` starts empty; the first `CreateDbConnection()` call lazily seeds `Simulation.DefaultDatabaseName` (`"simulated"`) when no `ImportBacpac` has landed a database first. `USE <db>` switches the session to a different entry (Msg 911 on miss); 3-part names route reads across databases (`SELECT * FROM other.dbo.t` works), but cross-DB writes raise `NotSupportedException` via `BatchContext.RejectCrossDatabaseMutation` — issue `USE` first.
6666
- **`Schema`** (internal) = one namespace inside a database. Holds the object dicts: `HeapTables`, `Functions`, `Views`, `Procedures`, `Sequences`, `Triggers` (DML — share the object namespace), and the separate type namespace `TableTypes` + `AliasTypes` + `XmlSchemaCollections`. Schema-qualified references (`audit.t`, `audit.fn`, `audit.proc`, `audit.seq`, …) route through `Database.Schemas["audit"]`; unqualified falls back to `Database.DefaultSchemaName` (`"dbo"`).
6767
- **`SimulatedDbConnection`** = session. Holds `CurrentDatabase`, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `LastErrorNumber` (`@@ERROR`), `NestingLevel` (capped at 32), `TempTables` (per-session `#foo` dict, cleared on Dispose), `Spid` (≥51), `LockTimeoutMillis`, `SessionIsolationLevel`, `FiringTriggerIds`, `CurrentExecutingThreadId` (drives same-thread-deadlock detection).

SqlServerSimulator.Tests/NameComparisonRegimeTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,61 @@ public void CsDatabase_HierarchyIdTypePrefix_UppercaseHIERARCHYID_NotResolved()
255255
public void CsDatabase_HierarchyIdTypePrefix_CanonicalCase_Resolves()
256256
=> AreEqual("/", CsCollation().ExecuteScalar(
257257
"SELECT hierarchyid::GetRoot().ToString()"));
258+
259+
// ===== Simulation.ServerCollationName seeds new-database collation =====
260+
// Mirrors SQL Server's model.collation role: setting it before the
261+
// first CreateDbConnection / ImportBacpac makes the lazy-seeded
262+
// "simulated" database (and any subsequent bacpac import that
263+
// doesn't declare its own collation) inherit the value. Equivalent
264+
// end state to ALTER DATABASE COLLATE for the simulated DB, but
265+
// chosen up-front rather than as a post-hoc adjustment.
266+
267+
[TestMethod]
268+
public void ServerCollationName_DefaultIsClassicCi()
269+
=> AreEqual("SQL_Latin1_General_CP1_CI_AS", new Simulation().ServerCollationName);
270+
271+
[TestMethod]
272+
public void ServerCollationName_SetToRecognizedCs_Persists()
273+
{
274+
var sim = new Simulation { ServerCollationName = "SQL_Latin1_General_CP1_CS_AS" };
275+
AreEqual("SQL_Latin1_General_CP1_CS_AS", sim.ServerCollationName);
276+
}
277+
278+
[TestMethod]
279+
public void ServerCollationName_Unrecognized_Throws()
280+
{
281+
var ex = Throws<ArgumentException>(() => new Simulation { ServerCollationName = "Not_A_Real_Collation" });
282+
Assert.Contains("not recognized", ex.Message);
283+
}
284+
285+
[TestMethod]
286+
public void ServerCollationName_Null_Throws()
287+
=> _ = Throws<ArgumentNullException>(() => new Simulation { ServerCollationName = null! });
288+
289+
[TestMethod]
290+
public void ServerCollationName_SeedsLazySimulatedDatabase()
291+
{
292+
var sim = new Simulation { ServerCollationName = "SQL_Latin1_General_CP1_CS_AS" };
293+
AreEqual("SQL_Latin1_General_CP1_CS_AS",
294+
sim.ExecuteScalar("SELECT collation_name FROM sys.databases WHERE name = N'simulated'"));
295+
}
296+
297+
[TestMethod]
298+
public void ServerCollationName_SeededDatabase_CreateSchemaDBO_CoexistsWithDbo()
299+
{
300+
// Probe-confirmed on real SQL Server CS database (2026-05-22):
301+
// CREATE SCHEMA DBO succeeds on a CS-collation DB — both `dbo`
302+
// and `DBO` end up in sys.schemas as distinct entries. The
303+
// ALTER-DATABASE-COLLATE variant (CsDatabase_CreateSchemaUppercaseDBO_…)
304+
// hits Msg 2714 instead because the Schemas dict's comparer
305+
// was built CI at construction time and doesn't rebuild on
306+
// ALTER (documented fidelity gap). With ServerCollationName
307+
// seeded up-front, the dict is CS from the start and the gap
308+
// doesn't apply — the simulator matches real SQL Server's
309+
// coexistence behavior.
310+
var sim = new Simulation { ServerCollationName = "SQL_Latin1_General_CP1_CS_AS" };
311+
_ = sim.ExecuteNonQuery("CREATE SCHEMA DBO");
312+
AreEqual(2, sim.ExecuteScalar("SELECT COUNT(*) FROM sys.schemas WHERE name IN (N'dbo', N'DBO')"));
313+
}
314+
258315
}

SqlServerSimulator.Tests/QualityTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public void PublicApiWhitelist()
2828
nameof(Simulation.CreateDbConnection),
2929
nameof(Simulation.ImportBacpac),
3030
nameof(Simulation.AddRemoteSimulation),
31+
nameof(Simulation.ServerCollationName),
3132
],
3233
[typeof(SimulatedDbConnection)] = [
3334
nameof(SimulatedDbConnection.InfoMessage),

SqlServerSimulator/Collation.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,23 @@ private protected Collation()
6868
/// <summary>
6969
/// The simulator's hardcoded baseline collation —
7070
/// <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
71+
/// <see cref="TryGet"/>. Two consumers:
72+
/// <list type="bullet">
73+
/// <item>The default for
74+
/// <see cref="Simulation.ServerCollation"/>, which in turn seeds every
75+
/// freshly-created <see cref="Database.Collation"/>. Apps that want a
76+
/// non-default identifier collation set <see cref="Simulation.ServerCollation"/>
77+
/// before the first <c>CreateDbConnection</c> / <c>ImportBacpac</c>;
78+
/// per-database divergence happens via <c>ALTER DATABASE COLLATE</c>.</item>
79+
/// <item>The storage-layer baseline for the
7580
/// <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.
81+
/// <see cref="CharSqlType"/> / <see cref="NCharSqlType"/> singletons
82+
/// (<c>Unspecified</c>, <c>MaxForm</c>, <c>Get(length)</c>). Those
83+
/// caches are process-global and can't be per-simulation, so literal /
84+
/// parameter / CAST result types carry this collation regardless of
85+
/// the active database; per-column declared collation overrides at
86+
/// compare and sort time.</item>
87+
/// </list>
8288
/// </summary>
8389
internal static Collation Default => defaultLazy.Value;
8490

SqlServerSimulator/Database.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,6 @@ internal sealed class Database
4545
/// <summary>Conventional schema-id for <c>sys</c> (matches real SQL Server).</summary>
4646
public const int SysSchemaId = 4;
4747

48-
public Database(string name)
49-
: this(name, Collation.Default)
50-
{
51-
}
52-
5348
public Database(string name, Collation collation)
5449
{
5550
this.Name = name;
@@ -109,7 +104,8 @@ public Database(string name, Collation collation)
109104
/// new collation, but the construction-time dict comparers don't
110105
/// rebuild (matches real SQL Server's behavior: existing objects keep
111106
/// their identifier registration, new ones bind under the new
112-
/// collation). Defaults to <see cref="Collation.Default"/>.
107+
/// collation). Seeded at construction from
108+
/// <see cref="Simulation.ServerCollation"/>.
113109
/// </summary>
114110
public Collation Collation;
115111

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -890,8 +890,8 @@ internal override void VisitOperandExpressions(Action<Expression> visitor)
890890
// Probe-confirmed wording order: right operand's collation first,
891891
// left operand's collation second.
892892
throw SimulatedSqlException.CollationConflict(
893-
(r.Type.Collation ?? Collation.Default).Name,
894-
(l.Type.Collation ?? Collation.Default).Name,
893+
r.Type.Collation!.Name,
894+
l.Type.Collation!.Name,
895895
operatorName);
896896
}
897897

@@ -1000,8 +1000,8 @@ private sealed class LikeExpression(Expression left, Expression right, Expressio
10001000
// name); higher rank wins otherwise.
10011001
var resolved = Collation.Resolve(l.Type, r.Type)
10021002
?? throw SimulatedSqlException.CollationConflict(
1003-
(r.Type.Collation ?? Collation.Default).Name,
1004-
(l.Type.Collation ?? Collation.Default).Name,
1003+
r.Type.Collation!.Name,
1004+
l.Type.Collation!.Name,
10051005
"like");
10061006

10071007
var matched = LikePatternBuilder.BuildAnchored(r.AsString, escapeChar, resolved.Collation.CaseSensitive).IsMatch(l.AsString);

SqlServerSimulator/SimulatedDbConnection.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ private static Database ResolveInitialDatabase(Simulation simulation)
5555
return existing;
5656
if (simulation.Databases.Count == 0)
5757
{
58-
var seeded = new Database(Simulation.DefaultDatabaseName);
58+
var seeded = new Database(Simulation.DefaultDatabaseName, simulation.ServerCollation);
5959
simulation.Databases.Add(Simulation.DefaultDatabaseName, seeded);
6060
return seeded;
6161
}

SqlServerSimulator/Simulation/Simulation.ImportBacpac.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ private void ImportBacpacCore(Stream stream, string databaseName, int maxDegreeO
5050
if (Databases.ContainsKey(databaseName))
5151
throw new InvalidOperationException($"A database named '{databaseName}' already exists in this Simulation. Import is a create-only operation; choose a different name via BacpacImportOptions.DatabaseName.");
5252
result = new BacpacImportResult();
53-
var database = new Database(databaseName);
53+
var database = new Database(databaseName, this.ServerCollation);
5454
Databases.Add(databaseName, database);
5555
BacpacReader.Load(stream, this, database, maxDegreeOfParallelism, result);
5656
}

SqlServerSimulator/Simulation/Simulation.cs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public void AddRemoteSimulation(string name, Simulation target)
7676
/// by <c>sp_addlinkedserver</c>; cleared by <c>sp_dropserver</c>.
7777
/// Four-part-name FROM resolution consults this dict; <c>sys.servers</c>
7878
/// projects one row per entry plus the local-server row. Case-
79-
/// insensitive keys (<see cref="Collation.Default"/>).
79+
/// insensitive keys (<see cref="BuiltInToken"/>).
8080
/// </summary>
8181
internal readonly ConcurrentDictionary<string, LinkedServer> ActiveLinkedServers = new(BuiltInToken.Comparer);
8282

@@ -89,6 +89,49 @@ public void AddRemoteSimulation(string name, Simulation target)
8989
/// </summary>
9090
internal const string DefaultDatabaseName = "simulated";
9191

92+
/// <summary>
93+
/// Server-wide default collation name. Used as the seed for every
94+
/// <see cref="Database"/> created on this simulation — both the lazy
95+
/// <c>"simulated"</c> seed picked up on first
96+
/// <see cref="CreateDbConnection"/> and bacpac imports that don't carry
97+
/// their own collation declaration. Defaults to
98+
/// <c>SQL_Latin1_General_CP1_CI_AS</c>.
99+
/// </summary>
100+
/// <remarks>
101+
/// Mirrors SQL Server's <c>model.collation</c>: install-time choice,
102+
/// immutable thereafter (the only way to change it on a real instance
103+
/// is the <c>sqlservr -m -q</c> rebuild-master dance, and it's blocked
104+
/// outright on Azure SQL). Hence <see langword="init"/>-only on this
105+
/// API — set it in an object initializer
106+
/// (<c>new Simulation { ServerCollationName = "…" }</c>) before the
107+
/// first <see cref="CreateDbConnection"/> /
108+
/// <see cref="ImportBacpac(Stream, out Storage.Bacpac.BacpacImportResult, Storage.Bacpac.BacpacImportOptions?)"/>.
109+
/// Per-database divergence after construction goes through
110+
/// <c>ALTER DATABASE COLLATE</c>, which only affects the targeted
111+
/// database. An unrecognized collation name raises
112+
/// <see cref="ArgumentException"/>.
113+
/// </remarks>
114+
public string ServerCollationName
115+
{
116+
get => this.ServerCollation.Name;
117+
init
118+
{
119+
ArgumentNullException.ThrowIfNull(value);
120+
this.ServerCollation = Collation.TryGet(value)
121+
?? throw new ArgumentException($"Collation '{value}' is not recognized by the simulator.", nameof(value));
122+
}
123+
}
124+
125+
/// <summary>
126+
/// Resolved <see cref="Collation"/> backing <see cref="ServerCollationName"/>.
127+
/// Internal accessor used by <see cref="Database"/> seeding paths
128+
/// (<c>SimulatedDbConnection.ResolveInitialDatabase</c>,
129+
/// <see cref="ImportBacpac(Stream, out Storage.Bacpac.BacpacImportResult, Storage.Bacpac.BacpacImportOptions?)"/>);
130+
/// public callers go through the string-typed property to keep
131+
/// <see cref="Collation"/> off the public API surface.
132+
/// </summary>
133+
internal Collation ServerCollation { get; private set; } = Collation.Default;
134+
92135
/// <summary>
93136
/// Per-database state hosted by this server instance, keyed by name.
94137
/// Starts empty; <see cref="SimulatedDbConnection"/>'s constructor

SqlServerSimulator/Storage/SqlValue.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ public bool Equals(SqlValue other) =>
701701
&& this.IsNull == other.IsNull
702702
&& (this.IsNull
703703
|| (IsStringTypeRef(this.Type)
704-
? (this.Type.Collation ?? Collation.Default).Equals(TrimTrailing((string)this.reference!), TrimTrailing((string)other.reference!))
704+
? this.Type.Collation!.Equals(TrimTrailing((string)this.reference!), TrimTrailing((string)other.reference!))
705705
: this.Type is DateTimeOffsetSqlType
706706
? this.primitive == other.primitive
707707
: this.Type == SqlType.UniqueIdentifier
@@ -756,7 +756,7 @@ public int CompareTo(SqlValue other) =>
756756
: this.Type == SqlType.SmallInt ? this.AsInt16.CompareTo(other.AsInt16)
757757
: this.Type == SqlType.TinyInt ? this.AsByte.CompareTo(other.AsByte)
758758
: this.Type == SqlType.Bit ? this.AsBoolean.CompareTo(other.AsBoolean)
759-
: IsStringTypeRef(this.Type) ? (this.Type.Collation ?? Collation.Default).Compare(TrimTrailing((string)this.reference!), TrimTrailing((string)other.reference!))
759+
: IsStringTypeRef(this.Type) ? this.Type.Collation!.Compare(TrimTrailing((string)this.reference!), TrimTrailing((string)other.reference!))
760760
: this.Type is RowVersionSqlType ? this.primitive.CompareTo(other.primitive)
761761
: this.Type is VarbinarySqlType or BinarySqlType or ImageSqlType ? this.AsBytes.AsSpan().SequenceCompareTo(other.AsBytes)
762762
: this.Type == SqlType.Date ? this.primitive.CompareTo(other.primitive)
@@ -787,7 +787,7 @@ public override int GetHashCode()
787787
{
788788
// Trailing spaces and case folding are part of equality, so the
789789
// hash must agree.
790-
hash.Add((this.Type.Collation ?? Collation.Default).GetHashCode(TrimTrailing((string)this.reference!)));
790+
hash.Add(this.Type.Collation!.GetHashCode(TrimTrailing((string)this.reference!)));
791791
}
792792
else if (this.Type is DateTimeOffsetSqlType)
793793
{

0 commit comments

Comments
 (0)