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 2d75362ef2..042284e5b9 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 @@ -10654,7 +10654,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. @@ -11126,7 +11126,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.Reader.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Reader.cs index 7d054f593b..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 @@ -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( } } + // 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) { try @@ -801,12 +810,14 @@ private void FinishExecuteReader( { if (ADP.IsCatchableExceptionType(e)) { - if (_inPrepare) + 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; + _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 +879,14 @@ private void FinishExecuteReader( } catch (Exception e) when (ADP.IsCatchableExceptionType(e)) { - if (_inPrepare) + 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; + _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 +1462,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 b852e2bafb..f69ca983e7 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(TdsReturnValueToken returnValue, TdsParserStateObjec if (!returnValue.Value.IsNull) { _prepareHandle = returnValue.Value.Int32; + _preparedParameterByteCapacities = _pendingParameterByteCapacities; } + _pendingParameterByteCapacities = null; _inPrepare = false; return; } @@ -2167,7 +2176,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(); @@ -2215,36 +2224,7 @@ 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 = parser.GetEncodingCharLength( - value: s, - numChars: sqlParam.GetActualSize(), - charOffset: sqlParam.Offset, - encoding: null); - if (actualBytes > size) - { - size = actualBytes; - } - } + size = GetAnsiParameterByteCapacity(sqlParam, parser); } // If the user specified a 0-sized parameter for a variable length field, pass @@ -2275,6 +2255,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 @@ -2726,7 +2756,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(); } @@ -2866,6 +2896,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/src/Microsoft/Data/SqlClient/SqlParameter.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs index e943911495..c12e96f978 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs @@ -856,6 +856,7 @@ public int Offset throw ADP.InvalidOffsetValue(value); } _offset = value; + _actualSize = -1; } } @@ -1647,6 +1648,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; + // 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) @@ -1740,29 +1747,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) @@ -2112,7 +2147,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 @@ -2125,6 +2160,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/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/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 new file mode 100644 index 0000000000..c8c25f69ce --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/Utf8SupportTest/Utf8ParameterTests.cs @@ -0,0 +1,387 @@ +// 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.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 + { + /// 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. + /// + [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', 4000); + if (commandMode == 2) + { + command.Prepare(); + } + + // Reuse the command across the byte boundary, including a prepared handle. + foreach (int count in new[] { 4000, 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); + } + } + + [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/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 index 7d75e4ce97..d022818ed5 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlParameterTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/SqlParameterTests.cs @@ -4,14 +4,78 @@ using System; using System.Data; +using System.Reflection; +using System.Text; +using Microsoft.Data.SqlClient.Parser; +using Microsoft.Data.SqlClient.Server; using Microsoft.Data.SqlTypes; using Xunit; namespace Microsoft.Data.SqlClient.UnitTests; -/// Verifies parameter coercion remains separate from vector reader projections. +/// Verifies parameter metadata sizing and value coercion. public sealed 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); + } + /// Supplies each native vector type and unsupported text parameter destination. public static TheoryData NativeVectorTextInputs { 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..6a958356ef --- /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); + TdsReturnValueToken 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); + } + } +} 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); + } + } +} 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..1ee0c9e954 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SqlCommandAnsiParameterTests.cs @@ -0,0 +1,275 @@ +// 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); + 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; + } + finally + { + // Disposing a command does not release its parameters for the next declaration. + command.Parameters.Clear(); + } + } + + 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; + } + } +}