Skip to content

Commit a503020

Browse files
committed
Supplementary-character-aware collations now have correct behavior with various string functions.
1 parent f164c32 commit a503020

14 files changed

Lines changed: 370 additions & 70 deletions

File tree

SqlServerSimulator.Tests/CollationDeclaredColumnTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,5 +979,67 @@ public void CastAsCharN_WithPostfixCollateUtf8_MatchesByteBudget(string input, i
979979
AreEqual(expectedHex, actualHex);
980980
}
981981

982+
/// <summary>
983+
/// Scalar functions switch indexing semantics on the input value's
984+
/// collation: code-unit semantics under non-<c>_SC_</c>, codepoint
985+
/// semantics under <c>_SC_</c>. All eight affected functions
986+
/// probe-confirmed against SQL Server 2025 (2026-05-21) using a single
987+
/// supplementary character (😀 = U+1F600) as the test fixture.
988+
/// </summary>
989+
[TestMethod]
990+
[DataRow("len(N'😀' collate Latin1_General_100_CI_AS)", "2", DisplayName = "LEN non-SC = 2 code units")]
991+
[DataRow("len(N'😀' collate Latin1_General_100_CI_AS_SC_UTF8)", "1", DisplayName = "LEN SC = 1 codepoint")]
992+
[DataRow("len(N'a😀b' collate Latin1_General_100_CI_AS)", "4", DisplayName = "LEN non-SC a😀b = 4")]
993+
[DataRow("len(N'a😀b' collate Latin1_General_100_CI_AS_SC_UTF8)", "3", DisplayName = "LEN SC a😀b = 3")]
994+
[DataRow("charindex(N'X', N'😀X' collate Latin1_General_100_CI_AS)", "3", DisplayName = "CHARINDEX non-SC = position 3")]
995+
[DataRow("charindex(N'X', N'😀X' collate Latin1_General_100_CI_AS_SC_UTF8)", "2", DisplayName = "CHARINDEX SC = position 2")]
996+
[DataRow("patindex(N'%X%', N'😀X' collate Latin1_General_100_CI_AS)", "3", DisplayName = "PATINDEX non-SC = 3")]
997+
[DataRow("patindex(N'%X%', N'😀X' collate Latin1_General_100_CI_AS_SC_UTF8)", "2", DisplayName = "PATINDEX SC = 2")]
998+
[DataRow("unicode(N'😀' collate Latin1_General_100_CI_AS)", "55357", DisplayName = "UNICODE non-SC = 55357 high surrogate")]
999+
[DataRow("unicode(N'😀' collate Latin1_General_100_CI_AS_SC_UTF8)", "128512", DisplayName = "UNICODE SC = 128512 codepoint")]
1000+
public void SupplementaryCharFunctions_PositionAndLengthDispatch(string expression, string expectedScalar)
1001+
=> AreEqual(int.Parse(expectedScalar), new Simulation().ExecuteScalar($"select {expression}"));
1002+
1003+
/// <summary>
1004+
/// SUBSTRING / LEFT / RIGHT / REVERSE / STUFF return string results
1005+
/// whose UTF-16 byte content differs between SC and non-SC. Compares
1006+
/// the .NET string char-by-char to the expected UTF-16 LE byte hex —
1007+
/// lone surrogates that real SQL Server preserves under non-SC are
1008+
/// also preserved by the simulator (the nvarchar Encode/Decode path
1009+
/// byte-copies directly, bypassing <c>Encoding.Unicode</c>'s lone-
1010+
/// surrogate replacement).
1011+
/// </summary>
1012+
[TestMethod]
1013+
[DataRow("substring(N'😀X' collate Latin1_General_100_CI_AS, 1, 1)", "3DD8", DisplayName = "SUBSTRING non-SC pos 1 len 1 = lone high surrogate")]
1014+
[DataRow("substring(N'😀X' collate Latin1_General_100_CI_AS_SC_UTF8, 1, 1)", "3DD800DE", DisplayName = "SUBSTRING SC pos 1 len 1 = full emoji")]
1015+
[DataRow("substring(N'😀X' collate Latin1_General_100_CI_AS, 2, 1)", "00DE", DisplayName = "SUBSTRING non-SC pos 2 len 1 = lone low surrogate")]
1016+
[DataRow("substring(N'😀X' collate Latin1_General_100_CI_AS_SC_UTF8, 2, 1)", "5800", DisplayName = "SUBSTRING SC pos 2 len 1 = X")]
1017+
[DataRow("left(N'😀X' collate Latin1_General_100_CI_AS, 1)", "3DD8", DisplayName = "LEFT non-SC")]
1018+
[DataRow("left(N'😀X' collate Latin1_General_100_CI_AS_SC_UTF8, 1)", "3DD800DE", DisplayName = "LEFT SC")]
1019+
[DataRow("right(N'X😀' collate Latin1_General_100_CI_AS, 1)", "00DE", DisplayName = "RIGHT non-SC")]
1020+
[DataRow("right(N'X😀' collate Latin1_General_100_CI_AS_SC_UTF8, 1)", "3DD800DE", DisplayName = "RIGHT SC")]
1021+
[DataRow("reverse(N'😀X' collate Latin1_General_100_CI_AS)", "580000DE3DD8", DisplayName = "REVERSE non-SC tears surrogate pair")]
1022+
[DataRow("reverse(N'😀X' collate Latin1_General_100_CI_AS_SC_UTF8)", "58003DD800DE", DisplayName = "REVERSE SC keeps emoji intact")]
1023+
[DataRow("stuff(N'😀X' collate Latin1_General_100_CI_AS, 1, 1, N'Y')", "590000DE5800", DisplayName = "STUFF non-SC replaces 1 code unit (high surrogate)")]
1024+
[DataRow("stuff(N'😀X' collate Latin1_General_100_CI_AS_SC_UTF8, 1, 1, N'Y')", "59005800", DisplayName = "STUFF SC replaces 1 codepoint (whole emoji)")]
1025+
public void SupplementaryCharFunctions_ResultByteContent(string expression, string expectedHex)
1026+
{
1027+
var result = (string)new Simulation().ExecuteScalar($"select {expression}")!;
1028+
var actualHex = AsUtf16LeHex(result);
1029+
AreEqual(expectedHex, actualHex);
1030+
}
1031+
1032+
/// <summary>Renders a .NET string as UTF-16 LE byte hex, preserving lone surrogates.</summary>
1033+
private static string AsUtf16LeHex(string s)
1034+
{
1035+
var bytes = new byte[s.Length * 2];
1036+
for (var i = 0; i < s.Length; i++)
1037+
{
1038+
bytes[i * 2] = (byte)(s[i] & 0xFF);
1039+
bytes[(i * 2) + 1] = (byte)((s[i] >> 8) & 0xFF);
1040+
}
1041+
return Convert.ToHexString(bytes);
1042+
}
1043+
9821044
private static void IsNull(object? value) => Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsNull(value is DBNull ? null : value);
9831045
}

