@@ -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