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