SqlServerSimulator/Collation.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,14 @@ private protected Collation()
283283
/// <c>collations.md</c>.</summary>
284284
internal static readonly CultureCollation Latin1General100CiAsScUtf8 = new(
285285
"Latin1_General_100_CI_AS_SC_UTF8", CultureInfo.InvariantCulture.Name, caseSensitive: false,
286-
storageEncoding: Encoding.UTF8);
286+
storageEncoding: Encoding.UTF8, isSupplementaryCharacterAware: true);
287287

288288
/// <summary>"Latin1_General_100_CS_AS_SC_UTF8" — case-sensitive UTF-8
289289
/// variant. Sort / compare matches <see cref="Latin1GeneralCsAs"/>;
290290
/// <c>StorageEncoding</c> is UTF-8.</summary>
291291
internal static readonly CultureCollation Latin1General100CsAsScUtf8 = new(
292292
"Latin1_General_100_CS_AS_SC_UTF8", CultureInfo.InvariantCulture.Name, caseSensitive: true,
293-
storageEncoding: Encoding.UTF8);
293+
storageEncoding: Encoding.UTF8, isSupplementaryCharacterAware: true);
294294

295295
/// <summary>"Latin1_General_100_BIN2_UTF8" — binary UTF-8 variant.
296296
/// On nvarchar / nchar storage the comparer body is
@@ -473,6 +473,25 @@ internal static (Collation Collation, Coercibility Coercibility)? Resolve(SqlTyp
473473
/// </summary>
474474
internal virtual Collation ForVarcharStorage() => this;
475475

476+
/// <summary>
477+
/// True for collations whose name carries the <c>_SC_</c>
478+
/// (supplementary-character-aware) flag. Real SQL Server's text functions
479+
/// — <c>LEN</c>, <c>SUBSTRING</c>, <c>LEFT</c>, <c>RIGHT</c>,
480+
/// <c>CHARINDEX</c>, <c>PATINDEX</c>, <c>STUFF</c>, <c>REVERSE</c>,
481+
/// <c>UNICODE</c> — switch from UTF-16 code-unit semantics (non-SC) to
482+
/// Unicode-codepoint semantics (SC) based on the input value's collation:
483+
/// <c>LEN(N'😀')</c> returns 2 (code units) under non-SC and 1 (codepoint)
484+
/// under SC; <c>SUBSTRING(N'😀X', 1, 1)</c> returns a lone high surrogate
485+
/// under non-SC and the full emoji under SC. The simulator's default is
486+
/// <see langword="false"/> (code-unit semantics, matching non-SC); the
487+
/// two recognized <c>_SC_UTF8</c> collations override to
488+
/// <see langword="true"/>. Sort behavior is governed separately by the
489+
/// individual collations' <see cref="Compare"/> bodies — see the "Known
490+
/// gaps" entry in <c>collations.md</c> for the residual non-<c>_SC_</c>
491+
/// sort divergence on pre-v100 collations.
492+
/// </summary>
493+
internal virtual bool IsSupplementaryCharacterAware => false;
494+
476495
/// <summary>
477496
/// The byte encoding the storage layer uses when this collation is
478497
/// pinned on a <c>varchar</c> / <c>char</c> column. Default is CP1252,
@@ -863,12 +882,15 @@ internal sealed class CultureCollation : Collation
863882

864883
private readonly Encoding storageEncoding;
865884

866-
internal CultureCollation(string name, string cultureName, bool caseSensitive, bool kanaTypeSensitive = false, bool widthSensitive = false, Encoding? storageEncoding = null)
885+
private readonly bool isSupplementaryCharacterAware;
886+
887+
internal CultureCollation(string name, string cultureName, bool caseSensitive, bool kanaTypeSensitive = false, bool widthSensitive = false, Encoding? storageEncoding = null, bool isSupplementaryCharacterAware = false)
867888
{
868889
this.name = name;
869890
this.caseSensitive = caseSensitive;
870891
this.compareInfo = CultureInfo.GetCultureInfo(cultureName).CompareInfo;
871892
this.storageEncoding = storageEncoding ?? CharSqlType.Cp1252Encoder;
893+
this.isSupplementaryCharacterAware = isSupplementaryCharacterAware;
872894
var baseOpts = caseSensitive
873895
? CompareOptions.None
874896
: CompareOptions.IgnoreCase;
@@ -891,6 +913,8 @@ internal CultureCollation(string name, string cultureName, bool caseSensitive, b
891913

892914
internal override Encoding StorageEncoding => this.storageEncoding;
893915

916+
internal override bool IsSupplementaryCharacterAware => this.isSupplementaryCharacterAware;
917+
894918
public override int Compare(string? x, string? y) =>
895919
x is null
896920
? (y is null ? 0 : -1)

SqlServerSimulator/Parser/Expressions/CharIndex.cs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,34 @@ public override SqlValue Run(RuntimeContext runtime)
3434
if (!SqlType.IsStringCategory(n.Type) || !SqlType.IsStringCategory(h.Type))
3535
throw new NotSupportedException("CHARINDEX expects string operands.");
3636

37-
var startIndex = 0;
37+
var needleStr = n.AsString;
38+
var haystackStr = h.AsString;
39+
// CHARINDEX indexes in code units under non-SC collations and in
40+
// codepoints under _SC_. Probe-confirmed against SQL Server 2025:
41+
// CHARINDEX(N'X', N'😀X') = 3 under non-SC (surrogate pair occupies
42+
// positions 1-2) and = 2 under _SC_UTF8 (emoji = position 1). The
43+
// start argument is in the same unit as the result.
44+
var isSc = h.Type.Collation?.IsSupplementaryCharacterAware == true;
45+
var startUnits = 0;
3846
if (start is not null)
3947
{
4048
var startValue = start.Run(runtime);
4149
if (startValue.IsNull)
4250
return SqlValue.Null(SqlType.Int32);
43-
startIndex = Math.Max(0, startValue.CoerceTo(SqlType.Int32).AsInt32 - 1);
51+
startUnits = Math.Max(0, startValue.CoerceTo(SqlType.Int32).AsInt32 - 1);
4452
}
45-
46-
var needleStr = n.AsString;
47-
var haystackStr = h.AsString;
48-
if (startIndex >= haystackStr.Length)
53+
var startCu = isSc
54+
? SupplementaryCharacters.CodepointToCodeUnit(haystackStr, startUnits)
55+
: startUnits;
56+
if (startCu >= haystackStr.Length)
4957
return SqlValue.FromInt32(0);
5058

51-
var found = haystackStr.IndexOf(needleStr, startIndex, StringComparison.InvariantCultureIgnoreCase);
52-
return SqlValue.FromInt32(found < 0 ? 0 : found + 1);
59+
var foundCu = haystackStr.IndexOf(needleStr, startCu, StringComparison.InvariantCultureIgnoreCase);
60+
return SqlValue.FromInt32(foundCu < 0
61+
? 0
62+
: isSc
63+
? SupplementaryCharacters.CodeUnitToCodepoint(haystackStr, foundCu) + 1
64+
: foundCu + 1);
5365
}
5466

5567
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;

SqlServerSimulator/Parser/Expressions/Left.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ public override SqlValue Run(RuntimeContext runtime)
3434
throw SimulatedSqlException.NegativeLengthNotAllowed("LEFT");
3535

3636
var input = s.AsString;
37-
return SqlValue.FromString(s.Type, len >= input.Length ? input : input[..len]);
37+
var result = s.Type.Collation?.IsSupplementaryCharacterAware == true
38+
? SupplementaryCharacters.LeftByCodepoints(input, len)
39+
: len >= input.Length ? input : input[..len];
40+
return SqlValue.FromString(s.Type, result);
3841
}
3942

4043
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => source.GetSqlType(resolveColumnType);

SqlServerSimulator/Parser/Expressions/Length.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ namespace SqlServerSimulator.Parser.Expressions;
88
/// </summary>
99
/// <remarks>
1010
/// SQL Server's quirk: <c>LEN</c> ignores trailing spaces but not leading
11-
/// spaces. The simulator measures characters in <see cref="string.Length"/>
12-
/// terms (UCS-2 code units for nvarchar; bytes-equal-chars for varchar in
13-
/// CP1252), matching SQL Server.
11+
/// spaces. The simulator measures by code unit (<see cref="string.Length"/>)
12+
/// under non-SC collations and by Unicode codepoint (rune count) under
13+
/// <c>_SC_</c>-flagged collations — probe-confirmed against SQL Server
14+
/// 2025: <c>LEN(N'😀')</c> = 2 under default / <c>Latin1_General_100_CI_AS</c>
15+
/// (code units, surrogate pair counts as 2) and = 1 under
16+
/// <c>Latin1_General_100_CI_AS_SC_UTF8</c> (the supplementary codepoint
17+
/// counts as 1).
1418
/// Reference: https://learn.microsoft.com/en-us/sql/t-sql/functions/len-transact-sql
1519
/// </remarks>
1620
internal sealed class Length(ParserContext context) : Expression
@@ -28,7 +32,10 @@ public override SqlValue Run(RuntimeContext runtime)
2832
if (!SqlType.IsStringCategory(value.Type))
2933
throw new NotSupportedException($"LEN expects a string operand; got {value.Type}.");
3034
var trimmed = value.AsString.TrimEnd(' ');
31-
return SqlValue.FromInt32(trimmed.Length);
35+
var length = value.Type.Collation?.IsSupplementaryCharacterAware == true
36+
? SupplementaryCharacters.CodepointCount(trimmed)
37+
: trimmed.Length;
38+
return SqlValue.FromInt32(length);
3239
}
3340

