Skip to content

Commit f164c32

Browse files
committed
Improved fidelity of UTF8 collations.
1 parent 6418af1 commit f164c32

7 files changed

Lines changed: 476 additions & 39 deletions

File tree

SqlServerSimulator.Tests/CollationDeclaredColumnTests.cs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,5 +740,244 @@ insert t values (N'ア'), (N'あ'), (N'ア')
740740
AreEqual(3, sim.ExecuteScalar("select count(*) from t where s = N'ア'"));
741741
}
742742

743+
/// <summary>
744+
/// <c>varchar Latin1_General_100_BIN2_UTF8</c> sorts by UTF-8 byte order
745+
/// — which equals Unicode codepoint order. So {Z (U+005A), NBSP (U+00A0),
746+
/// ƒ (U+0192), € (U+20AC)} sorts in that codepoint order. Diverges from
747+
/// the CP1252-byte order of plain <c>varchar BIN2</c>, where € (CP1252
748+
/// 0x80) sorts BEFORE NBSP (CP1252 0xA0). Probe-confirmed against
749+
/// SQL Server 2025.
750+
/// </summary>
751+
[TestMethod]
752+
public void OrderBy_VarcharBin2Utf8_UsesCodepointOrder()
753+
{
754+
var sim = new Simulation();
755+
_ = sim.ExecuteNonQuery("""
756+
create table t (id int identity primary key, s varchar(10) collate Latin1_General_100_BIN2_UTF8);
757+
insert t (s) values (N'€'), (nchar(160)), (N'ƒ'), ('Z')
758+
""");
759+
using var reader = sim.CreateCommand("select id from t order by s").ExecuteReader();
760+
var ids = new List<int>();
761+
while (reader.Read())
762+
ids.Add(reader.GetInt32(0));
763+
// Inserted as €(1), NBSP(2), ƒ(3), Z(4); codepoint order is Z, NBSP, ƒ, €.
764+
CollectionAssert.AreEqual(new[] { 4, 2, 3, 1 }, ids);
765+
}
766+
767+
/// <summary>
768+
/// Contrast to <see cref="OrderBy_VarcharBin2Utf8_UsesCodepointOrder"/>:
769+
/// the same data on a plain <c>varchar BIN2</c> column sorts in CP1252
770+
/// byte order — Z, €, ƒ, NBSP — because under CP1252 storage € maps to
771+
/// byte 0x80 (before NBSP's 0xA0). UTF-8 storage flips €'s first byte
772+
/// to 0xE2 (after NBSP's 0xC2), inverting the relationship.
773+
/// </summary>
774+
[TestMethod]
775+
public void OrderBy_VarcharBin2_VsBin2Utf8_DifferAtCp1252HoleWindow()
776+
{
777+
var sim = new Simulation();
778+
_ = sim.ExecuteNonQuery("""
779+
create table t (id int identity primary key, s varchar(10) collate Latin1_General_BIN2);
780+
insert t (s) values (N'€'), (nchar(160)), (N'ƒ'), ('Z')
781+
""");
782+
using var reader = sim.CreateCommand("select id from t order by s").ExecuteReader();
783+
var ids = new List<int>();
784+
while (reader.Read())
785+
ids.Add(reader.GetInt32(0));
786+
// Inserted as €(1), NBSP(2), ƒ(3), Z(4); CP1252 byte order is Z(0x5A), €(0x80), ƒ(0x83), NBSP(0xA0).
787+
CollectionAssert.AreEqual(new[] { 4, 1, 3, 2 }, ids);
788+
}
789+
790+
/// <summary>
791+
/// <c>nvarchar Latin1_General_100_BIN2_UTF8</c> is a no-op on the UTF-8
792+
/// suffix — nvarchar storage stays UTF-16, and the sort body is
793+
/// <see cref="StringComparer.Ordinal"/> (UTF-16 code-unit), identical to
794+
/// plain <c>nvarchar BIN2</c>. Demonstrates that the codepoint-order
795+
/// dispatch happens only when the collation is pinned on a varchar /
796+
/// char column.
797+
/// </summary>
798+
[TestMethod]
799+
public void OrderBy_NvarcharBin2Utf8_MatchesNvarcharBin2CodeUnitOrder()
800+
{
801+
var sim = new Simulation();
802+
_ = sim.ExecuteNonQuery("""
803+
create table t (id int identity primary key, s nvarchar(4) collate Latin1_General_100_BIN2_UTF8);
804+
insert t (s) values
805+
(N'Z' + nchar(55357) + nchar(56832)), -- 'Z' + emoji U+1F600 (high surrogate D83D)
806+
(N'Z' + nchar(57344)) -- 'Z' + U+E000
807+
""");
808+
using var reader = sim.CreateCommand("select id from t order by s").ExecuteReader();
809+
var ids = new List<int>();
810+
while (reader.Read())
811+
ids.Add(reader.GetInt32(0));
812+
// Code-unit order on nvarchar: 0xD83D < 0xE000, so emoji-row (id=1) sorts first.
813+
CollectionAssert.AreEqual(new[] { 1, 2 }, ids);
814+
}
815+
816+
/// <summary>
817+
/// <c>DATALENGTH</c> on a <c>varchar *_UTF8</c> column reflects UTF-8
818+
/// byte counts — `café` is 5 bytes (c=1, a=1, f=1, é=2), not 4
819+
/// characters. Probe-confirmed against SQL Server 2025.
820+
/// </summary>
821+
[TestMethod]
822+
public void Datalength_VarcharCiAsScUtf8_ReturnsUtf8ByteCount()
823+
{
824+
var sim = new Simulation();
825+
_ = sim.ExecuteNonQuery("""
826+
create table t (s varchar(20) collate Latin1_General_100_CI_AS_SC_UTF8);
827+
insert t values (N'café')
828+
""");
829+
AreEqual(5, sim.ExecuteScalar("select datalength(s) from t"));
830+
}
831+
832+
/// <summary>
833+
/// Same data through the BIN2_UTF8 sibling — UTF-8 storage is shared
834+
/// across all three <c>*_UTF8</c> collations, so the byte count
835+
/// matches. Distinct test catches a regression where only one of the
836+
/// three UTF-8 collations was wired correctly.
837+
/// </summary>
838+
[TestMethod]
839+
public void Datalength_VarcharBin2Utf8_ReturnsUtf8ByteCount()
840+
{
841+
var sim = new Simulation();
842+
_ = sim.ExecuteNonQuery("""
843+
create table t (s varchar(20) collate Latin1_General_100_BIN2_UTF8);
844+
insert t values (N'café')
845+
""");
846+
AreEqual(5, sim.ExecuteScalar("select datalength(s) from t"));
847+
}
848+
849+
/// <summary>
850+
/// Contrast: the same `café` literal on a default-collation
851+
/// <c>varchar</c> column stores 4 CP1252 bytes (é → 0xE9). Pins down
852+
/// the per-collation storage dispatch — the same .NET string produces
853+
/// different byte widths depending on the column's collation.
854+
/// </summary>
855+
[TestMethod]
856+
public void Datalength_VarcharDefault_ReturnsCp1252ByteCount()
857+
{
858+
var sim = new Simulation();
859+
_ = sim.ExecuteNonQuery("""
860+
create table t (s varchar(20));
861+
insert t values (N'café')
862+
""");
863+
AreEqual(4, sim.ExecuteScalar("select datalength(s) from t"));
864+
}
865+
866+
/// <summary>
867+
/// <c>nvarchar *_UTF8</c> still uses UTF-16 storage — the _UTF8 suffix
868+
/// is varchar-only at the storage layer. `café` on
869+
/// <c>nvarchar(20) BIN2_UTF8</c> is 8 bytes (4 chars × 2 UTF-16 code
870+
/// units). Lock-in test for the "nvarchar ignores StorageEncoding" rule.
871+
/// </summary>
872+
[TestMethod]
873+
public void Datalength_NvarcharBin2Utf8_StaysUtf16()
874+
{
875+
var sim = new Simulation();
876+
_ = sim.ExecuteNonQuery("""
877+
create table t (s nvarchar(20) collate Latin1_General_100_BIN2_UTF8);
878+
insert t values (N'café')
879+
""");
880+
AreEqual(8, sim.ExecuteScalar("select datalength(s) from t"));
881+
}
882+
883+
/// <summary>
884+
/// <c>char(N)</c> with a UTF-8 collation pads to N <em>bytes</em>, not
885+
/// N characters: real SQL Server stores `é` (2 UTF-8 bytes) in
886+
/// <c>char(5)</c> as `é` + 3 space bytes = 5 bytes total. Probe-
887+
/// confirmed against SQL Server 2025.
888+
/// </summary>
889+
[TestMethod]
890+
public void CharN_Utf8_PadsToNBytesNotChars()
891+
{
892+
var sim = new Simulation();
893+
_ = sim.ExecuteNonQuery("""
894+
create table t (s char(5) collate Latin1_General_100_CI_AS_SC_UTF8);
895+
insert t values (N'é')
896+
""");
897+
AreEqual(5, sim.ExecuteScalar("select datalength(s) from t"));
898+
}
899+
900+
/// <summary>
901+
/// <c>varchar(N)</c> under a UTF-8 collation budgets N <em>bytes</em>:
902+
/// `é` (2 UTF-8 bytes) fits in <c>varchar(2)</c> exactly; adding any
903+
/// further byte overflows. Locks in <c>EnforceMaxLength</c>'s use of
904+
/// the column collation's <c>StorageEncoding</c>.
905+
/// </summary>
906+
[TestMethod]
907+
public void VarcharN_Utf8_BudgetIsBytesNotChars()
908+
{
909+
var sim = new Simulation();
910+
_ = sim.ExecuteNonQuery("""
911+
create table t (s varchar(2) collate Latin1_General_100_CI_AS_SC_UTF8);
912+
insert t values (N'é')
913+
""");
914+
AreEqual(2, sim.ExecuteScalar("select datalength(s) from t"));
915+
// 'éA' is 2 UTF-8 bytes + 1 byte = 3 bytes; exceeds varchar(2).
916+
// Default session raises the verbose Msg 2628 form (with table/
917+
// column/value), not the legacy 8152.
918+
_ = sim.AssertSqlError("insert t values (N'éA')", 2628);
919+
}
920+
921+
/// <summary>
922+
/// Same value `é` on a default-collation <c>varchar(2)</c> stores 1
923+
/// CP1252 byte (0xE9), and there's room left for another ASCII byte:
924+
/// <c>'éA'</c> stores as 2 CP1252 bytes, fits exactly. Demonstrates
925+
/// the storage-encoding-driven budget difference between defaults and
926+
/// UTF-8 collations.
927+
/// </summary>
928+
[TestMethod]
929+
public void VarcharN_Default_BudgetIsCp1252Bytes()
930+
{
931+
var sim = new Simulation();
932+
_ = sim.ExecuteNonQuery("""
933+
create table t (s varchar(2));
934+
insert t values (N'é'), (N'éA')
935+
""");
936+
AreEqual(1, sim.ExecuteScalar("select datalength(s) from t where s = N'é'"));
937+
AreEqual(2, sim.ExecuteScalar("select datalength(s) from t where s = N'éA'"));
938+
}
939+
940+
/// <summary>
941+
/// DISTINCT hash on a <c>varchar BIN2_UTF8</c> column agrees with the
942+
/// UTF-8 byte-equality contract — duplicates collapse and codepoint-
943+
/// distinct values stay in separate buckets. Covers the GetHashCode
944+
/// path on <c>Utf8CodepointBinaryCollation</c>.
945+
/// </summary>
946+
[TestMethod]
947+
public void Distinct_VarcharBin2Utf8_HashRespectsUtf8Bytes()
948+
{
949+
var sim = new Simulation();
950+
_ = sim.ExecuteNonQuery("""
951+
create table t (s varchar(10) collate Latin1_General_100_BIN2_UTF8);
952+
insert t values (N'€'), (N'€'), (N'ƒ')
953+
""");
954+
AreEqual(2, sim.ExecuteScalar("select count(*) from (select distinct s from t) d"));
955+
}
956+
957+
/// <summary>
958+
/// <c>CAST(... AS char(N)) COLLATE …UTF8</c>: the postfix COLLATE swaps
959+
/// to a UTF-8 storage encoding after the CAST has already normalized
960+
/// the .NET string under the default CP1252 byte budget. Without re-
961+
/// normalization the encoder would overflow when the new UTF-8 byte
962+
/// count exceeds N. <c>CollateExpression.Run</c> re-routes char(N)
963+
/// values through <c>FromString</c> when the storage encoding changes,
964+
/// triggering <c>NormalizeFixedLengthStringToByteCount</c> under the
965+
/// new encoding. Four probe-confirmed shapes (SQL Server 2025,
966+
/// 2026-05-21).
967+
/// </summary>
968+
[TestMethod]
969+
[DataRow("N'é'", 1, "20", DisplayName = "N'é' -> char(1) UTF8 → 0x20 (é dropped, 1 space)")]
970+
[DataRow("N'éA'", 2, "C3A9", DisplayName = "N'éA' -> char(2) UTF8 → 0xC3A9 (é exact)")]
971+
[DataRow("N'Aé'", 2, "4120", DisplayName = "N'Aé' -> char(2) UTF8 → 0x4120 (A + space)")]
972+
[DataRow("N'AéB'", 3, "41C3A9", DisplayName = "N'AéB' -> char(3) UTF8 → 0x41C3A9 (Aé)")]
973+
public void CastAsCharN_WithPostfixCollateUtf8_MatchesByteBudget(string input, int n, string expectedHex)
974+
{
975+
var sim = new Simulation();
976+
var actualHex = Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(
977+
(string)sim.ExecuteScalar(
978+
$"select cast({input} as char({n})) collate Latin1_General_100_CI_AS_SC_UTF8")!));
979+
AreEqual(expectedHex, actualHex);
980+
}
981+
743982
private static void IsNull(object? value) => Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsNull(value is DBNull ? null : value);
744983
}

0 commit comments

Comments
 (0)