Skip to content

Commit 53d728b

Browse files
committed
Collation information is now carried into string literals. Minor documentation cleanup.
1 parent 96a4736 commit 53d728b

7 files changed

Lines changed: 66 additions & 21 deletions

File tree

SqlServerSimulator.Tests.Internal/Parser/TokenLineNumberTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public void NewlineWithinACommentToken_PushesLaterTokensDown()
7777
private static IEnumerable<Token> TokenizeMeaningful(string command)
7878
{
7979
var index = 0;
80-
while (Tokenizer.NextToken(command, ref index) is Token t)
80+
while (Tokenizer.NextToken(command, ref index, Collation.Default) is Token t)
8181
{
8282
if (t is Whitespace or Comment)
8383
continue;

SqlServerSimulator.Tests/NameComparisonRegimeTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,4 +335,26 @@ public void CsDatabase_NCharFunctionResultUsesActiveCollation()
335335
[TestMethod]
336336
public void CiDatabase_CharFunctionResult_StillEqualsUnderCi()
337337
=> AreEqual("eq", new Simulation().ExecuteScalar("SELECT IIF(CHAR(65) = CHAR(97), 'eq', 'neq')"));
338+
339+
// ===== String literals carry the active DB collation =====
340+
// Tokenizer.NextToken receives the executing database's collation and
341+
// tags every 'foo' / N'foo' literal with it at CoercibleDefault, so
342+
// literal-vs-literal comparisons in a CS database evaluate
343+
// case-sensitively without needing a column to anchor the rank.
344+
345+
[TestMethod]
346+
public void CsDatabase_TwoVarcharLiteralsCompareCaseSensitively()
347+
=> AreEqual("neq",
348+
new Simulation { ServerCollationName = "SQL_Latin1_General_CP1_CS_AS" }
349+
.ExecuteScalar("SELECT IIF('A' = 'a', 'eq', 'neq')"));
350+
351+
[TestMethod]
352+
public void CsDatabase_TwoNVarcharLiteralsCompareCaseSensitively()
353+
=> AreEqual("neq",
354+
new Simulation { ServerCollationName = "SQL_Latin1_General_CP1_CS_AS" }
355+
.ExecuteScalar("SELECT IIF(N'A' = N'a', 'eq', 'neq')"));
356+
357+
[TestMethod]
358+
public void CiDatabase_TwoVarcharLiterals_StillEqualUnderCi()
359+
=> AreEqual("eq", new Simulation().ExecuteScalar("SELECT IIF('A' = 'a', 'eq', 'neq')"));
338360
}

SqlServerSimulator/Collation.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ internal static (Collation Collation, Coercibility Coercibility)? Resolve(SqlTyp
172172

173173
/// <summary>
174174
/// Generic culture-driven collation. Pins a
175-
/// <see cref="System.Globalization.CompareInfo"/> and a flag-derived
175+
/// <see cref="CompareInfo"/> and a flag-derived
176176
/// <see cref="CompareOptions"/> set; routes <see cref="Compare"/> /
177177
/// <see cref="Equals"/> / <see cref="GetHashCode"/> through them.
178178
/// Sort options layer <see cref="CompareOptions.IgnoreSymbols"/> on

SqlServerSimulator/Parser/ParserContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ public void MoveNextRequired<T>()
266266
[MemberNotNullWhen(true, nameof(Token))]
267267
public bool MoveNext()
268268
{
269-
while (Tokenizer.NextToken(commandText, ref index) is Token token)
269+
while (Tokenizer.NextToken(commandText, ref index, this.CurrentDatabase.Collation) is Token token)
270270
{
271271
if (token is Whitespace or Comment)
272272
continue;

SqlServerSimulator/Parser/Tokenizer.cs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ namespace SqlServerSimulator.Parser;
2222
/// while characters match, stop at the first that doesn't, return — the
2323
/// natural exit position of that loop is already correct.
2424
/// </para>
25+
/// <para>
26+
/// <b>Active collation.</b> String literals (<c>'foo'</c>, <c>N'foo'</c>)
27+
/// produce <see cref="SqlValue"/>s tagged with the active database
28+
/// collation at <see cref="Coercibility.CoercibleDefault"/>, matching
29+
/// real SQL Server's rule that a string literal inherits the executing
30+
/// database's collation. Callers thread <see cref="ParserContext.CurrentDatabase"/>'s
31+
/// collation into <see cref="NextToken"/>; other literal kinds (varbinary,
32+
/// money) don't carry collation and ignore the parameter.
33+
/// </para>
2534
/// </remarks>
2635
static class Tokenizer
2736
{
@@ -30,17 +39,18 @@ static class Tokenizer
3039
/// </summary>
3140
/// <param name="command">The command from which a token is produced.</param>
3241
/// <param name="index">The position of the next un-read character (0 to begin); updated to the next un-read position past the returned token.</param>
42+
/// <param name="activeCollation">Collation tagged onto string-literal <see cref="SqlValue"/>s; supplied by the caller's active <see cref="Database"/>.</param>
3343
/// <returns>The next token, or null if the end of <paramref name="command"/> has been reached.</returns>
3444
/// <exception cref="SimulatedSqlException">Incorrect or unsupported syntax.</exception>
35-
public static Token? NextToken(string command, ref int index) =>
45+
public static Token? NextToken(string command, ref int index, Collation activeCollation) =>
3646
index >= command.Length ? null : command[index] switch
3747
{
3848
' ' or '\r' or '\n' or '\t' => ParseWhitespace(command, ref index),
39-
'N' or 'n' when index + 1 < command.Length && command[index + 1] == '\'' => ParseNPrefixedStringLiteral(command, ref index),
49+
'N' or 'n' when index + 1 < command.Length && command[index + 1] == '\'' => ParseNPrefixedStringLiteral(command, ref index, activeCollation),
4050
'_' or (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') => ParseUnquotedStringOrReservedKeyword(command, ref index),
4151
'0' when index + 1 < command.Length && (command[index + 1] == 'x' || command[index + 1] == 'X') => ParseHexLiteral(command, ref index),
4252
>= '0' and <= '9' => ParseNumeric(command, ref index),
43-
'\'' => ParseStringLiteral(command, ref index),
53+
'\'' => ParseStringLiteral(command, ref index, activeCollation),
4454
'@' => ParseAtOrDoubleAtPrefixedString(command, ref index),
4555
'#' => ParseHashPrefixedName(command, ref index),
4656
'-' => ParseMinusOrComment(command, ref index),
@@ -195,9 +205,11 @@ private static Token ParseForwardSlashOrComment(string command, ref int index)
195205
/// <summary>
196206
/// Parses a SQL string literal: <c>'foo'</c>, with <c>''</c> as the
197207
/// embedded-apostrophe escape. The opening quote is at <paramref name="index"/>;
198-
/// returns a <see cref="Literal"/> typed as <see cref="SqlType.Varchar"/>.
208+
/// returns a <see cref="Literal"/> typed as <see cref="SqlType.Varchar"/>
209+
/// tagged with <paramref name="activeCollation"/> at
210+
/// <see cref="Coercibility.CoercibleDefault"/>.
199211
/// </summary>
200-
private static Literal ParseStringLiteral(string command, ref int index)
212+
private static Literal ParseStringLiteral(string command, ref int index, Collation activeCollation)
201213
{
202214
var start = index;
203215
var builder = new StringBuilder();
@@ -217,7 +229,8 @@ private static Literal ParseStringLiteral(string command, ref int index)
217229
continue;
218230
}
219231

220-
return new Literal(SqlValue.FromVarchar(builder.ToString()), command, start, ++index - start);
232+
var literalType = VarcharSqlType.Get(0, activeCollation, Coercibility.CoercibleDefault);
233+
return new Literal(SqlValue.FromVarchar(literalType, builder.ToString()), command, start, ++index - start);
221234
}
222235

223236
throw SimulatedSqlException.UnclosedStringLiteral();
@@ -227,9 +240,11 @@ private static Literal ParseStringLiteral(string command, ref int index)
227240
/// Parses an N-prefixed Unicode string literal: <c>N'foo'</c>. The leading
228241
/// N (or n) is at <paramref name="index"/>; the body uses the same
229242
/// <c>''</c>-escape rules as a plain string literal but the result is
230-
/// typed as <see cref="SqlType.NVarchar"/>.
243+
/// typed as <see cref="SqlType.NVarchar"/> tagged with
244+
/// <paramref name="activeCollation"/> at
245+
/// <see cref="Coercibility.CoercibleDefault"/>.
231246
/// </summary>
232-
private static Literal ParseNPrefixedStringLiteral(string command, ref int index)
247+
private static Literal ParseNPrefixedStringLiteral(string command, ref int index, Collation activeCollation)
233248
{
234249
var start = index;
235250
index++; // skip the N
@@ -250,7 +265,8 @@ private static Literal ParseNPrefixedStringLiteral(string command, ref int index
250265
continue;
251266
}
252267

253-
return new Literal(SqlValue.FromNVarchar(builder.ToString()), command, start, ++index - start);
268+
var literalType = NVarcharSqlType.Get(0, activeCollation, Coercibility.CoercibleDefault);
269+
return new Literal(SqlValue.FromNVarchar(literalType, builder.ToString()), command, start, ++index - start);
254270
}
255271

256272
throw SimulatedSqlException.UnclosedStringLiteral();

SqlServerSimulator/Storage/Bacpac/ModelXmlReader.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,14 @@ private static void EmitDatabaseOptions(XElement element, DbConnection connectio
248248
using var command = connection.CreateCommand();
249249

250250
// Collation: emit ALTER DATABASE COLLATE when the name is on the
251-
// recognized whitelist; the simulator stores it as metadata on
252-
// Database.CollationName for catalog-view round-trip. Comparison /
253-
// sort / LIKE still route through Collation.Default — see
254-
// docs/claude/database-options.md COLLATE-clause caveat. An
255-
// unrecognized name lands on Warnings rather than aborting the load
256-
// (the loader's best-effort contract).
251+
// recognized catalog. The statement lands on Database.Collation, so
252+
// subsequent column declarations without their own COLLATE clause
253+
// inherit it and route compare / sort / hash through the chosen
254+
// collation. An unrecognized name lands on Warnings rather than
255+
// aborting the load (the loader's best-effort contract); the
256+
// database keeps its construction-time server collation, and any
257+
// columns the bacpac declares with explicit COLLATE clauses still
258+
// pin those collations independently.
257259
var collation = element.Elements(Ns + "Property")
258260
.FirstOrDefault(p => p.Attribute("Name")?.Value == "Collation")
259261
?.Attribute("Value")?.Value;
@@ -268,7 +270,7 @@ private static void EmitDatabaseOptions(XElement element, DbConnection connectio
268270
}
269271
else
270272
{
271-
result.AddWarning($"Database declares Collation '{collation}' which isn't on the simulator's recognized list — stored as metadata, but comparison semantics fall back to the default. Add it to Collation.Recognized to surface in catalog views.");
273+
result.AddWarning($"Database declares Collation '{collation}' which the simulator's catalog doesn't recognize — the database keeps its server-default collation; columns declared with their own COLLATE clauses still pin those collations.");
272274
}
273275
}
274276

docs/claude/collations.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,18 @@ Sites routed through the active DB collation:
7373

7474
Probe-confirmed fidelity (real SQL Server CS database, 2026-05-22): `SELECT IIF(CHAR(65) = CHAR(97), 'eq', 'neq')` returns `'neq'` (literals don't case-fold under CS). The simulator now matches; the `CsDatabase_*CharFunctionResultUsesActiveCollation` tests in `NameComparisonRegimeTests.cs` lock the behavior in.
7575

76-
Sites that intentionally stay on `Collation.Default` (~34 references):
76+
Sites that intentionally stay on `Collation.Default`:
7777
- System catalog schemas in `BuiltInResources.cs` — process-wide statics, no per-Simulation affinity.
7878
- `SqlType.Varchar` / `NVarchar` pseudo-singletons and `SqlType.GetChar` / `GetNChar` static bridges — type-identity placeholders.
7979
- `text` / `ntext` / `sysname` `Collation` overrides — server-default-only types.
8080
- Error-message type placeholders, dynamic-SQL string extraction, PRINT formatting — collation irrelevant to the surfaced value.
8181
- `Simulation.ServerCollation` initializer — the deliberate anchor for "what does the simulator's hardcoded baseline resolve to."
82-
- **String literals via the tokenizer** (`Value` / `Literal`) — literals like `'A'` and `N'foo'` still carry `Collation.Default` because the tokenizer materializes the `SqlValue` without batch context. Closing this would require threading `BatchContext` through `Tokenizer.NextToken` — deferred. The gap surfaces as: `SELECT 'A' = 'a'` on a CS database returns `1` (CI compare) instead of `0` (CS compare). Real workloads typically have a column on at least one side, and the column's `Implicit`-rank collation wins via `Collation.Resolve`, so this gap is unobservable in EF Core workloads.
82+
83+
## String literals carry the active DB collation
84+
85+
`Tokenizer.NextToken` takes a `Collation activeCollation` parameter; `ParserContext.MoveNext` threads `context.CurrentDatabase.Collation` in. The two string-literal entry points (`ParseStringLiteral` for `'foo'`, `ParseNPrefixedStringLiteral` for `N'foo'`) construct `VarcharSqlType.Get(0, activeCollation, Coercibility.CoercibleDefault)` / `NVarcharSqlType` and tag the resulting `SqlValue` with it. Other literal kinds (varbinary `0xHEX`, currency `$1.23`) don't carry collation and ignore the parameter.
86+
87+
Effect: `SELECT IIF('A' = 'a', 'eq', 'neq')` on a CS database returns `'neq'` (case-sensitive), matching real SQL Server. The `CsDatabase_TwoVarcharLiteralsCompareCaseSensitively` / `CsDatabase_TwoNVarcharLiteralsCompareCaseSensitively` tests in `NameComparisonRegimeTests.cs` lock the behavior in. The earlier deferral framing (literal pairs falling through to the CI baseline because the tokenizer was stateless) is closed.
8388

8489
`ALTER COLUMN` without an explicit `COLLATE` clause preserves the existing column's collation (probe-aligned). With an explicit `COLLATE`, the new collation pins at `Implicit` rank.
8590

0 commit comments

Comments
 (0)