3441
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;

SqlServerSimulator/Parser/Expressions/PatIndex.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,14 @@ public override SqlValue Run(RuntimeContext runtime)
5757
: p.CoerceTo(s.Type).AsString;
5858

5959
var regex = LikePatternBuilder.BuildForPatIndex(patternString);
60-
var match = regex.Match(s.AsString);
61-
var position = match.Success ? (long)match.Index + 1 : 0L;
60+
var subjectStr = s.AsString;
61+
var match = regex.Match(subjectStr);
62+
// Result position is code-unit-based under non-SC, codepoint-based
63+
// under _SC_ — matches CHARINDEX dispatch on the subject's collation.
64+
var position = !match.Success ? 0L
65+
: s.Type.Collation?.IsSupplementaryCharacterAware == true
66+
? (long)SupplementaryCharacters.CodeUnitToCodepoint(subjectStr, match.Index) + 1
67+
: (long)match.Index + 1;
6268
return isBig ? SqlValue.FromInt64(position) : SqlValue.FromInt32((int)position);
6369
}
6470

SqlServerSimulator/Parser/Expressions/Reverse.cs

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ namespace SqlServerSimulator.Parser.Expressions;
44

55
/// <summary>
66
/// SQL <c>REVERSE(x)</c>: returns the source with its characters in reverse
7-
/// order. Surrogate pairs are reversed as a unit (their high/low order is
8-
/// preserved) so emoji and supplementary-plane characters don't get torn.
7+
/// order. Reverses by code unit under non-<c>_SC_</c> collations (surrogate
8+
/// pairs are split — the high/low halves swap positions) and by codepoint
9+
/// under <c>_SC_</c> collations (surrogate pairs stay intact). Probe-
10+
/// confirmed against SQL Server 2025: <c>REVERSE(N'😀X')</c> on a non-SC
11+
/// collation returns <c>X</c> followed by the swapped surrogate bytes,
12+
/// matching <c>0x580000DE3DD8</c>; the same call on
13+
/// <c>Latin1_General_100_CI_AS_SC_UTF8</c> returns <c>X</c> followed by an
14+
/// intact emoji (<c>0x58003DD800DE</c>).
915
/// </summary>
1016
/// <remarks>Reference: https://learn.microsoft.com/en-us/sql/t-sql/functions/reverse-transact-sql</remarks>
1117
internal sealed class Reverse(ParserContext context) : Expression
@@ -21,27 +27,10 @@ public override SqlValue Run(RuntimeContext runtime)
2127
throw new NotSupportedException($"REVERSE expects a string operand; got {value.Type}.");
2228

