diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/BulkCopy/TestBulkCopyWithUTF8.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/BulkCopy/TestBulkCopyWithUTF8.cs index 21cf670ae7..daa63c7d03 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/BulkCopy/TestBulkCopyWithUTF8.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/BulkCopy/TestBulkCopyWithUTF8.cs @@ -4,6 +4,7 @@ using System; using System.Data; +using System.Text; using System.Threading.Tasks; using Microsoft.Data.SqlClient.ManualTesting.Tests; using Xunit; @@ -17,11 +18,10 @@ namespace Microsoft.Data.SqlClient.ManualTests.BulkCopy [Trait("Set", "2")] public sealed class TestBulkCopyWithUtf8 : IDisposable { - private static string s_sourceTable = DataTestUtility.GetShortName("SourceTableForUTF8Data"); - private static string s_destinationTable = DataTestUtility.GetShortName("DestinationTableForUTF8Data"); - private static string s_testValue = "test"; - private static byte[] s_testValueInUtf8Bytes = new byte[] { 0x74, 0x65, 0x73, 0x74 }; - private static readonly string s_insertQuery = $"INSERT INTO {s_sourceTable} VALUES('{s_testValue}')"; + private static readonly string s_sourceTable = DataTestUtility.GetShortName("SourceTableForUTF8Data"); + private static readonly string s_destinationTable = DataTestUtility.GetShortName("DestinationTableForUTF8Data"); + private static readonly string s_testValue = GlobalizationTestData.CreatePacketSpanningText(); + private static readonly byte[] s_testValueInUtf8Bytes = Encoding.UTF8.GetBytes(s_testValue); /// /// Constructor: Initializes and populates source and destination tables required for the tests. @@ -37,7 +37,7 @@ public TestBulkCopyWithUtf8() using SqlConnection sourceConnection = new SqlConnection(GetConnectionString(true)); sourceConnection.Open(); - SetupTables(sourceConnection, s_sourceTable, s_destinationTable, s_insertQuery); + SetupTables(sourceConnection, s_sourceTable, s_destinationTable); } /// @@ -61,11 +61,14 @@ public void Dispose() /// /// Builds a connection string with or without Multiple Active Result Sets (MARS) property. /// + /// Whether Multiple Active Result Sets is enabled. + /// A connection string configured with a small packet size for boundary coverage. private string GetConnectionString(bool enableMars) { return new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString) { - MultipleActiveResultSets = enableMars + MultipleActiveResultSets = enableMars, + PacketSize = 512 }.ConnectionString; } @@ -73,14 +76,18 @@ private string GetConnectionString(bool enableMars) /// Creates source and destination tables with a varchar(max) column with a collation setting /// that stores the data in UTF8 encoding and inserts the data in the source table. /// - private void SetupTables(SqlConnection connection, string sourceTable, string destinationTable, string insertQuery) + /// The open connection used to create and populate the tables. + /// The source table name. + /// The destination table name. + private void SetupTables(SqlConnection connection, string sourceTable, string destinationTable) { string columnDefinition = "(str_col varchar(max) COLLATE Latin1_General_100_CS_AS_KS_WS_SC_UTF8)"; DataTestUtility.CreateTable(connection, sourceTable, columnDefinition); DataTestUtility.CreateTable(connection, destinationTable, columnDefinition); using SqlCommand insertCommand = connection.CreateCommand(); - insertCommand.CommandText = insertQuery; - Helpers.TryExecute(insertCommand, insertQuery); + insertCommand.CommandText = $"INSERT INTO {sourceTable} VALUES(@value)"; + insertCommand.Parameters.Add(new SqlParameter("@value", SqlDbType.NVarChar, -1) { Value = s_testValue }); + insertCommand.ExecuteNonQuery(); } /// @@ -105,6 +112,11 @@ public void BulkCopy_Utf8Data_ShouldMatchSource(bool isMarsEnabled, bool enableS using SqlConnection destinationConnection = new SqlConnection(connectionString); destinationConnection.Open(); + using (SqlCommand sourceVerifyCommand = new SqlCommand($"SELECT CAST(str_col AS varbinary(max)) FROM {s_sourceTable}", sourceConnection)) + { + Assert.Equal(s_testValueInUtf8Bytes, sourceVerifyCommand.ExecuteScalar()); + } + // Read data from source table using SqlCommand sourceDataCommand = new SqlCommand($"SELECT str_col FROM {s_sourceTable}", sourceConnection); using SqlDataReader reader = sourceDataCommand.ExecuteReader(CommandBehavior.SequentialAccess); @@ -135,7 +147,7 @@ public void BulkCopy_Utf8Data_ShouldMatchSource(bool isMarsEnabled, bool enableS Assert.Equal(1, Convert.ToInt16(countCommand.ExecuteScalar())); // Read the data from destination table as varbinary to verify the UTF-8 byte sequence - using SqlCommand verifyCommand = new SqlCommand($"SELECT cast(str_col as varbinary) FROM {s_destinationTable}", destinationConnection); + using SqlCommand verifyCommand = new SqlCommand($"SELECT CAST(str_col AS varbinary(max)) FROM {s_destinationTable}", destinationConnection); using SqlDataReader verifyReader = verifyCommand.ExecuteReader(CommandBehavior.SequentialAccess); // Verify that we have data in the destination table @@ -170,6 +182,11 @@ public async Task BulkCopy_Utf8Data_ShouldMatchSource_Async(bool isMarsEnabled, using SqlConnection destinationConnection = new SqlConnection(connectionString); await destinationConnection.OpenAsync(); + using (SqlCommand sourceVerifyCommand = new SqlCommand($"SELECT CAST(str_col AS varbinary(max)) FROM {s_sourceTable}", sourceConnection)) + { + Assert.Equal(s_testValueInUtf8Bytes, await sourceVerifyCommand.ExecuteScalarAsync()); + } + // Read data from source table using SqlCommand sourceDataCommand = new SqlCommand($"SELECT str_col FROM {s_sourceTable}", sourceConnection); using SqlDataReader reader = await sourceDataCommand.ExecuteReaderAsync(CommandBehavior.SequentialAccess); @@ -200,7 +217,7 @@ public async Task BulkCopy_Utf8Data_ShouldMatchSource_Async(bool isMarsEnabled, Assert.Equal(1, Convert.ToInt16(await countCommand.ExecuteScalarAsync())); // Read the data from destination table as varbinary to verify the UTF-8 byte sequence - using SqlCommand verifyCommand = new SqlCommand($"SELECT cast(str_col as varbinary) FROM {s_destinationTable}", destinationConnection); + using SqlCommand verifyCommand = new SqlCommand($"SELECT CAST(str_col AS varbinary(max)) FROM {s_destinationTable}", destinationConnection); using SqlDataReader verifyReader = await verifyCommand.ExecuteReaderAsync(CommandBehavior.SequentialAccess); // Verify that we have data in the destination table diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/GlobalizationTest/GlobalizationEncodingTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/GlobalizationTest/GlobalizationEncodingTests.cs new file mode 100644 index 0000000000..f2ba885b1f --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/GlobalizationTest/GlobalizationEncodingTests.cs @@ -0,0 +1,217 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Data; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + /// + /// Provides representative worldwide text for encoding validation tests. + /// + internal static class GlobalizationTestData + { + internal const string RepresentativeText = + "Latin: Caf\u00E9 / Cafe\u0301 | " + + "Arabic: \u0627\u0644\u0639\u064E\u0631\u064E\u0628\u0650\u064A\u064E\u0651\u0629 | " + + "Devanagari: \u0939\u093F\u0928\u094D\u0926\u0940 | " + + "Thai: \u0E20\u0E32\u0E29\u0E32\u0E44\u0E17\u0E22 | " + + "CJK: \u65E5\u672C\u8A9E \u4E2D\u6587 \uD55C\uAD6D\uC5B4 | " + + "Supplementary: \uD83D\uDE00 \uD834\uDD1E | " + + "Emoji sequence: \uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDCBB"; + + /// + /// Repeats the representative text so reads span TDS packets and internal character buffers. + /// + internal static string CreatePacketSpanningText() + { + StringBuilder value = new(); + for (int i = 0; i < 16; i++) + { + value.Append(RepresentativeText); + } + + return value.ToString(); + } + } + + /// + /// Validates exact worldwide text preservation through parameters, SQL Server storage, and reader APIs. + /// + [Trait("Set", "3")] + public static class GlobalizationEncodingTests + { + /// + /// Verifies normal and streamed Unicode parameters round-trip exactly through buffered and sequential + /// readers on synchronous and asynchronous paths. + /// + /// Whether command and reader operations use asynchronous APIs. + /// Whether the input parameter is supplied through a . + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public static async Task UnicodeParameterAndReaderRoundTrip_PreservesWorldwideText(bool useAsync, bool streamInput) + { + string expected = GlobalizationTestData.CreatePacketSpanningText(); + SqlConnectionStringBuilder connectionString = new(DataTestUtility.TCPConnectionString) + { + PacketSize = 512 + }; + + using SqlConnection connection = new(connectionString.ConnectionString); + if (useAsync) + { + await connection.OpenAsync(); + } + else + { + connection.Open(); + } + + using Table table = new(connection, nameof(UnicodeParameterAndReaderRoundTrip_PreservesWorldwideText), "(Value nvarchar(max) NOT NULL)"); + using StringReader inputReader = new(expected); + using (SqlCommand insert = new($"INSERT INTO {table.Name} (Value) VALUES (@value)", connection)) + { + insert.Parameters.Add(new SqlParameter("@value", SqlDbType.NVarChar, -1) + { + Value = streamInput ? inputReader : expected + }); + + if (useAsync) + { + await insert.ExecuteNonQueryAsync(); + } + else + { + insert.ExecuteNonQuery(); + } + } + + string query = $"SELECT Value FROM {table.Name}"; + string directValue = await ReadDirectValue(connection, query, useAsync); + string streamedValue = await ReadStreamedValue(connection, query, useAsync); + + Assert.Equal(expected, directValue); + Assert.Equal(expected, streamedValue); + Assert.Equal(expected.Length, directValue.Length); + Assert.Equal(expected.Length, streamedValue.Length); + } + + /// + /// Verifies a UTF-8-collated varchar value returns exact worldwide text and the expected UTF-8 bytes + /// on synchronous and asynchronous paths. + /// + /// Whether command and reader operations use asynchronous APIs. + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsUTF8Supported))] + [InlineData(false)] + [InlineData(true)] + public static async Task Utf8VarcharRoundTrip_PreservesWorldwideTextAndBytes(bool useAsync) + { + string expected = GlobalizationTestData.CreatePacketSpanningText(); + SqlConnectionStringBuilder connectionString = new(DataTestUtility.TCPConnectionString) + { + PacketSize = 512 + }; + using SqlConnection connection = new(connectionString.ConnectionString); + if (useAsync) + { + await connection.OpenAsync(); + } + else + { + connection.Open(); + } + + using Table table = new( + connection, + nameof(Utf8VarcharRoundTrip_PreservesWorldwideTextAndBytes), + "(Value varchar(max) COLLATE Latin1_General_100_CS_AS_KS_WS_SC_UTF8 NOT NULL)"); + using (SqlCommand insert = new($"INSERT INTO {table.Name} (Value) VALUES (@value)", connection)) + { + insert.Parameters.Add(new SqlParameter("@value", SqlDbType.NVarChar, -1) { Value = expected }); + if (useAsync) + { + await insert.ExecuteNonQueryAsync(); + } + else + { + insert.ExecuteNonQuery(); + } + } + + using SqlCommand select = new($"SELECT Value, CONVERT(varbinary(max), Value) FROM {table.Name}", connection); + using SqlDataReader reader = useAsync + ? await select.ExecuteReaderAsync(CommandBehavior.SequentialAccess) + : select.ExecuteReader(CommandBehavior.SequentialAccess); + bool hasRow = useAsync ? await reader.ReadAsync() : reader.Read(); + + Assert.True(hasRow); + Assert.Equal(expected, reader.GetString(0)); + Assert.Equal(Encoding.UTF8.GetBytes(expected), reader.GetFieldValue(1)); + Assert.False(useAsync ? await reader.ReadAsync() : reader.Read()); + } + + /// + /// Reads a string through the standard buffered reader path. + /// + /// The open SQL connection used to execute the query. + /// The query that returns one string value. + /// Whether command and reader operations use asynchronous APIs. + /// The string returned by SQL Server. + private static async Task ReadDirectValue(SqlConnection connection, string query, bool useAsync) + { + using SqlCommand command = new(query, connection); + using SqlDataReader reader = useAsync + ? await command.ExecuteReaderAsync() + : command.ExecuteReader(); + bool hasRow = useAsync ? await reader.ReadAsync() : reader.Read(); + + Assert.True(hasRow); + string result = reader.GetString(0); + Assert.False(useAsync ? await reader.ReadAsync() : reader.Read()); + return result; + } + + /// + /// Reads a string through sequential calls with a small buffer so character + /// sequences cross read boundaries. + /// + /// The open SQL connection used to execute the query. + /// The query that returns one string value. + /// Whether command and reader operations use asynchronous APIs. + /// The string returned by SQL Server. + private static async Task ReadStreamedValue(SqlConnection connection, string query, bool useAsync) + { + using SqlCommand command = new(query, connection); + using SqlDataReader reader = useAsync + ? await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess) + : command.ExecuteReader(CommandBehavior.SequentialAccess); + bool hasRow = useAsync ? await reader.ReadAsync() : reader.Read(); + + Assert.True(hasRow); + using TextReader textReader = reader.GetTextReader(0); + char[] buffer = new char[3]; + StringBuilder result = new(); + int charsRead; + do + { + charsRead = useAsync + ? await textReader.ReadAsync(buffer, 0, buffer.Length) + : textReader.Read(buffer, 0, buffer.Length); + result.Append(buffer, 0, charsRead); + } + while (charsRead != 0); + + Assert.False(useAsync ? await reader.ReadAsync() : reader.Read()); + return result.ToString(); + } + } +}