From 671d5bdc92b29c1d31c2c988027c105e35730b71 Mon Sep 17 00:00:00 2001 From: Jean-Sebastien Carle <29762210+jscarle@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:23:50 -0400 Subject: [PATCH 1/5] Fix multibyte ANSI parameter sizing across RPC and encrypted metadata Use the negotiated connection encoding for ANSI parameter byte lengths in SQL declarations, RPC metadata, and Always Encrypted type_info. Bound ANSI payload sizes by the characters remaining after Offset and invalidate the cached size when the offset changes. Include the complete fix and its unit and SQL Server regression coverage directly on main. Adapt the tests to the parser namespace introduced by the current main branch. Fixes #2035 Co-authored-by: Jens Theisen --- .../Data/SqlClient/Parser/TdsParser.cs | 4 +- .../Microsoft/Data/SqlClient/SqlCommand.cs | 35 +-- .../Microsoft/Data/SqlClient/SqlParameter.cs | 53 +++- .../SQL/Utf8SupportTest/Utf8ParameterTests.cs | 101 +++++++ .../UnitTests/SqlCommandAnsiParameterTests.cs | 267 ++++++++++++++++++ 5 files changed, 422 insertions(+), 38 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Parser/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Parser/TdsParser.cs index 001e494cae..6fb82c147f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Parser/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Parser/TdsParser.cs @@ -10534,7 +10534,7 @@ private Task TDSExecuteRPCAddParameter(TdsParserStateObject stateObj, SqlParamet size = -1; actualSize = (encryptedValue == null) ? 0 : encryptedValue.Length; - encryptedParameterInfoToWrite = new SqlColumnEncryptionInputParameterInfo(param.GetMetadataForTypeInfo(), + encryptedParameterInfoToWrite = new SqlColumnEncryptionInputParameterInfo(param.GetMetadataForTypeInfo(this), param.CipherMetadata); // Set the value to the encrypted value and mark isSqlVal as false for VARBINARY encrypted value. @@ -11006,7 +11006,7 @@ private void WriteSmiParameter(SqlParameter param, int paramIndex, bool sendDefa // Determine Metadata // ParameterPeekAheadValue peekAhead; - SmiParameterMetaData metaData = param.MetaDataForSmi(out peekAhead); + SmiParameterMetaData metaData = param.MetaDataForSmi(this, out peekAhead); // // Determine value to send 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 a42abe85cf..24a5e74073 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs @@ -2167,7 +2167,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(); @@ -2213,35 +2213,10 @@ and not SqlDbType.Udt // parser, with it's associated code page. if (mt.IsAnsiType) { - object val = sqlParam.GetCoercedValue(); - string s = null; - - // Deal with the sql types - if (val is not null && val != DBNull.Value) - { - // @TODO: I swear this can be one line in the if statement... - s = val as string; - if (s is null) - { - SqlString sval = val is SqlString sqlVal ? sqlVal : SqlString.Null; - if (!sval.IsNull) - { - s = sval.Value; - } - } - } - - if (s is not null) + int actualBytes = sqlParam.GetActualSizeInBytes(parser); + if (actualBytes > size) { - int actualBytes = parser.GetEncodingCharLength( - value: s, - numChars: sqlParam.GetActualSize(), - charOffset: sqlParam.Offset, - encoding: null); - if (actualBytes > size) - { - size = actualBytes; - } + size = actualBytes; } } @@ -2724,7 +2699,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 583cee1189..5ff155022f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs @@ -835,6 +835,7 @@ public int Offset throw ADP.InvalidOffsetValue(value); } _offset = value; + _actualSize = -1; } } @@ -1628,6 +1629,8 @@ internal int GetActualSize() { // for these types, ActualSize is the num of chars, not actual bytes - since non-unicode chars are not always uniform size coercedSize = (!HasFlag(SqlParameterFlags.IsNull) && (!HasFlag(SqlParameterFlags.CoercedValueIsDataFeed))) ? (StringSize(val, HasFlag(SqlParameterFlags.CoercedValueIsSqlType))) : 0; + // Size limits the transmitted slice, which starts at Offset. + coercedSize = Math.Max(0, coercedSize - Offset); _actualSize = (ShouldSerializeSize() ? Size : 0); _actualSize = (ShouldSerializeSize() && (_actualSize <= coercedSize)) ? _actualSize : coercedSize; if (_actualSize == -1) @@ -1721,29 +1724,57 @@ internal int GetParameterSize() return ShouldSerializeSize() ? Size : ValueSize(CoercedValue); } + // GetActualSize deliberately returns characters for ANSI values. Share the byte count + // between SQL declarations, RPC promotion, and Always Encrypted type_info metadata. + internal int GetActualSizeInBytes(TdsParser parser) + { + int actualSize = GetActualSize(); + if (!InternalMetaType.IsAnsiType || IsNull || + HasFlag(SqlParameterFlags.CoercedValueIsDataFeed) || + Direction == ParameterDirection.Output) + { + return actualSize; + } + + object value = GetCoercedValue(); + string text = value is SqlString sqlString ? sqlString.Value : + value is SqlChars sqlChars ? new string(sqlChars.Value) : (string)value; + + return parser.GetEncodingCharLength(text, actualSize, Offset, null); + } + /// /// Get SMI Metadata to write out type_info stream. /// /// - internal SmiParameterMetaData GetMetadataForTypeInfo() + internal SmiParameterMetaData GetMetadataForTypeInfo(TdsParser parser) { if (_internalMetaType == null) { _internalMetaType = GetMetaTypeOnly(); } - return MetaDataForSmi(out _); + return MetaDataForSmi(parser, out _); } // IMPORTANT DEVNOTE: This method is being used for parameter encryption functionality, to get the type_info TDS object from SqlParameter. // Please consider impact to that when changing this method. Refer to the callers of SqlParameter.GetMetadataForTypeInfo(). - internal SmiParameterMetaData MetaDataForSmi(out ParameterPeekAheadValue peekAhead) + internal SmiParameterMetaData MetaDataForSmi( + TdsParser parser, out ParameterPeekAheadValue peekAhead) { peekAhead = null; - MetaType mt = ValidateTypeLengths(); - long actualLen = GetActualSize(); + MetaType mt = ValidateTypeLengths(parser); + long actualLen = mt.IsAnsiType && !mt.IsLong + ? GetActualSizeInBytes(parser) + : GetActualSize(); long maxLen = Size; + // MAX promotion can be caused by encoded bytes even when Size is below 8000. + if (mt.IsAnsiType && mt.IsPlp) + { + maxLen = SmiMetaData.UnlimitedMaxLengthIndicator; + } + // GetActualSize returns bytes length, but smi expects char length for // character types, so adjust if (!mt.IsLong) @@ -2093,7 +2124,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) { MetaType mt = InternalMetaType; // Since the server will automatically reject any @@ -2106,6 +2137,16 @@ 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 (mt.IsAnsiType && + actualSizeInBytes <= TdsEnums.TYPE_SIZE_LIMIT && + sizeInCharacters <= TdsEnums.TYPE_SIZE_LIMIT && sizeInCharacters != -1) + { + actualSizeInBytes = GetActualSizeInBytes(parser); + } + // 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..fe4e08dc8b --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs @@ -0,0 +1,101 @@ +// 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.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 data loss. + /// + [Trait("Set", "3")] + public static class Utf8ParameterTests + { + /// + /// Covers text, stored-procedure and prepared RPCs with sync and async execution. + /// + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsUTF8Supported), nameof(DataTestUtility.IsNotAzureServer), + nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData(false, 0, 8000, 0)] + [InlineData(true, 0, 8000, 0)] + [InlineData(false, 1, 8000, 0)] + [InlineData(true, 1, 8000, 0)] + [InlineData(false, 2, 8000, 0)] + [InlineData(true, 2, 8000, 0)] + [InlineData(false, 0, 0, 1)] + [InlineData(true, 0, 0, 1)] + [InlineData(false, 1, 0, 1)] + [InlineData(true, 1, 0, 1)] + [InlineData(false, 0, 8000, 1)] + [InlineData(true, 0, 8000, 1)] + [InlineData(false, 1, 8000, 1)] + [InlineData(true, 1, 8000, 1)] + [InlineData(false, 2, 8000, 1)] + [InlineData(true, 2, 8000, 1)] + public static async Task VarCharUtf8ByteBoundaryRoundTrip( + bool useAsync, int commandMode, int size, int offset) + { + 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 (useAsync) + { + 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 = commandMode == 1 ? procedure.Name : "SELECT @p"; + command.CommandType = commandMode == 1 + ? CommandType.StoredProcedure + : CommandType.Text; + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, size); + parameter.Offset = offset; + parameter.Value = new string('a', offset) + new string('\u00e9', 4001); + if (commandMode == 2) + { + command.Prepare(); + } + + // Reuse the command across the byte boundary, including a prepared handle. + foreach (int count in new[] { 4001, 4000, 4001 }) + { + string text = new('\u00e9', count); + parameter.Value = new string('a', offset) + text; + object result = useAsync + ? 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..1d54cb053f --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs @@ -0,0 +1,267 @@ +// 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 Microsoft.Data.SqlClient.Parser; +using Microsoft.Data.SqlClient.Server; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests +{ + /// + /// Guards ANSI parameter declarations against TDS's byte limit under multibyte encodings. + /// + public class SqlCommandAnsiParameterTests + { + /// + /// Exercises sp_executesql and prepared command declarations at UTF-8 boundaries. + /// + [Theory] + [InlineData("\u00e9", 4000, 8000, false, false)] + [InlineData("\u00e9", 4001, 8000, false, true)] + [InlineData("\u00e9", 4001, 0, false, true)] + [InlineData("\u20ac", 2666, 8000, false, false)] + [InlineData("\u20ac", 2667, 8000, false, true)] + [InlineData("\U0001f600", 2001, 8000, false, true)] + [InlineData("a", 8000, 8000, false, false)] + [InlineData("\u00e9", 4001, 4000, false, false)] + [InlineData("\u00e9", 4001, -1, false, true)] + [InlineData("\u00e9", 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); + string transmittedText = text.Substring(0, parameter.GetActualSize()); + int encodedBytes = Encoding.UTF8.GetByteCount(transmittedText); + 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('\u00e9', 4001) + }; + string declaration = BuildDeclaration(parameter, Encoding.GetEncoding(28591)); + + Assert.Contains("varchar(8000)", declaration); + 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('\u00e9', 4001), + Offset = 4001 + }; + Assert.Contains("varchar(max)", BuildDeclaration(parameter, Encoding.UTF8)); + } + + [Theory] + [InlineData(4000, 0, false)] + [InlineData(4001, 0, true)] + [InlineData(4000, 8000, false)] + [InlineData(4001, 8000, true)] + [InlineData(4001, 4000, false)] + public void BuildParamList_OffsetNearEnd_UsesRemainingCharacters( + int count, int size, bool expectMax) + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, size) + { + Value = "a" + new string('\u00e9', count), + Offset = 1 + }; + + string declaration = BuildDeclaration(parameter, Encoding.UTF8); + + Assert.Equal(expectMax, parameter.InternalMetaType.IsPlp); + Assert.Equal(size > 0 ? Math.Min(size, count) : count, parameter.GetActualSize()); + bool declaresMax = declaration.IndexOf("varchar(max)", StringComparison.Ordinal) >= 0; + Assert.Equal(expectMax, declaresMax); + } + + [Fact] + public void BuildParamList_OffsetChanged_RecalculatesTransmittedLength() + { + SqlParameter parameter = new("@p", SqlDbType.VarChar) + { + Value = new string('\u00e9', 4000) + }; + BuildDeclaration(parameter, Encoding.UTF8); + Assert.Equal(4000, parameter.GetActualSize()); + + parameter.Offset = 1; + Assert.Equal(3999, parameter.GetActualSize()); + string declaration = BuildDeclaration(parameter, Encoding.UTF8); + + Assert.Equal(3999, parameter.GetActualSize()); + Assert.Contains("varchar(7998)", declaration); + } + + [Theory] + [InlineData(4000, 0, false)] + [InlineData(4001, 0, true)] + [InlineData(4000, 8000, false)] + [InlineData(4001, 8000, true)] + [InlineData(4001, 4000, false)] + [InlineData(4001, -1, true)] + public void GetMetadataForTypeInfo_Utf8Value_UsesEncodedByteLength( + int count, int size, bool expectMax) + { + SqlParameter parameter = new("@p", SqlDbType.VarChar, size) + { + Value = new string('\u00e9', count) + }; + TdsParser parser = CreateParser(Encoding.UTF8); + + // This is the metadata entry point used when writing Always Encrypted type_info. + SmiParameterMetaData metadata = parameter.GetMetadataForTypeInfo(parser); + + Assert.Equal(SqlDbType.VarChar, metadata.SqlDbType); + Assert.Equal(expectMax, parameter.InternalMetaType.IsPlp); + int expectedBytes = (size > 0 ? Math.Min(size, count) : count) * 2; + Assert.Equal(expectMax ? -1L : Math.Max(parameter.Size, expectedBytes), + metadata.MaxLength); + Assert.Equal(size != 0 ? size : count, parameter.Size); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetMetadataForTypeInfo_SqlCharsWithOffset_UsesTransmittedSlice(bool expectMax) + { + int count = expectMax ? 4001 : 4000; + SqlParameter parameter = new("@p", SqlDbType.VarChar) + { + Value = new SqlChars(("a" + new string('\u00e9', count)).ToCharArray()), + Offset = 1 + }; + TdsParser parser = CreateParser(Encoding.UTF8); + + SmiParameterMetaData metadata = parameter.GetMetadataForTypeInfo(parser); + + Assert.Equal(expectMax ? -1L : 8000L, metadata.MaxLength); + Assert.Equal(expectMax, parameter.InternalMetaType.IsPlp); + Assert.Equal(count, parameter.GetActualSize()); + } + + [Theory] + [InlineData(SqlDbType.Char, false)] + [InlineData(SqlDbType.Char, true)] + [InlineData(SqlDbType.VarChar, false)] + [InlineData(SqlDbType.VarChar, true)] + public void GetMetadataForTypeInfo_AfterBuildParamList_PreservesMax( + SqlDbType sqlDbType, bool sqlString) + { + string text = new('\u00e9', 4001); + SqlParameter parameter = new("@p", sqlDbType, 8000) + { + Value = sqlString ? new SqlString(text) : (object)text + }; + string declaration = BuildDeclaration(parameter, Encoding.UTF8); + TdsParser parser = CreateParser(Encoding.UTF8); + + SmiParameterMetaData metadata = parameter.GetMetadataForTypeInfo(parser); + + Assert.Contains("varchar(max)", declaration); + Assert.Equal(SqlDbType.VarChar, metadata.SqlDbType); + Assert.Equal(-1L, metadata.MaxLength); + } + + [Fact] + public void GetMetadataForTypeInfo_SingleByteEncoding_DoesNotPromote() + { + SqlParameter parameter = new("@p", SqlDbType.VarChar) + { + Value = new string('\u00e9', 4001) + }; + TdsParser parser = CreateParser(Encoding.GetEncoding(28591)); + + SmiParameterMetaData metadata = parameter.GetMetadataForTypeInfo(parser); + + Assert.False(parameter.InternalMetaType.IsPlp); + Assert.Equal(4001L, metadata.MaxLength); + } + + /// + /// 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); + + TdsParser parser = CreateParser(Encoding.UTF8); + SmiParameterMetaData metadata = parameter.GetMetadataForTypeInfo(parser); + Assert.Equal(8000L, metadata.MaxLength); + } + + /// + /// Calls the SQL declaration builder with a 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 = CreateParser(encoding); + using SqlCommand command = new(); + command.Parameters.Add(parameter); + MethodInfo buildParamList = typeof(SqlCommand).GetMethod( + "BuildParamList", BindingFlags.Instance | BindingFlags.NonPublic)!; + string declaration = (string)buildParamList.Invoke( + command, new object[] { parser, command.Parameters, false })!; + + return declaration; + } + + private static TdsParser CreateParser(Encoding encoding) + { + TdsParser parser = new(false, false); + FieldInfo encodingField = typeof(TdsParser).GetField( + "_defaultEncoding", BindingFlags.Instance | BindingFlags.NonPublic)!; + encodingField.SetValue(parser, encoding); + + return parser; + } + } +} From e8f7998d84c733baaa2648950a5fd5b302a4325c Mon Sep 17 00:00:00 2001 From: Jean-Sebastien Carle <29762210+jscarle@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:42:22 -0400 Subject: [PATCH 2/5] Release parameter ownership after building test declarations --- .../UnitTests/SqlCommandAnsiParameterTests.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs index 1d54cb053f..1ee0c9e954 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs @@ -246,12 +246,20 @@ private static string BuildDeclaration(SqlParameter parameter, Encoding encoding TdsParser parser = CreateParser(encoding); using SqlCommand command = new(); command.Parameters.Add(parameter); - MethodInfo buildParamList = typeof(SqlCommand).GetMethod( - "BuildParamList", BindingFlags.Instance | BindingFlags.NonPublic)!; - string declaration = (string)buildParamList.Invoke( - command, new object[] { parser, command.Parameters, false })!; + try + { + MethodInfo buildParamList = typeof(SqlCommand).GetMethod( + "BuildParamList", BindingFlags.Instance | BindingFlags.NonPublic)!; + string declaration = (string)buildParamList.Invoke( + command, new object[] { parser, command.Parameters, false })!; - return declaration; + return declaration; + } + finally + { + // Disposing a command does not release its parameters for the next declaration. + command.Parameters.Clear(); + } } private static TdsParser CreateParser(Encoding encoding) From de01f8cf95faf79cc3b4be3f2285565e89a1f8e6 Mon Sep 17 00:00:00 2001 From: Jean-Sebastien Carle <29762210+jscarle@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:01:02 -0400 Subject: [PATCH 3/5] Reprepare ANSI parameters when encoded capacity grows --- .../Data/SqlClient/SqlCommand.Reader.cs | 24 ++- .../Microsoft/Data/SqlClient/SqlCommand.cs | 68 +++++++- .../SQL/Utf8SupportTest/Utf8ParameterTests.cs | 151 +++++++++++++++++- .../EncryptedAnsiParameterTests.cs | 129 +++++++++++++++ .../PreparedAnsiParameterTests.cs | 99 ++++++++++++ 5 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/EncryptedAnsiParameterTests.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/PreparedAnsiParameterTests.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs index 7d054f593b..6e691ab3e6 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs @@ -555,6 +555,12 @@ private _SqlRPC BuildPrepExec(CommandBehavior behavior) sqlParam.Direction = ParameterDirection.Input; SetUpRPCParameters(rpc, inSchema: false, _parameters); + _pendingParameterByteCapacities = new int[_parameters.Count]; + for (int i = 0; i < _parameters.Count; i++) + { + _pendingParameterByteCapacities[i] = GetAnsiParameterByteCapacity( + _parameters[i], _stateObj.Parser); + } return rpc; } @@ -779,6 +785,9 @@ private void FinishExecuteReader( } } + // The server can return a handle before reporting a preparation error. + // Retain this state even if OnReturnValue clears _inPrepare while reading the response. + bool wasInPrepare = _inPrepare; if (runBehavior is RunBehavior.UntilDone) { try @@ -801,12 +810,15 @@ private void FinishExecuteReader( { if (ADP.IsCatchableExceptionType(e)) { - if (_inPrepare) + if (wasInPrepare) { // The flag is expected to be reset by OnReturnValue. We should receive // the handle unless command execution failed. If it fails, move back // to pending state. _inPrepare = false; // reset the flag + _prepareHandle = s_cachedInvalidPrepareHandle; + _preparedParameterByteCapacities = null; + _pendingParameterByteCapacities = null; IsDirty = true; // mark command as dirty so it will be // prepared next time we're coming through _execType = EXECTYPE.PREPAREPENDING; // reset execution type to pending @@ -868,12 +880,15 @@ private void FinishExecuteReader( } catch (Exception e) when (ADP.IsCatchableExceptionType(e)) { - if (_inPrepare) + if (wasInPrepare) { // The flag is expected to be reset by OnReturnValue. We should receive // the handle unless command execution failed. If it fails, move back // to pending state. _inPrepare = false; // reset the flag + _prepareHandle = s_cachedInvalidPrepareHandle; + _preparedParameterByteCapacities = null; + _pendingParameterByteCapacities = null; IsDirty = true; // mark command as dirty so it will be prepared next time we're coming through _execType = EXECTYPE.PREPAREPENDING; // reset execution type to pending } @@ -1449,6 +1464,11 @@ private SqlDataReader RunExecuteReaderTds( else if (CommandType is CommandType.Text) { // @TODO: Execute as RPC (or text with parameters) + if (_execType is EXECTYPE.PREPARED && !IsDirty && + PreparedParametersRequireReprepare()) + { + IsDirty = true; + } if (IsDirty) { // Can have cached metadata if dirty because of parameters 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 24a5e74073..169d222329 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.cs @@ -287,6 +287,11 @@ private static class ProcParamsColumns /// private object _prepareHandle = s_cachedInvalidPrepareHandle; + // These capacities describe the server declaration, not the current parameter values. + // Zero skips non-ANSI parameters; -1 denotes a MAX declaration. + private int[] _preparedParameterByteCapacities; + private int[] _pendingParameterByteCapacities; + /// /// Last TaskCompletionSource for reconnect task - use for cancellation only. /// @@ -629,6 +634,8 @@ public override CommandType CommandType // Clean prepare status (even successful unprepare does not do that) // @TODO: ... but it does? _prepareHandle = s_cachedInvalidPrepareHandle; + _preparedParameterByteCapacities = null; + _pendingParameterByteCapacities = null; _execType = EXECTYPE.UNPREPARED; } } @@ -1604,8 +1611,10 @@ internal void OnReturnValue(SqlReturnValue returnValue, TdsParserStateObject sta if (!returnValue.value.IsNull) { _prepareHandle = returnValue.value.Int32; + _preparedParameterByteCapacities = _pendingParameterByteCapacities; } + _pendingParameterByteCapacities = null; _inPrepare = false; return; } @@ -2213,11 +2222,7 @@ and not SqlDbType.Udt // parser, with it's associated code page. if (mt.IsAnsiType) { - int actualBytes = sqlParam.GetActualSizeInBytes(parser); - if (actualBytes > size) - { - size = actualBytes; - } + size = GetAnsiParameterByteCapacity(sqlParam, parser); } // If the user specified a 0-sized parameter for a variable length field, pass @@ -2248,6 +2253,56 @@ and not SqlDbType.Udt return paramList.ToString(); } + private static int GetAnsiParameterByteCapacity(SqlParameter parameter, TdsParser parser) + { + MetaType metaType = parameter.InternalMetaType; + if (!metaType.IsAnsiType || parameter.Direction == ParameterDirection.ReturnValue) + { + return 0; + } + if (metaType.IsPlp) + { + return -1; + } + if (metaType.IsLong) + { + return 0; + } + + int capacity = Math.Max(parameter.Size, parameter.GetActualSizeInBytes(parser)); + return capacity == 0 ? TdsEnums.MAXSIZE : capacity; + } + + private bool PreparedParametersRequireReprepare() + { + if (_preparedParameterByteCapacities == null || + _preparedParameterByteCapacities.Length != _parameters.Count) + { + return true; + } + + TdsParser parser = _stateObj.Parser; + for (int i = 0; i < _parameters.Count; i++) + { + int preparedCapacity = _preparedParameterByteCapacities[i]; + if (preparedCapacity == 0 || preparedCapacity == -1) + { + continue; + } + + SqlParameter parameter = _parameters[i]; + parameter.Validate(i, isCommandProc: false); + parameter.ValidateTypeLengths(parser); + int requiredCapacity = GetAnsiParameterByteCapacity(parameter, parser); + if (requiredCapacity == -1 || requiredCapacity > preparedCapacity) + { + return true; + } + } + + return false; + } + /// /// This method is used to route CancellationTokens to the Cancel method. Cancellation is a /// suggestion, and exceptions should be ignored rather than allowed to be unhandled, as @@ -2839,6 +2894,9 @@ private void Unprepare() _execType = EXECTYPE.PREPAREPENDING; + _preparedParameterByteCapacities = null; + _pendingParameterByteCapacities = null; + // Don't zero out the handle because we'll pass it in to sp_prepexec on the next prepare // Unless the close count isn't the same as when we last prepared if (_activeConnection.CloseCount != _preparedConnectionCloseCount || diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs index fe4e08dc8b..df4beb2799 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Data; +using System.Reflection; using System.Threading.Tasks; using Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects; using Xunit; @@ -75,14 +76,14 @@ public static async Task VarCharUtf8ByteBoundaryRoundTrip( : CommandType.Text; SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, size); parameter.Offset = offset; - parameter.Value = new string('a', offset) + new string('\u00e9', 4001); + parameter.Value = new string('a', offset) + new string('\u00e9', 4000); if (commandMode == 2) { command.Prepare(); } // Reuse the command across the byte boundary, including a prepared handle. - foreach (int count in new[] { 4001, 4000, 4001 }) + foreach (int count in new[] { 4000, 4001, 4000, 4001 }) { string text = new('\u00e9', count); parameter.Value = new string('a', offset) + text; @@ -97,5 +98,151 @@ public static async Task VarCharUtf8ByteBoundaryRoundTrip( DataTestUtility.DropDatabase(admin, database); } } + + [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)] + [InlineData(false, 3)] + [InlineData(true, 3)] + [InlineData(false, 4)] + [InlineData(true, 4)] + [InlineData(false, 5)] + [InlineData(true, 5)] + [InlineData(false, 6)] + [InlineData(true, 6)] + [InlineData(false, 7)] + [InlineData(true, 7)] + public static async Task PreparedVarChar_GrowingByteCapacity_PreservesValueAndReuse( + bool useAsync, int scenario) + { + string database = DataTestUtility.GetLongName("Utf8Prepared", false); + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) + { + InitialCatalog = "master", + Pooling = false + }; + using SqlConnection admin = new(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 = new(builder.ConnectionString); + await connection.OpenAsync(); + using SqlConnection replacement = new(builder.ConnectionString); + using SqlCommand command = connection.CreateCommand(); + bool insert = scenario == 2; + if (insert) + { + using SqlCommand create = new("CREATE TABLE ValuesTable (Value varchar(max))", + connection); + create.ExecuteNonQuery(); + } + command.CommandText = insert + ? "INSERT INTO ValuesTable VALUES (@p); SELECT Value FROM ValuesTable; " + + "DELETE FROM ValuesTable" + : "SELECT @p"; + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, + scenario == 1 ? 8000 : 4000); + string original = scenario == 1 + ? new string('\u00e9', 4001) + : new string('a', 4000); + parameter.Value = original; + parameter.Offset = scenario == 1 ? 1 : 0; + command.Prepare(); + + object first = useAsync + ? await command.ExecuteScalarAsync() : command.ExecuteScalar(); + Assert.Equal(original.Substring(parameter.Offset), first); + FieldInfo handleField = typeof(SqlCommand).GetField("_prepareHandle", + BindingFlags.Instance | BindingFlags.NonPublic)!; + object originalHandle = handleField.GetValue(command); + + // Equal byte capacities should keep the original server preparation. + Assert.Equal(first, useAsync + ? await command.ExecuteScalarAsync() : command.ExecuteScalar()); + Assert.Equal(originalHandle, handleField.GetValue(command)); + + if (scenario == 3) + { + connection.Close(); + await connection.OpenAsync(); + } + else if (scenario == 4 || scenario == 7) + { + // A failed reprepare must discard the old declaration's handle. + command.CommandText = "SELECT @p FROM MissingUtf8PreparedTable"; + if (useAsync) + { + await Assert.ThrowsAsync(() => scenario == 7 + ? command.ExecuteNonQueryAsync() : command.ExecuteScalarAsync()); + } + else + { + Assert.Throws(() => + { + if (scenario == 7) + { + command.ExecuteNonQuery(); + } + else + { + command.ExecuteScalar(); + } + }); + } + command.CommandText = "SELECT @p"; + } + else if (scenario == 5) + { + await replacement.OpenAsync(); + command.Connection = replacement; + command.Prepare(); + } + else if (scenario == 6) + { + // A collection/schema change must replace the entire capacity snapshot. + command.Parameters.Add("@unused", SqlDbType.Int).Value = 1; + } + + parameter.Offset = 0; + string grown = new('\u00e9', scenario == 1 ? 4001 : 4000); + if (scenario != 1) + { + parameter.Value = grown; + } + Assert.Equal(grown, useAsync + ? await command.ExecuteScalarAsync() : command.ExecuteScalar()); + object grownHandle = handleField.GetValue(command); + if (scenario < 3) + { + Assert.NotEqual(originalHandle, grownHandle); + } + + // Shrinking and regrowing within the new capacity (including MAX) reuse it. + foreach (string value in new[] { "small", grown }) + { + parameter.Value = value; + Assert.Equal(value, useAsync + ? await command.ExecuteScalarAsync() : command.ExecuteScalar()); + Assert.Equal(grownHandle, handleField.GetValue(command)); + } + } + finally + { + DataTestUtility.DropDatabase(admin, database); + } + } } } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/EncryptedAnsiParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/EncryptedAnsiParameterTests.cs new file mode 100644 index 0000000000..2bfc393a4d --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/EncryptedAnsiParameterTests.cs @@ -0,0 +1,129 @@ +// 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.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient.AlwaysEncrypted; +using Microsoft.Data.SqlClient.Parser; +using Microsoft.SqlServer.TDS.Servers; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests +{ + [Collection(SimulatedServerTestCollection.Name)] + public class EncryptedAnsiParameterTests + { + [Theory] + [InlineData(4000, 8000, 0, false, false)] + [InlineData(4001, 8000, 0, false, false)] + [InlineData(4001, 4000, 0, false, false)] + [InlineData(4001, 8000, 1, false, false)] + [InlineData(4000, 0, 1, true, false)] + [InlineData(4001, 0, 1, true, false)] + [InlineData(4001, 4000, 1, true, false)] + [InlineData(0, 8000, 0, false, true)] + [InlineData(0, -1, 0, true, true)] + public async Task EncryptedRpc_Utf8Parameter_WritesOriginalTypeInfoAndExactCiphertext( + int count, int size, int offset, bool sqlString, bool isNull) + { + using TdsServer server = new(); + server.Start(); + using SqlConnection connection = new(new SqlConnectionStringBuilder + { + DataSource = $"localhost,{server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false + }.ConnectionString); + connection.Open(); + TdsParser parser = connection.Parser; + typeof(TdsParser).GetField("_defaultEncoding", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, Encoding.UTF8); + typeof(TdsParser).GetField("_defaultCollation", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, + new SqlCollation(1033 | TdsEnums.UTF8_IN_TDSCOLLATION, 0)); + TdsParserStateObject state = (TdsParserStateObject)typeof(TdsParser) + .GetField("_physicalStateObj", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(parser)!; + state.SetPacketSize(32768); + int start = state._outBytesUsed; + + // Supply an initialized test cipher to exercise encryption without a key store. + SqlAeadAes256CbcHmac256Algorithm cipher = new( + new AeadAes256CbcHmac256EncryptionKey(new byte[32]), + EncryptionType.Deterministic, SqlAeadAes256CbcHmac256Algorithm.CurrentVersion); + string text = new string('a', offset) + new string('\u00e9', count); + object value = isNull ? (sqlString ? SqlString.Null : DBNull.Value) + : sqlString ? new SqlString(text) : (object)text; + using SqlCommand command = new("SELECT @p", connection); + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, size); + parameter.Value = value; + parameter.Offset = offset; + parameter.CipherMetadata = new SqlCipherMetadata(new SqlTceCipherInfoEntry(), 0, + 2, SqlAeadAes256CbcHmac256Algorithm.AlgorithmName, 1, 1) + { + CipherAlgorithm = cipher, + EncryptionKeyInfo = new SqlEncryptionKeyInfo() + }; + parameter.Validate(0, false); + MetaType metaType = parameter.ValidateTypeLengths(parser); + + // Invoke the actual encrypted RPC writer, including its original TYPE_INFO caller. + MethodInfo serialize = typeof(TdsParser).GetMethod("TDSExecuteRPCAddParameter", + BindingFlags.Instance | BindingFlags.NonPublic)!; + Task? pending = (Task?)serialize.Invoke(parser, new object[] + { + state, parameter, metaType, TdsEnums.RPC_PARAM_ENCRYPTED, command, false + }); + if (pending != null) + { + await pending; + } + + using MemoryStream bytes = new(state._outBuff, start, state._outBytesUsed - start); + using BinaryReader reader = new(bytes); + int nameCharacters = reader.ReadByte(); + Assert.Equal("@p", Encoding.Unicode.GetString(reader.ReadBytes(nameCharacters * 2))); + Assert.Equal(TdsEnums.RPC_PARAM_ENCRYPTED, reader.ReadByte()); + Assert.Equal(TdsEnums.SQLBIGVARBINARY, reader.ReadByte()); + Assert.Equal(ushort.MaxValue, reader.ReadUInt16()); + ulong payloadLength = reader.ReadUInt64(); + int transmittedCharacters = size > 0 ? Math.Min(size, count) : count; + if (isNull) + { + Assert.Equal(ulong.MaxValue, payloadLength); + } + else + { + int chunkLength = reader.ReadInt32(); + Assert.Equal((ulong)chunkLength, payloadLength); + byte[] encrypted = reader.ReadBytes(chunkLength); + byte[] plaintext = cipher.DecryptData(encrypted); + Assert.Equal(Encoding.UTF8.GetBytes(new string('\u00e9', transmittedCharacters)), + plaintext); + Assert.Equal(0, reader.ReadInt32()); // PLP terminator + } + + Assert.Equal(TdsEnums.SQLBIGVARCHAR, reader.ReadByte()); + int capacity = Math.Max(size, transmittedCharacters * 2); + ushort expectedLength = size == -1 || capacity > 8000 + ? ushort.MaxValue : (ushort)capacity; + Assert.Equal(expectedLength, reader.ReadUInt16()); + Assert.Equal(1033 | TdsEnums.UTF8_IN_TDSCOLLATION, reader.ReadUInt32()); + Assert.Equal(0, reader.ReadByte()); // sort ID + Assert.Equal(parameter.CipherMetadata.CipherAlgorithmId, reader.ReadByte()); + Assert.Equal(parameter.CipherMetadata.EncryptionType, reader.ReadByte()); + Assert.Equal(0, reader.ReadInt32()); // database ID + Assert.Equal(0, reader.ReadInt32()); // CEK ID + Assert.Equal(0, reader.ReadInt32()); // CEK version + Assert.Equal(0UL, reader.ReadUInt64()); // CEK metadata version + Assert.Equal(1, reader.ReadByte()); // normalization version + Assert.Equal(bytes.Length, bytes.Position); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/PreparedAnsiParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/PreparedAnsiParameterTests.cs new file mode 100644 index 0000000000..3b0de7de92 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/PreparedAnsiParameterTests.cs @@ -0,0 +1,99 @@ +// 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.Reflection; +using System.Text; +using Microsoft.Data.SqlClient.Parser; +using Microsoft.Data.SqlClient.Parser.Tokens; +using Microsoft.SqlServer.TDS.Servers; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests +{ + [Collection(SimulatedServerTestCollection.Name)] + public class PreparedAnsiParameterTests + { + [Theory] + [InlineData(4000, false, true, true)] + [InlineData(4000, true, false, false)] + [InlineData(8000, false, true, false)] + [InlineData(-1, false, true, false)] + public void PreparedDeclaration_ValueChanges_RequiresOnlyWiderByteCapacity( + int size, bool initialMultibyte, bool nextMultibyte, bool expectReprepare) + { + using TdsServer server = new(); + server.Start(); + using SqlConnection connection = new(new SqlConnectionStringBuilder + { + DataSource = $"localhost,{server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false + }.ConnectionString); + connection.Open(); + TdsParser parser = connection.Parser; + typeof(TdsParser).GetField("_defaultEncoding", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, Encoding.UTF8); + object state = typeof(TdsParser).GetField("_physicalStateObj", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(parser)!; + using SqlCommand command = new("SELECT @p", connection); + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, size); + parameter.Value = new string(initialMultibyte ? '\u00e9' : 'a', 4000); + command.Parameters.Add("@integer", SqlDbType.Int).Value = 1; + command.Parameters.Add("@legacy", SqlDbType.Text, 10).Value = "legacy"; + command.Parameters.Add("@return", SqlDbType.VarChar, 10).Direction = + ParameterDirection.ReturnValue; + command.Prepare(); + typeof(SqlCommand).GetField("_stateObj", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(command, state); + + _SqlRPC rpc = (_SqlRPC)typeof(SqlCommand).GetMethod("BuildPrepExec", + BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(command, + new object[] { CommandBehavior.Default })!; + string declaration = Assert.IsType(rpc.systemParams[1].Value); + Assert.Contains(size == -1 ? "@p varchar(max)" : + $"@p varchar({Math.Max(size, initialMultibyte ? 8000 : 4000)})", declaration); + + typeof(SqlCommand).GetField("_inPrepare", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(command, true); + SqlReturnValue handle = new(); + handle.value.Int32 = 123; + command.OnReturnValue(handle, (TdsParserStateObject)state); + parameter.Value = new string(nextMultibyte ? '\u00e9' : 'a', 4000); + + bool requiresReprepare = (bool)typeof(SqlCommand) + .GetMethod("PreparedParametersRequireReprepare", + BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(command, null)!; + + Assert.Equal(expectReprepare, requiresReprepare); + Assert.Equal(size, parameter.Size); + Assert.Null(typeof(SqlCommand).GetField("_pendingParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(command)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PreparedDeclaration_MissingOrChangedSchema_RequiresFreshPreparation( + bool changedSchema) + { + using SqlCommand command = new(); + command.Parameters.Add("@p", SqlDbType.VarChar, 8000).Value = "value"; + if (changedSchema) + { + // A snapshot for an earlier parameter collection cannot describe this command. + typeof(SqlCommand).GetField("_preparedParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(command, Array.Empty()); + } + + bool requiresReprepare = (bool)typeof(SqlCommand) + .GetMethod("PreparedParametersRequireReprepare", + BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(command, null)!; + + Assert.True(requiresReprepare); + } + } +} From 5c761b94a2c84caf45de5c7eb9e6d10cbc366de5 Mon Sep 17 00:00:00 2001 From: Jean-Sebastien Carle <29762210+jscarle@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:38:40 -0400 Subject: [PATCH 4/5] Preserve ANSI sql_variant payload lengths with offsets --- .../Microsoft/Data/SqlClient/SqlParameter.cs | 8 +- .../ParameterTest/SqlVariantParameterTests.cs | 45 ++++++ .../SqlVariantRpcParameterTests.cs | 147 ++++++++++++++++++ 3 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/SqlVariantRpcParameterTests.cs 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 2b6ee91e8a..dad20df5bb 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs @@ -1627,8 +1627,12 @@ internal int GetActualSize() { // for these types, ActualSize is the num of chars, not actual bytes - since non-unicode chars are not always uniform size coercedSize = (!HasFlag(SqlParameterFlags.IsNull) && (!HasFlag(SqlParameterFlags.CoercedValueIsDataFeed))) ? (StringSize(val, HasFlag(SqlParameterFlags.CoercedValueIsSqlType))) : 0; - // Size limits the transmitted slice, which starts at Offset. - coercedSize = Math.Max(0, coercedSize - Offset); + // ANSI variants serialize the whole string, ignoring Offset. + if (!isSqlVariant) + { + // Size limits the transmitted slice, which starts at Offset. + coercedSize = Math.Max(0, coercedSize - Offset); + } _actualSize = (ShouldSerializeSize() ? Size : 0); _actualSize = (ShouldSerializeSize() && (_actualSize <= coercedSize)) ? _actualSize : coercedSize; if (_actualSize == -1) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/SqlVariantParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/SqlVariantParameterTests.cs index 609e5c11c2..ad18cde1ef 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/SqlVariantParameterTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/SqlVariantParameterTests.cs @@ -8,6 +8,7 @@ using System.Data.SqlTypes; using System.Globalization; using System.Threading; +using System.Threading.Tasks; using Microsoft.Data.SqlClient.Server; using Xunit; @@ -106,6 +107,50 @@ public void SqlType_VariantParam_RoundTripsCorrectly(object paramValue, string e Assert.Equal(expectedBaseTypeName, actualBaseTypeName); } + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task AnsiVariant_OffsetChanged_PreservesCompleteValueAndCommandReuse( + bool useAsync, bool sqlString) + { + SqlConnectionStringBuilder builder = new(_connStr) { Pooling = false }; + using SqlConnection connection = new(builder.ConnectionString); + if (useAsync) + { + await connection.OpenAsync(); + } + else + { + connection.Open(); + } + using SqlCommand command = connection.CreateCommand(); + command.CommandText = "SELECT @p, DATALENGTH(@p), " + + "CONVERT(varchar(20), SQL_VARIANT_PROPERTY(@p, 'BaseType')), @marker"; + string text = "A" + new string('b', 4498) + "Z"; + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.Variant); + parameter.Value = sqlString ? new SqlString(text) : (object)text; + const int Marker = 0x12345678; + command.Parameters.Add("@marker", SqlDbType.Int).Value = Marker; + + // The ANSI variant writer ignores Offset; reusing the command must preserve that. + foreach (int offset in new[] { 0, 1, 0 }) + { + parameter.Offset = offset; + using SqlDataReader reader = useAsync + ? await command.ExecuteReaderAsync() : command.ExecuteReader(); + Assert.True(useAsync ? await reader.ReadAsync() : reader.Read()); + Assert.Equal(text, reader.GetString(0)); + Assert.Equal(4500, reader.GetInt32(1)); + Assert.Equal("varchar", reader.GetString(2)); + Assert.Equal(Marker, reader.GetInt32(3)); + Assert.False(useAsync ? await reader.ReadAsync() : reader.Read()); + Assert.False(useAsync ? await reader.NextResultAsync() : reader.NextResult()); + } + } + /// /// Round trip sql_variant value using SqlBulkCopy with a SqlDataReader source. /// diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/SqlVariantRpcParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/SqlVariantRpcParameterTests.cs new file mode 100644 index 0000000000..ea9b584247 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/SqlVariantRpcParameterTests.cs @@ -0,0 +1,147 @@ +// 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.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient.Parser; +using Microsoft.SqlServer.TDS.Servers; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.SimulatedServerTests +{ + [Collection(SimulatedServerTestCollection.Name)] + public class SqlVariantRpcParameterTests + { + [Theory] + [InlineData(true, 4500, 0, 0, false)] + [InlineData(true, 4500, 0, 0, true)] + [InlineData(true, 4500, 0, 1, false)] + [InlineData(true, 4500, 0, 1, true)] + [InlineData(true, 4500, 0, 4500, false)] + [InlineData(true, 4500, 0, 4500, true)] + [InlineData(true, 4500, 0, 4501, false)] + [InlineData(true, 4500, 0, 4501, true)] + [InlineData(true, 4001, 0, 1, false)] + [InlineData(true, 8000, 0, 1, false)] + [InlineData(true, 4500, 8000, 1, false)] + [InlineData(true, 4500, 8000, 1, true)] + [InlineData(false, 4000, 0, 6, false)] + [InlineData(false, 4000, 3999, 6, false)] + public async Task Rpc_AnsiValueWithOffset_WritesExactLengthsAndFollowingParameter( + bool variant, int count, int size, int offset, bool sqlString) + { + using TdsServer server = new(); + server.Start(); + using SqlConnection connection = new(new SqlConnectionStringBuilder + { + DataSource = $"localhost,{server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + Pooling = false + }.ConnectionString); + connection.Open(); + TdsParser parser = connection.Parser; + typeof(TdsParser).GetField("_defaultEncoding", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, Encoding.UTF8); + typeof(TdsParser).GetField("_defaultCollation", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, + new SqlCollation(1033 | TdsEnums.UTF8_IN_TDSCOLLATION, 0)); + TdsParserStateObject state = (TdsParserStateObject)typeof(TdsParser) + .GetField("_physicalStateObj", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(parser)!; + state.SetPacketSize(32768); + int start = state._outBytesUsed; + + string text = variant ? "A" + new string('b', count - 2) + "Z" + : "prefix" + new string('\u00e9', count); + using SqlCommand command = new("SELECT @p, @marker", connection); + SqlParameter parameter = command.Parameters.Add("@p", + variant ? SqlDbType.Variant : SqlDbType.VarChar, size); + parameter.Value = sqlString ? new SqlString(text) : (object)text; + parameter.Offset = offset; + const int Marker = 0x12345678; + command.Parameters.Add("@marker", SqlDbType.Int).Value = Marker; + MethodInfo serialize = typeof(TdsParser).GetMethod("TDSExecuteRPCAddParameter", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + // Capture actual RPC output; the second parameter detects incorrect framing. + for (int i = 0; i < command.Parameters.Count; i++) + { + SqlParameter current = command.Parameters[i]; + current.Validate(i, false); + MetaType metaType = current.ValidateTypeLengths(parser); + Task? pending = (Task?)serialize.Invoke(parser, new object[] + { + state, current, metaType, (byte)0, command, false + }); + if (pending != null) + { + await pending; + } + } + + string expected = variant ? text : new string('\u00e9', size == 0 ? 4000 : 3999); + byte[] expectedBytes = Encoding.UTF8.GetBytes(expected); + using MemoryStream bytes = new(state._outBuff, start, state._outBytesUsed - start); + using BinaryReader reader = new(bytes); + int nameCharacters = reader.ReadByte(); + Assert.Equal("@p", Encoding.Unicode.GetString(reader.ReadBytes(nameCharacters * 2))); + Assert.Equal(0, reader.ReadByte()); // parameter status + if (variant) + { + Assert.Equal(TdsEnums.SQLVARIANT, reader.ReadByte()); + // Variant headers contain two type bytes and seven varchar property bytes. + int expectedVariantLength = expectedBytes.Length + 2 + 7; + Assert.Equal(expectedVariantLength, reader.ReadInt32()); // maximum length + Assert.Equal(expectedVariantLength, reader.ReadInt32()); // actual length + Assert.Equal(TdsEnums.SQLBIGVARCHAR, reader.ReadByte()); + Assert.Equal(7, reader.ReadByte()); // property-byte count + } + else + { + Assert.Equal(TdsEnums.SQLBIGVARCHAR, reader.ReadByte()); + Assert.Equal(expectedBytes.Length, reader.ReadUInt16()); // maximum length + } + Assert.Equal(1033 | TdsEnums.UTF8_IN_TDSCOLLATION, reader.ReadUInt32()); + Assert.Equal(0, reader.ReadByte()); // sort ID + Assert.Equal(expectedBytes.Length, reader.ReadUInt16()); // data length + Assert.Equal(expectedBytes, reader.ReadBytes(expectedBytes.Length)); + + nameCharacters = reader.ReadByte(); + Assert.Equal("@marker", + Encoding.Unicode.GetString(reader.ReadBytes(nameCharacters * 2))); + Assert.Equal(0, reader.ReadByte()); + Assert.Equal(TdsEnums.SQLINTN, reader.ReadByte()); + Assert.Equal(4, reader.ReadByte()); // maximum length + Assert.Equal(4, reader.ReadByte()); // actual length + Assert.Equal(Marker, reader.ReadInt32()); + Assert.Equal(bytes.Length, bytes.Position); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Variant_OverMaximumLengthWithOffset_RejectsCompleteValue(bool sqlString) + { + string text = new('a', 8001); + SqlParameter parameter = new("@p", SqlDbType.Variant) + { + Value = sqlString ? new SqlString(text) : (object)text, + Offset = 1 + }; + + InvalidOperationException exception = Assert.Throws(() => + { + parameter.Validate(0, false); + parameter.GetActualSize(); + }); + + Assert.Equal(SQL.ParameterInvalidVariant("@p").Message, exception.Message); + } + } +} From 5054b26ae88397917d361903528daae675d6fce3 Mon Sep 17 00:00:00 2001 From: Jean-Sebastien Carle <29762210+jscarle@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:22:05 -0400 Subject: [PATCH 5/5] Preserve returned prepare handles and cover output parameter reuse --- .../Data/SqlClient/SqlCommand.Reader.cs | 18 +- .../PreparedCommandLifecycleTests.cs | 226 ++++++++++++++++++ .../SQL/Utf8SupportTest/Utf8ParameterTests.cs | 139 +++++++++++ .../Data/SqlClient/SqlCommandTests.cs | 63 +++++ .../Data/SqlClient/SqlParameterTests.cs | 77 ++++++ 5 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/PreparedCommandLifecycleTests.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlCommandTests.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlParameterTests.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs index 6e691ab3e6..fd3c6b7bd5 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs @@ -785,8 +785,8 @@ private void FinishExecuteReader( } } - // The server can return a handle before reporting a preparation error. - // Retain this state even if OnReturnValue clears _inPrepare while reading the response. + // OnReturnValue clears _inPrepare even when preparation returns a null handle. + // A committed capacity snapshot distinguishes a valid handle from failed preparation. bool wasInPrepare = _inPrepare; if (runBehavior is RunBehavior.UntilDone) { @@ -810,11 +810,10 @@ private void FinishExecuteReader( { if (ADP.IsCatchableExceptionType(e)) { - if (wasInPrepare) + if (wasInPrepare && _preparedParameterByteCapacities == null) { - // The flag is expected to be reset by OnReturnValue. We should receive - // the handle unless command execution failed. If it fails, move back - // to pending state. + // Preparation failed without returning a handle. A returned handle and + // its capacity snapshot remain reusable after an execution error. _inPrepare = false; // reset the flag _prepareHandle = s_cachedInvalidPrepareHandle; _preparedParameterByteCapacities = null; @@ -880,11 +879,10 @@ private void FinishExecuteReader( } catch (Exception e) when (ADP.IsCatchableExceptionType(e)) { - if (wasInPrepare) + if (wasInPrepare && _preparedParameterByteCapacities == null) { - // The flag is expected to be reset by OnReturnValue. We should receive - // the handle unless command execution failed. If it fails, move back - // to pending state. + // Preparation failed without returning a handle. A returned handle and + // its capacity snapshot remain reusable after an execution error. _inPrepare = false; // reset the flag _prepareHandle = s_cachedInvalidPrepareHandle; _preparedParameterByteCapacities = null; diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/PreparedCommandLifecycleTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/PreparedCommandLifecycleTests.cs new file mode 100644 index 0000000000..a3016226e4 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlCommand/PreparedCommandLifecycleTests.cs @@ -0,0 +1,226 @@ +// 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.Data; +using System.Reflection; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + /// Verifies prepared handle ownership across execution failures and retries. + [Trait("Set", "3")] + public class PreparedCommandLifecycleTests + { + /// Execution errors retain handles through retries and replacement. + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData("SELECT 1 / @p;")] + [InlineData("SET @p = 1 / @p; SELECT @p;")] + public void ExecuteScalar_PreparedRuntimeError_PreservesHandleForRetry(string batch) + { + // Arrange + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) + { + Pooling = false + }; + using SqlConnection connection = new(builder.ConnectionString); + connection.Open(); + using SqlCommand command = connection.CreateCommand(); + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.Int); + FieldInfo handleField = typeof(SqlCommand).GetField("_prepareHandle", + BindingFlags.Instance | BindingFlags.NonPublic)!; + FieldInfo capacitiesField = typeof(SqlCommand).GetField( + "_preparedParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + for (int attempt = 0; attempt < 3; attempt++) + { + // Each changed batch replaces the previous preparation on the same connection. + command.CommandText = batch + $" -- attempt {attempt}"; + parameter.Value = 0; + command.Prepare(); + + // Act + SqlException exception = Assert.Throws( + () => command.ExecuteScalar()); + + // Assert + Assert.Equal(8134, exception.Number); + int handle = Assert.IsType(handleField.GetValue(command)); + Assert.True(handle >= 0, + "Execution failure must retain the returned prepare handle."); + object capacities = capacitiesField.GetValue(command); + Assert.NotNull(capacities); + + // Act: correct the value and retry without changing the batch. + parameter.Value = 1; + object result = command.ExecuteScalar(); + + // Assert: reuse both the handle and its declaration capacity. + Assert.Equal(1, result); + Assert.Equal(handle, handleField.GetValue(command)); + Assert.Same(capacities, capacitiesField.GetValue(command)); + } + } + + /// Execution errors retain handles through retries and replacement. + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData("SELECT 1 / @p;")] + [InlineData("SET @p = 1 / @p; SELECT @p;")] + public async Task ExecuteScalarAsync_PreparedRuntimeError_PreservesHandleForRetry( + string batch) + { + // Arrange + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) + { + Pooling = false + }; + using SqlConnection connection = new(builder.ConnectionString); + await connection.OpenAsync(); + using SqlCommand command = connection.CreateCommand(); + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.Int); + FieldInfo handleField = typeof(SqlCommand).GetField("_prepareHandle", + BindingFlags.Instance | BindingFlags.NonPublic)!; + FieldInfo capacitiesField = typeof(SqlCommand).GetField( + "_preparedParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + for (int attempt = 0; attempt < 3; attempt++) + { + // Each changed batch replaces the previous preparation on the same connection. + command.CommandText = batch + $" -- attempt {attempt}"; + parameter.Value = 0; + command.Prepare(); + + // Act + SqlException exception = await Assert.ThrowsAsync( + () => command.ExecuteScalarAsync()); + + // Assert + Assert.Equal(8134, exception.Number); + int handle = Assert.IsType(handleField.GetValue(command)); + Assert.True(handle >= 0, + "Execution failure must retain the returned prepare handle."); + object capacities = capacitiesField.GetValue(command); + Assert.NotNull(capacities); + + // Act: correct the value and retry without changing the batch. + parameter.Value = 1; + object result = await command.ExecuteScalarAsync(); + + // Assert: reuse both the handle and its declaration capacity. + Assert.Equal(1, result); + Assert.Equal(handle, handleField.GetValue(command)); + Assert.Same(capacities, capacitiesField.GetValue(command)); + } + } + + /// Execution errors retain handles through retries and replacement. + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData("SELECT 1 / @p;")] + [InlineData("SET @p = 1 / @p; SELECT @p;")] + public void ExecuteNonQuery_PreparedRuntimeError_PreservesHandleForRetry(string batch) + { + // Arrange + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) + { + Pooling = false + }; + using SqlConnection connection = new(builder.ConnectionString); + connection.Open(); + using SqlCommand command = connection.CreateCommand(); + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.Int); + FieldInfo handleField = typeof(SqlCommand).GetField("_prepareHandle", + BindingFlags.Instance | BindingFlags.NonPublic)!; + FieldInfo capacitiesField = typeof(SqlCommand).GetField( + "_preparedParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + for (int attempt = 0; attempt < 3; attempt++) + { + // Each changed batch replaces the previous preparation on the same connection. + command.CommandText = batch + $" -- attempt {attempt}"; + parameter.Value = 0; + command.Prepare(); + + // Act + SqlException exception = Assert.Throws( + () => command.ExecuteNonQuery()); + + // Assert + Assert.Equal(8134, exception.Number); + int handle = Assert.IsType(handleField.GetValue(command)); + Assert.True(handle >= 0, + "Execution failure must retain the returned prepare handle."); + object capacities = capacitiesField.GetValue(command); + Assert.NotNull(capacities); + + // Act: correct the value and retry without changing the batch. + parameter.Value = 1; + int result = command.ExecuteNonQuery(); + + // Assert: reuse both the handle and its declaration capacity. + Assert.Equal(-1, result); + Assert.Equal(handle, handleField.GetValue(command)); + Assert.Same(capacities, capacitiesField.GetValue(command)); + } + } + + /// Execution errors retain handles through retries and replacement. + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData("SELECT 1 / @p;")] + [InlineData("SET @p = 1 / @p; SELECT @p;")] + public async Task ExecuteNonQueryAsync_PreparedRuntimeError_PreservesHandleForRetry( + string batch) + { + // Arrange + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) + { + Pooling = false + }; + using SqlConnection connection = new(builder.ConnectionString); + await connection.OpenAsync(); + using SqlCommand command = connection.CreateCommand(); + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.Int); + FieldInfo handleField = typeof(SqlCommand).GetField("_prepareHandle", + BindingFlags.Instance | BindingFlags.NonPublic)!; + FieldInfo capacitiesField = typeof(SqlCommand).GetField( + "_preparedParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + for (int attempt = 0; attempt < 3; attempt++) + { + // Each changed batch replaces the previous preparation on the same connection. + command.CommandText = batch + $" -- attempt {attempt}"; + parameter.Value = 0; + command.Prepare(); + + // Act + SqlException exception = await Assert.ThrowsAsync( + () => command.ExecuteNonQueryAsync()); + + // Assert + Assert.Equal(8134, exception.Number); + int handle = Assert.IsType(handleField.GetValue(command)); + Assert.True(handle >= 0, + "Execution failure must retain the returned prepare handle."); + object capacities = capacitiesField.GetValue(command); + Assert.NotNull(capacities); + + // Act: correct the value and retry without changing the batch. + parameter.Value = 1; + int result = await command.ExecuteNonQueryAsync(); + + // Assert: reuse both the handle and its declaration capacity. + Assert.Equal(-1, result); + Assert.Equal(handle, handleField.GetValue(command)); + Assert.Same(capacities, capacitiesField.GetValue(command)); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs index df4beb2799..c8c25f69ce 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs @@ -2,6 +2,7 @@ // 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.Reflection; using System.Threading.Tasks; @@ -16,6 +17,144 @@ namespace Microsoft.Data.SqlClient.ManualTesting.Tests [Trait("Set", "3")] public static class Utf8ParameterTests { + /// Verifies output capacity on reuse and records #4953's unsized limitation. + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), + nameof(DataTestUtility.IsUTF8Supported), nameof(DataTestUtility.IsNotAzureServer), + nameof(DataTestUtility.IsNotAzureSynapse))] + [InlineData(false, ParameterDirection.Output, 8000, 0)] + [InlineData(true, ParameterDirection.Output, 8000, 0)] + [InlineData(false, ParameterDirection.InputOutput, 8000, 0)] + [InlineData(true, ParameterDirection.InputOutput, 8000, 0)] + [InlineData(false, ParameterDirection.Output, -1, 0)] + [InlineData(true, ParameterDirection.Output, -1, 0)] + [InlineData(false, ParameterDirection.InputOutput, -1, 0)] + [InlineData(true, ParameterDirection.InputOutput, -1, 0)] + [InlineData(false, ParameterDirection.Output, 8000, 1)] + [InlineData(true, ParameterDirection.Output, 8000, 1)] + [InlineData(false, ParameterDirection.InputOutput, 8000, 1)] + [InlineData(true, ParameterDirection.InputOutput, 8000, 1)] + [InlineData(false, ParameterDirection.Output, -1, 1)] + [InlineData(true, ParameterDirection.Output, -1, 1)] + [InlineData(false, ParameterDirection.InputOutput, -1, 1)] + [InlineData(true, ParameterDirection.InputOutput, -1, 1)] + [InlineData(false, ParameterDirection.Output, 8000, 2)] + [InlineData(true, ParameterDirection.Output, 8000, 2)] + [InlineData(false, ParameterDirection.InputOutput, 8000, 2)] + [InlineData(true, ParameterDirection.InputOutput, 8000, 2)] + [InlineData(false, ParameterDirection.Output, -1, 2)] + [InlineData(true, ParameterDirection.Output, -1, 2)] + [InlineData(false, ParameterDirection.InputOutput, -1, 2)] + [InlineData(true, ParameterDirection.InputOutput, -1, 2)] + [InlineData(false, ParameterDirection.Output, 0, 0)] + [InlineData(true, ParameterDirection.Output, 0, 0)] + [InlineData(false, ParameterDirection.InputOutput, 0, 0)] + [InlineData(true, ParameterDirection.InputOutput, 0, 0)] + public static async Task AnsiOutputDirections_CommandReuse_RespectsCapacityAndInference( + bool useAsync, ParameterDirection direction, int size, int commandMode) + { + // Arrange: use a UTF-8 database so characters and encoded byte counts differ. + string database = DataTestUtility.GetLongName("Utf8Output", false); + SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) + { + InitialCatalog = "master", + Pooling = false + }; + using SqlConnection admin = new(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 = new(builder.ConnectionString); + await connection.OpenAsync(); + const string Batch = "SELECT @p; SET @p = @source;"; + using StoredProcedure procedure = commandMode == 1 + ? new StoredProcedure(connection, "Utf8Output", + "@p varchar(max) OUTPUT, @source nvarchar(max) AS " + Batch) + : null; + using SqlCommand command = connection.CreateCommand(); + command.CommandText = procedure?.Name ?? Batch; + command.CommandType = commandMode == 1 + ? CommandType.StoredProcedure : CommandType.Text; + SqlParameter source = command.Parameters.Add("@source", SqlDbType.NVarChar, -1); + SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, size); + parameter.Direction = direction; + + if (size == 0) + { + // Characterize #4953's unsized-output limitation without changing Size policy. + command.CommandText = "SET @p = @source;"; + parameter.Value = "abcdefgh"; + source.Value = new string('\u00e9', 2); + // Act + if (useAsync) + { + await command.ExecuteNonQueryAsync(); + } + else + { + command.ExecuteNonQuery(); + } + // Assert + Assert.Equal(direction == ParameterDirection.Output + ? string.Empty : new string('\u00e9', 2), parameter.Value); + source.Value = new string('\u00e9', 4); + InvalidOperationException exception = useAsync + ? await Assert.ThrowsAsync( + () => command.ExecuteNonQueryAsync()) + : Assert.Throws(() => command.ExecuteNonQuery()); + Assert.Contains(nameof(SqlParameter.Size), exception.Message); + return; + } + + parameter.Value = "a" + new string('\u00e9', 4000); + parameter.Offset = 1; + if (commandMode == 2) + { + command.Prepare(); + } + foreach (int count in new[] { 4000, 4001, 4000, 4001 }) + { + string text = new('\u00e9', count); + source.Value = text; + parameter.Value = direction == ParameterDirection.Output + ? "ignored" : "a" + text; + // Act: inspect the input leg, then consume output tokens before checking Value. + using (SqlDataReader reader = useAsync + ? await command.ExecuteReaderAsync() : command.ExecuteReader()) + { + // Assert + Assert.True(useAsync ? await reader.ReadAsync() : reader.Read()); + if (direction == ParameterDirection.Output) + { + Assert.True(reader.IsDBNull(0)); + } + else + { + Assert.Equal(text, reader.GetString(0)); + } + Assert.False(useAsync ? await reader.ReadAsync() : reader.Read()); + Assert.False(useAsync + ? await reader.NextResultAsync() : reader.NextResult()); + } + string expected = direction == ParameterDirection.Output && size == 8000 + ? text.Substring(0, Math.Min(count, 4000)) : text; + Assert.Equal(expected, parameter.Value); + Assert.Equal(size, parameter.Size); + } + } + finally + { + // Cleanup + DataTestUtility.DropDatabase(admin, database); + } + } + /// /// Covers text, stored-procedure and prepared RPCs with sync and async execution. /// diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlCommandTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlCommandTests.cs new file mode 100644 index 0000000000..4e65313353 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlCommandTests.cs @@ -0,0 +1,63 @@ +// 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.Reflection; +using Microsoft.Data.SqlClient.Parser.Tokens; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// Verifies preparation response handling in SqlCommand. +public class SqlCommandTests +{ + /// A returned handle commits the capacities belonging to that preparation. + [Fact] + public void OnReturnValue_PrepareHandle_CommitsPreparedCapacity() + { + // Arrange + using SqlCommand command = new(); + int[] pendingCapacities = { 8000 }; + typeof(SqlCommand).GetField("_pendingParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(command, pendingCapacities); + typeof(SqlCommand).GetField("_inPrepare", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(command, true); + TdsReturnValueToken token = new(); + token.Value.Int32 = 123; + + // Act + command.OnReturnValue(token, null!); + + // Assert + Assert.False(command.InPrepare); + Assert.Same(pendingCapacities, typeof(SqlCommand).GetField( + "_preparedParameterByteCapacities", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(command)); + Assert.Equal(123, typeof(SqlCommand).GetField("_prepareHandle", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(command)); + } + + /// + /// A null handle leaves no committed preparation capacity after a failed prepare. + /// + [Fact] + public void OnReturnValue_NullPrepareHandle_DoesNotCommitPreparedCapacity() + { + // Arrange + using SqlCommand command = new(); + typeof(SqlCommand).GetField("_pendingParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(command, new[] { 8000 }); + typeof(SqlCommand).GetField("_inPrepare", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(command, true); + TdsReturnValueToken token = new(); + token.Value.SetToNullOfType(SqlBuffer.StorageType.Int32); + + // Act + command.OnReturnValue(token, null!); + + // Assert + Assert.False(command.InPrepare); + Assert.Null(typeof(SqlCommand).GetField("_preparedParameterByteCapacities", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(command)); + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlParameterTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlParameterTests.cs new file mode 100644 index 0000000000..8487de3ac9 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlParameterTests.cs @@ -0,0 +1,77 @@ +// 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.Reflection; +using System.Text; +using Microsoft.Data.SqlClient.Parser; +using Microsoft.Data.SqlClient.Server; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests; + +/// Verifies ANSI metadata sizing for SqlParameter output directions. +public class SqlParameterTests +{ + /// + /// Input/output values can promote metadata; output-only metadata keeps its capacity. + /// + [Theory] + [InlineData(ParameterDirection.Output, 8000, 8000L)] + [InlineData(ParameterDirection.InputOutput, 8000, -1L)] + [InlineData(ParameterDirection.Output, -1, -1L)] + [InlineData(ParameterDirection.InputOutput, -1, -1L)] + public void GetMetadataForTypeInfo_MultibyteOutputValue_UsesExpectedByteCapacity( + ParameterDirection direction, int size, long expectedLength) + { + // Arrange + SqlParameter parameter = new("@p", SqlDbType.VarChar, size) + { + Direction = direction, + Offset = 1, + Value = "a" + new string('\u00e9', 4001) + }; + TdsParser parser = new(false, false); + typeof(TdsParser).GetField("_defaultEncoding", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, Encoding.UTF8); + + // Act + SmiParameterMetaData metadata = parameter.GetMetadataForTypeInfo(parser); + + // Assert + Assert.Equal(expectedLength, metadata.MaxLength); + Assert.Equal(expectedLength == -1, parameter.InternalMetaType.IsPlp); + Assert.Equal(size, parameter.Size); + } + + /// Null values preserve the declared bounded or MAX output capacity. + [Theory] + [InlineData(ParameterDirection.Output, 8000)] + [InlineData(ParameterDirection.InputOutput, 8000)] + [InlineData(ParameterDirection.Output, -1)] + [InlineData(ParameterDirection.InputOutput, -1)] + public void GetMetadataForTypeInfo_NullOutputValue_UsesDeclaredCapacity( + ParameterDirection direction, int size) + { + // Arrange + SqlParameter parameter = new("@p", SqlDbType.VarChar, size) + { + Direction = direction, + Offset = 1, + Value = DBNull.Value + }; + TdsParser parser = new(false, false); + typeof(TdsParser).GetField("_defaultEncoding", + BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, Encoding.UTF8); + + // Act + SmiParameterMetaData metadata = parameter.GetMetadataForTypeInfo(parser); + + // Assert + Assert.Equal(size, metadata.MaxLength); + Assert.Equal(size == -1, parameter.InternalMetaType.IsPlp); + Assert.Equal(size, parameter.Size); + } +}