2329
var input = value.AsString;
24-
var reversed = new char[input.Length];
25-
var sourceIndex = 0;
26-
var destIndex = input.Length;
27-
while (sourceIndex < input.Length)
28-
{
29-
var c = input[sourceIndex];
30-
if (char.IsHighSurrogate(c) && sourceIndex + 1 < input.Length && char.IsLowSurrogate(input[sourceIndex + 1]))
31-
{
32-
destIndex -= 2;
33-
reversed[destIndex] = c;
34-
reversed[destIndex + 1] = input[sourceIndex + 1];
35-
sourceIndex += 2;
36-
}
37-
else
38-
{
39-
destIndex--;
40-
reversed[destIndex] = c;
41-
sourceIndex++;
42-
}
43-
}
44-
return SqlValue.FromString(value.Type, new string(reversed));
30+
var reversed = value.Type.Collation?.IsSupplementaryCharacterAware == true
31+
? SupplementaryCharacters.ReverseByCodepoints(input)
32+
: SupplementaryCharacters.ReverseByCodeUnits(input);
33+
return SqlValue.FromString(value.Type, reversed);
4534
}
4635

4736
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => source.GetSqlType(resolveColumnType);

SqlServerSimulator/Parser/Expressions/Right.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ public override SqlValue Run(RuntimeContext runtime)
3434
throw SimulatedSqlException.NegativeLengthNotAllowed("RIGHT");
3535

