diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs index 7757927203..3d494dfc57 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs @@ -2248,7 +2248,7 @@ private string BuildParamList(TdsParser parser, SqlParameterCollection parameter // Func will change type to that with a 4 byte length if the type has a two // byte length and a parameter length > that expressible in 2 bytes. // @TODO: what func? - mt = sqlParam.ValidateTypeLengths(); + mt = sqlParam.ValidateTypeLengths(parser); if (!mt.IsPlp && sqlParam.Direction is not ParameterDirection.Output) { sqlParam.FixStreamDataForNonPLP(); @@ -2805,7 +2805,7 @@ private void SetUpRPCParameters(_SqlRPC rpc, bool inSchema, SqlParameterCollecti // Func will change type to that with a 4 byte length if the type has a 2 byte // length and a parameter length > than that expressible in 2 bytes. - if (!parameter.ValidateTypeLengths().IsPlp && parameter.Direction is not ParameterDirection.Output) + if (!parameter.ValidateTypeLengths(_stateObj.Parser).IsPlp && parameter.Direction is not ParameterDirection.Output) { parameter.FixStreamDataForNonPLP(); } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs index b0fc3fd911..925f94c571 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs @@ -2091,7 +2091,7 @@ internal void Validate(int index, bool isCommandProc) // func will change type to that with a 4 byte length if the type has a two // byte length and a parameter length > than that expressible in 2 bytes - internal MetaType ValidateTypeLengths() + internal MetaType ValidateTypeLengths(TdsParser parser = null) { MetaType mt = InternalMetaType; // Since the server will automatically reject any @@ -2104,6 +2104,20 @@ internal MetaType ValidateTypeLengths() long actualSizeInBytes = GetActualSize(); long sizeInCharacters = Size; + // GetActualSize returns characters for ANSI strings. Decide whether to use + // MAX before building the SQL declaration or writing the RPC metadata, using + // the same connection encoding and character slice as the serializer. + if (parser != null && mt.IsAnsiType && !IsNull && + actualSizeInBytes <= TdsEnums.TYPE_SIZE_LIMIT && + sizeInCharacters <= TdsEnums.TYPE_SIZE_LIMIT && sizeInCharacters != -1 && + !HasFlag(SqlParameterFlags.CoercedValueIsDataFeed) && Direction != ParameterDirection.Output) + { + object value = GetCoercedValue(); + string text = value is SqlString sqlString ? sqlString.Value : + value is SqlChars sqlChars ? new string(sqlChars.Value) : (string)value; + actualSizeInBytes = parser.GetEncodingCharLength(text, (int)actualSizeInBytes, Offset, null); + } + // Bug: VSTFDevDiv #636867 // Notes: // 'actualSizeInBytes' is the size of value passed; diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs new file mode 100644 index 0000000000..4a4b94f1e0 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs @@ -0,0 +1,69 @@ +// 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.Threading.Tasks; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + /// + /// Verifies that UTF-8 RPC values crossing 8000 bytes round-trip without malformed metadata or data loss. + /// + [Trait("Set", "3")] + public static class Utf8ParameterTests + { + /// + /// Covers text, stored-procedure and prepared RPCs with both synchronous and asynchronous execution. + /// + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsUTF8Supported), nameof(DataTestUtility.IsNotAzureServer), nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 2)] + [InlineData(true, 2)] + public static async Task VarCharUtf8ByteBoundaryRoundTrip(bool async, int mode) + { + string database = DataTestUtility.GetLongName("Utf8Parameter", false); + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) { InitialCatalog = "master" }; + using SqlConnection admin = DataTestUtility.CreateConnection(builder.ConnectionString); + admin.Open(); + using (SqlCommand create = admin.CreateCommand()) + { + create.CommandText = $"CREATE DATABASE [{database}] COLLATE Latin1_General_100_CI_AS_SC_UTF8"; + create.ExecuteNonQuery(); + } + try + { + builder.InitialCatalog = database; + using SqlConnection connection = DataTestUtility.CreateConnection(builder.ConnectionString); + if (async) await connection.OpenAsync(); + else connection.Open(); + using StoredProcedure procedure = new(connection, "Utf8Echo", "@p varchar(max) AS SELECT @p"); + using SqlCommand command = connection.CreateCommand(); + command.CommandText = mode == 1 ? procedure.Name : "SELECT @p"; + command.CommandType = mode == 1 ? CommandType.StoredProcedure : CommandType.Text; + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, 8000); + parameter.Value = new string('é', 4001); + if (mode == 2) command.Prepare(); + + // Reuse the command across the byte boundary and back, including an existing prepared handle. + foreach (int count in new[] { 4001, 4000, 4001 }) + { + string text = new('é', count); + parameter.Value = text; + object result = async ? await command.ExecuteScalarAsync() : command.ExecuteScalar(); + Assert.Equal(text, result); + } + } + finally + { + DataTestUtility.DropDatabase(admin, database); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs new file mode 100644 index 0000000000..1ad170d3b8 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs @@ -0,0 +1,114 @@ +// 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.Data.SqlTypes; +using System.Reflection; +using System.Text; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests +{ + /// + /// Guards ANSI parameter declarations against exceeding TDS's byte limit under multibyte encodings. + /// + public class SqlCommandAnsiParameterTests + { + /// + /// Exercises the declaration path used by sp_executesql and prepared commands at UTF-8 boundaries. + /// + [Theory] + [InlineData("é", 4000, 8000, false, false)] + [InlineData("é", 4001, 8000, false, true)] + [InlineData("é", 4001, 0, false, true)] + [InlineData("€", 2666, 8000, false, false)] + [InlineData("€", 2667, 8000, false, true)] + [InlineData("😀", 2001, 8000, false, true)] + [InlineData("a", 8000, 8000, false, false)] + [InlineData("é", 4001, 4000, false, false)] + [InlineData("é", 4001, -1, false, true)] + [InlineData("é", 4001, 8000, true, true)] + public void BuildParamList_UsesEncodedByteCount(string character, int count, int size, bool sqlString, bool expectMax) + { + string text = string.Concat(System.Linq.Enumerable.Repeat(character, count)); + SqlParameter parameter = new("@p", SqlDbType.VarChar, size) + { + Value = sqlString ? new SqlString(text) : (object)text + }; + string declaration = BuildDeclaration(parameter, Encoding.UTF8); + + Assert.Equal(expectMax, parameter.InternalMetaType.IsPlp); + if (expectMax) + { + Assert.Contains("varchar(max)", declaration); + } + else + { + Assert.DoesNotContain("(max)", declaration); + int encodedBytes = Encoding.UTF8.GetByteCount(text.Substring(0, parameter.GetActualSize())); + Assert.Contains($"varchar({Math.Max(size, encodedBytes)})", declaration); + } + // GetActualSize's ANSI character-count contract must remain intact for serialization. + Assert.Equal(size > 0 ? Math.Min(size, text.Length) : text.Length, parameter.GetActualSize()); + } + + /// + /// Promotion uses the connection encoding rather than assuming all ANSI values are UTF-8. + /// + [Fact] + public void BuildParamList_SingleByteEncoding_DoesNotPromote() + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, 8000) { Value = new string('é', 4001) }; + Assert.Contains("varchar(8000)", BuildDeclaration(parameter, Encoding.GetEncoding(28591))); + Assert.False(parameter.InternalMetaType.IsPlp); + } + + /// + /// Truncation and offset select which characters determine the encoded byte count. + /// + [Fact] + public void BuildParamList_Offset_UsesTransmittedSlice() + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, 4001) + { + Value = new string('a', 4001) + new string('é', 4001), + Offset = 4001 + }; + Assert.Contains("varchar(max)", BuildDeclaration(parameter, Encoding.UTF8)); + } + + /// + /// Null parameters must not require conversion to a string for the byte-count check. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void BuildParamList_NullValue_DoesNotPromote(bool sqlNull) + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, 8000) + { + Value = sqlNull ? SqlString.Null : DBNull.Value + }; + Assert.Contains("varchar(8000)", BuildDeclaration(parameter, Encoding.UTF8)); + Assert.False(parameter.InternalMetaType.IsPlp); + } + + /// + /// Calls the real SQL declaration builder with a connection's negotiated encoding, without a server. + /// + /// The parameter to validate and declare. + /// The encoding negotiated by the connection. + /// The SQL declaration emitted for the parameter. + private static string BuildDeclaration(SqlParameter parameter, Encoding encoding) + { + TdsParser parser = new(false, false); + typeof(TdsParser).GetField("_defaultEncoding", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, encoding); + using SqlCommand command = new(); + command.Parameters.Add(parameter); + return (string)typeof(SqlCommand).GetMethod("BuildParamList", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(command, new object[] { parser, command.Parameters, false })!; + } + } +}