diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/GlobalizationTest/DirectionalityTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/GlobalizationTest/DirectionalityTest.cs new file mode 100644 index 0000000000..f18cc94d06 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/GlobalizationTest/DirectionalityTest.cs @@ -0,0 +1,251 @@ +// 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; + +/// +/// Verifies that SqlClient preserves the logical UTF-16 representation of bidirectional text. +/// Visual direction, shaping, and mirroring are responsibilities of the consuming UI. +/// +[Trait("Set", "3")] +public sealed class DirectionalityTest +{ + private static readonly string[] s_bidiText = + { + "\u0645\u0631\u062D\u0628\u0627 Microsoft 01 - \u0639\u0627\u0644\u0645 \U0001F310", + "\u05E9\u05DC\u05D5\u05DD Microsoft 01 - \u05E2\u05D5\u05DC\u05DD \U0001F310" + }; + + /// + /// Ensures mixed Arabic/Hebrew, Latin, numeric, punctuation, and supplementary characters + /// round-trip unchanged through parameters, readers, sequential streaming, and bulk copy. + /// + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [InlineData(false)] + [InlineData(true)] + public async Task BidiText_RoundTripsWithoutTransformation(bool async) + { + using SqlConnection setupConnection = new(DataTestUtility.TCPConnectionString); + await OpenConnection(setupConnection, async); + + using Table sourceTable = new( + setupConnection, + "DirectionalitySource", + "(Id int NOT NULL, Value nvarchar(max) NOT NULL)"); + using Table destinationTable = new( + setupConnection, + "DirectionalityDestination", + "(Id int NOT NULL, Value nvarchar(max) NOT NULL)"); + + await InsertValues(setupConnection, sourceTable.Name, async); + await VerifyOrdinaryReader(setupConnection, sourceTable.Name, async); + await VerifyGetChars(setupConnection, sourceTable.Name, async); + await VerifyTextReader(setupConnection, sourceTable.Name, async); + await CopyValues(sourceTable.Name, destinationTable.Name, async); + await VerifyOrdinaryReader(setupConnection, destinationTable.Name, async); + } + + /// + /// Opens a connection through the requested synchronous or asynchronous API. + /// + /// The connection to open. + /// Whether to use the asynchronous API. + /// A task representing the open operation. + private static async Task OpenConnection(SqlConnection connection, bool async) + { + if (async) + { + await connection.OpenAsync(); + } + else + { + connection.Open(); + } + } + + /// + /// Inserts the bidi samples through explicitly typed Unicode parameters. + /// + /// The open connection used to insert the samples. + /// The table that receives the samples. + /// Whether to use asynchronous command execution. + /// A task representing the insert operations. + private static async Task InsertValues(SqlConnection connection, string tableName, bool async) + { + using SqlCommand command = new($"INSERT INTO {tableName} (Id, Value) VALUES (@id, @value)", connection); + SqlParameter idParameter = command.Parameters.Add("@id", SqlDbType.Int); + SqlParameter valueParameter = command.Parameters.Add("@value", SqlDbType.NVarChar, -1); + + for (int index = 0; index < s_bidiText.Length; index++) + { + idParameter.Value = index; + valueParameter.Value = s_bidiText[index]; + + if (async) + { + await command.ExecuteNonQueryAsync(); + } + else + { + command.ExecuteNonQuery(); + } + } + } + + /// + /// Reads complete strings through ordinary reader accessors and compares their UTF-16 content. + /// + /// The open connection used to read the samples. + /// The table containing the samples. + /// Whether to use asynchronous reader APIs. + /// A task representing the verification operation. + private static async Task VerifyOrdinaryReader(SqlConnection connection, string tableName, bool async) + { + using SqlCommand command = new($"SELECT Id, Value FROM {tableName} ORDER BY Id", connection); + using SqlDataReader reader = async + ? await command.ExecuteReaderAsync() + : command.ExecuteReader(); + + for (int index = 0; index < s_bidiText.Length; index++) + { + bool hasRow = async ? await reader.ReadAsync() : reader.Read(); + Assert.True(hasRow); + Assert.Equal(index, reader.GetInt32(0)); + AssertOrdinalEqual(s_bidiText[index], reader.GetString(1)); + string fieldValue = async + ? await reader.GetFieldValueAsync(1) + : reader.GetFieldValue(1); + AssertOrdinalEqual(s_bidiText[index], fieldValue); + } + + Assert.False(async ? await reader.ReadAsync() : reader.Read()); + } + + /// + /// Reads one UTF-16 code unit at a time to cover direction and surrogate boundaries in GetChars. + /// + /// The open connection used to read the samples. + /// The table containing the samples. + /// Whether to use asynchronous reader execution. + /// A task representing the verification operation. + private static async Task VerifyGetChars(SqlConnection connection, string tableName, bool async) + { + using SqlCommand command = new($"SELECT Value FROM {tableName} ORDER BY Id", connection); + using SqlDataReader reader = async + ? await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess) + : command.ExecuteReader(CommandBehavior.SequentialAccess); + + for (int index = 0; index < s_bidiText.Length; index++) + { + bool hasRow = async ? await reader.ReadAsync() : reader.Read(); + Assert.True(hasRow); + + StringBuilder result = new(); + char[] buffer = new char[1]; + long dataIndex = 0; + long charsRead; + do + { + charsRead = reader.GetChars(0, dataIndex, buffer, 0, buffer.Length); + result.Append(buffer, 0, (int)charsRead); + dataIndex += charsRead; + } + while (charsRead != 0); + + AssertOrdinalEqual(s_bidiText[index], result.ToString()); + } + } + + /// + /// Reads bidi text through the sequential TextReader using small sync or async buffer operations. + /// + /// The open connection used to read the samples. + /// The table containing the samples. + /// Whether to use asynchronous reader and text operations. + /// A task representing the verification operation. + private static async Task VerifyTextReader(SqlConnection connection, string tableName, bool async) + { + using SqlCommand command = new($"SELECT Value FROM {tableName} ORDER BY Id", connection); + using SqlDataReader reader = async + ? await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess) + : command.ExecuteReader(CommandBehavior.SequentialAccess); + + for (int index = 0; index < s_bidiText.Length; index++) + { + bool hasRow = async ? await reader.ReadAsync() : reader.Read(); + Assert.True(hasRow); + + using TextReader textReader = reader.GetTextReader(0); + StringBuilder result = new(); + char[] buffer = new char[2]; + int charsRead; + do + { + charsRead = async + ? await textReader.ReadAsync(buffer, 0, buffer.Length) + : textReader.Read(buffer, 0, buffer.Length); + result.Append(buffer, 0, charsRead); + } + while (charsRead != 0); + + AssertOrdinalEqual(s_bidiText[index], result.ToString()); + } + } + + /// + /// Copies the Unicode rows through streaming SqlBulkCopy using the requested execution mode. + /// + /// The table containing the source rows. + /// The table receiving the copied rows. + /// Whether to use asynchronous reader and bulk-copy APIs. + /// A task representing the copy operation. + private static async Task CopyValues(string sourceTableName, string destinationTableName, bool async) + { + using SqlConnection sourceConnection = new(DataTestUtility.TCPConnectionString); + using SqlConnection destinationConnection = new(DataTestUtility.TCPConnectionString); + await OpenConnection(sourceConnection, async); + await OpenConnection(destinationConnection, async); + + using SqlCommand command = new($"SELECT Id, Value FROM {sourceTableName} ORDER BY Id", sourceConnection); + using SqlDataReader reader = async + ? await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess) + : command.ExecuteReader(CommandBehavior.SequentialAccess); + using SqlBulkCopy bulkCopy = new(destinationConnection) + { + DestinationTableName = destinationTableName, + EnableStreaming = true + }; + bulkCopy.ColumnMappings.Add(0, 0); + bulkCopy.ColumnMappings.Add(1, 1); + + if (async) + { + await bulkCopy.WriteToServerAsync(reader); + } + else + { + bulkCopy.WriteToServer(reader); + } + } + + /// + /// Compares strings ordinally so the assertion checks logical storage rather than visual rendering. + /// + /// The original UTF-16 string. + /// The round-tripped UTF-16 string. + private static void AssertOrdinalEqual(string expected, string actual) + { + Assert.True( + string.Equals(expected, actual, StringComparison.Ordinal), + $"Expected and actual UTF-16 values differ. Expected length: {expected.Length}; actual length: {actual?.Length}."); + } +}