3636
var input = s.AsString;
37-
return SqlValue.FromString(s.Type, len >= input.Length ? input : input[(input.Length - len)..]);
37+
var result = s.Type.Collation?.IsSupplementaryCharacterAware == true
38+
? SupplementaryCharacters.RightByCodepoints(input, len)
39+
: len >= input.Length ? input : input[(input.Length - len)..];
40+
return SqlValue.FromString(s.Type, result);
3841
}
3942

4043
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => source.GetSqlType(resolveColumnType);

SqlServerSimulator/Parser/Expressions/Stuff.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,23 @@ public override SqlValue Run(RuntimeContext runtime)
5454
var startIndex = startValue.CoerceTo(SqlType.Int32).AsInt32;
5555
var len = lengthValue.CoerceTo(SqlType.Int32).AsInt32;
5656
var s = inputValue.AsString;
57+
// STUFF indexes/lengths are code-unit-based under non-SC and
58+
// codepoint-based under _SC_. Under _SC_ a delete count of 1
59+
// removes one full codepoint (whole emoji) rather than splitting
60+
// a surrogate pair. Probe-confirmed against SQL Server 2025.
61+
var isSc = inputValue.Type.Collation?.IsSupplementaryCharacterAware == true;
62+
var inputUnits = isSc ? SupplementaryCharacters.CodepointCount(s) : s.Length;
5763

