Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2248,7 +2248,7 @@ private string BuildParamList(TdsParser parser, SqlParameterCollection parameter
// Func will change type to that with a 4 byte length if the type has a two
// byte length and a parameter length > that expressible in 2 bytes.
// @TODO: what func?
mt = sqlParam.ValidateTypeLengths();
mt = sqlParam.ValidateTypeLengths(parser);
if (!mt.IsPlp && sqlParam.Direction is not ParameterDirection.Output)
{
sqlParam.FixStreamDataForNonPLP();
Expand Down Expand Up @@ -2805,7 +2805,7 @@ private void SetUpRPCParameters(_SqlRPC rpc, bool inSchema, SqlParameterCollecti

// Func will change type to that with a 4 byte length if the type has a 2 byte
// length and a parameter length > than that expressible in 2 bytes.
if (!parameter.ValidateTypeLengths().IsPlp && parameter.Direction is not ParameterDirection.Output)
if (!parameter.ValidateTypeLengths(_stateObj.Parser).IsPlp && parameter.Direction is not ParameterDirection.Output)
{
parameter.FixStreamDataForNonPLP();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2091,7 +2091,7 @@ internal void Validate(int index, bool isCommandProc)

// func will change type to that with a 4 byte length if the type has a two
// byte length and a parameter length > than that expressible in 2 bytes
internal MetaType ValidateTypeLengths()
internal MetaType ValidateTypeLengths(TdsParser parser = null)
{
MetaType mt = InternalMetaType;
// Since the server will automatically reject any
Expand All @@ -2104,6 +2104,20 @@ internal MetaType ValidateTypeLengths()
long actualSizeInBytes = GetActualSize();
long sizeInCharacters = Size;

// GetActualSize returns characters for ANSI strings. Decide whether to use
// MAX before building the SQL declaration or writing the RPC metadata, using
// the same connection encoding and character slice as the serializer.
if (parser != null && mt.IsAnsiType && !IsNull &&
actualSizeInBytes <= TdsEnums.TYPE_SIZE_LIMIT &&
sizeInCharacters <= TdsEnums.TYPE_SIZE_LIMIT && sizeInCharacters != -1 &&
!HasFlag(SqlParameterFlags.CoercedValueIsDataFeed) && Direction != ParameterDirection.Output)
{
object value = GetCoercedValue();
string text = value is SqlString sqlString ? sqlString.Value :
value is SqlChars sqlChars ? new string(sqlChars.Value) : (string)value;
actualSizeInBytes = parser.GetEncodingCharLength(text, (int)actualSizeInBytes, Offset, null);
}

// Bug: VSTFDevDiv #636867
// Notes:
// 'actualSizeInBytes' is the size of value passed;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Data;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects;
using Xunit;

namespace Microsoft.Data.SqlClient.ManualTesting.Tests
{
/// <summary>
/// Verifies that UTF-8 RPC values crossing 8000 bytes round-trip without malformed metadata or data loss.
/// </summary>
[Trait("Set", "3")]
public static class Utf8ParameterTests
{
/// <summary>
/// Covers text, stored-procedure and prepared RPCs with both synchronous and asynchronous execution.
/// </summary>
[ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsUTF8Supported), nameof(DataTestUtility.IsNotAzureServer), nameof(DataTestUtility.IsNotAzureSynapse))]
[InlineData(false, 0)]
[InlineData(true, 0)]
[InlineData(false, 1)]
[InlineData(true, 1)]
[InlineData(false, 2)]
[InlineData(true, 2)]
public static async Task VarCharUtf8ByteBoundaryRoundTrip(bool async, int mode)
{
string database = DataTestUtility.GetLongName("Utf8Parameter", false);
SqlConnectionStringBuilder builder = new(DataTestUtility.TCPConnectionString) { InitialCatalog = "master" };
using SqlConnection admin = DataTestUtility.CreateConnection(builder.ConnectionString);
admin.Open();
using (SqlCommand create = admin.CreateCommand())
{
create.CommandText = $"CREATE DATABASE [{database}] COLLATE Latin1_General_100_CI_AS_SC_UTF8";
create.ExecuteNonQuery();
}
try
{
builder.InitialCatalog = database;
using SqlConnection connection = DataTestUtility.CreateConnection(builder.ConnectionString);
if (async) await connection.OpenAsync();
else connection.Open();
using StoredProcedure procedure = new(connection, "Utf8Echo", "@p varchar(max) AS SELECT @p");
using SqlCommand command = connection.CreateCommand();
command.CommandText = mode == 1 ? procedure.Name : "SELECT @p";
command.CommandType = mode == 1 ? CommandType.StoredProcedure : CommandType.Text;
SqlParameter parameter = command.Parameters.Add("@p", SqlDbType.VarChar, 8000);
parameter.Value = new string('é', 4001);
if (mode == 2) command.Prepare();

// Reuse the command across the byte boundary and back, including an existing prepared handle.
foreach (int count in new[] { 4001, 4000, 4001 })
{
string text = new('é', count);
parameter.Value = text;
object result = async ? await command.ExecuteScalarAsync() : command.ExecuteScalar();
Assert.Equal(text, result);
}
}
finally
{
DataTestUtility.DropDatabase(admin, database);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Data;
using System.Data.SqlTypes;
using System.Reflection;
using System.Text;
using Xunit;

namespace Microsoft.Data.SqlClient.UnitTests
{
/// <summary>
/// Guards ANSI parameter declarations against exceeding TDS's byte limit under multibyte encodings.
/// </summary>
public class SqlCommandAnsiParameterTests
{
/// <summary>
/// Exercises the declaration path used by sp_executesql and prepared commands at UTF-8 boundaries.
/// </summary>
[Theory]
[InlineData("é", 4000, 8000, false, false)]
[InlineData("é", 4001, 8000, false, true)]
[InlineData("é", 4001, 0, false, true)]
[InlineData("€", 2666, 8000, false, false)]
[InlineData("€", 2667, 8000, false, true)]
[InlineData("😀", 2001, 8000, false, true)]
[InlineData("a", 8000, 8000, false, false)]
[InlineData("é", 4001, 4000, false, false)]
[InlineData("é", 4001, -1, false, true)]
[InlineData("é", 4001, 8000, true, true)]
public void BuildParamList_UsesEncodedByteCount(string character, int count, int size, bool sqlString, bool expectMax)
{
string text = string.Concat(System.Linq.Enumerable.Repeat(character, count));
SqlParameter parameter = new("@p", SqlDbType.VarChar, size)
{
Value = sqlString ? new SqlString(text) : (object)text
};
string declaration = BuildDeclaration(parameter, Encoding.UTF8);

Assert.Equal(expectMax, parameter.InternalMetaType.IsPlp);
if (expectMax)
{
Assert.Contains("varchar(max)", declaration);
}
else
{
Assert.DoesNotContain("(max)", declaration);
int encodedBytes = Encoding.UTF8.GetByteCount(text.Substring(0, parameter.GetActualSize()));
Assert.Contains($"varchar({Math.Max(size, encodedBytes)})", declaration);
}
// GetActualSize's ANSI character-count contract must remain intact for serialization.
Assert.Equal(size > 0 ? Math.Min(size, text.Length) : text.Length, parameter.GetActualSize());
}

/// <summary>
/// Promotion uses the connection encoding rather than assuming all ANSI values are UTF-8.
/// </summary>
[Fact]
public void BuildParamList_SingleByteEncoding_DoesNotPromote()
{
SqlParameter parameter = new("@p", SqlDbType.VarChar, 8000) { Value = new string('é', 4001) };
Assert.Contains("varchar(8000)", BuildDeclaration(parameter, Encoding.GetEncoding(28591)));
Assert.False(parameter.InternalMetaType.IsPlp);
}

/// <summary>
/// Truncation and offset select which characters determine the encoded byte count.
/// </summary>
[Fact]
public void BuildParamList_Offset_UsesTransmittedSlice()
{
SqlParameter parameter = new("@p", SqlDbType.VarChar, 4001)
{
Value = new string('a', 4001) + new string('é', 4001),
Offset = 4001
};
Assert.Contains("varchar(max)", BuildDeclaration(parameter, Encoding.UTF8));
}

/// <summary>
/// Null parameters must not require conversion to a string for the byte-count check.
/// </summary>
[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);
}

/// <summary>
/// Calls the real SQL declaration builder with a connection's negotiated encoding, without a server.
/// </summary>
/// <param name="parameter">The parameter to validate and declare.</param>
/// <param name="encoding">The encoding negotiated by the connection.</param>
/// <returns>The SQL declaration emitted for the parameter.</returns>
private static string BuildDeclaration(SqlParameter parameter, Encoding encoding)
{
TdsParser parser = new(false, false);
typeof(TdsParser).GetField("_defaultEncoding", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(parser, encoding);
using SqlCommand command = new();
command.Parameters.Add(parameter);
return (string)typeof(SqlCommand).GetMethod("BuildParamList", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(command, new object[] { parser, command.Parameters, false })!;
}
}
}