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 })!;
+ }
+ }
+}