5864
// Invalid argument cases all map to NULL silently — matches SQL
5965
// Server's documented and probed behavior.
60-
if (startIndex < 1 || startIndex > s.Length || len < 0)
66+
if (startIndex < 1 || startIndex > inputUnits || len < 0)
6167
return SqlValue.Null(resultType);
6268

63-
var deleteCount = Math.Min(len, s.Length - (startIndex - 1));
69+
var deleteCount = Math.Min(len, inputUnits - (startIndex - 1));
6470
var insertText = replacementValue.IsNull ? string.Empty : replacementValue.AsString;
65-
var result = string.Concat(s.AsSpan(0, startIndex - 1), insertText, s.AsSpan(startIndex - 1 + deleteCount));
71+
var sliceStartCu = isSc ? SupplementaryCharacters.CodepointToCodeUnit(s, startIndex - 1) : startIndex - 1;
72+
var sliceEndCu = isSc ? SupplementaryCharacters.CodepointToCodeUnit(s, startIndex - 1 + deleteCount) : startIndex - 1 + deleteCount;
73+
var result = string.Concat(s.AsSpan(0, sliceStartCu), insertText, s.AsSpan(sliceEndCu));
6674
return SqlValue.FromString(resultType, result);
6775
}
6876

0 commit comments

Comments
 (0)