From 226bdb8e4cedd3933ca415a91d69b8c814b5b9df Mon Sep 17 00:00:00 2001 From: Aris Rellegue <134557572+arellegue@users.noreply.github.com> Date: Thu, 2 May 2024 09:50:38 -0700 Subject: [PATCH] Fix | Fix DateTimeOffset size in TdsValueSetter.cs class file. (#2453) --- .../Data/SqlClient/TdsValueSetter.cs | 30 +++- ....Data.SqlClient.ManualTesting.Tests.csproj | 1 + .../SQL/UdtTest/UdtDateTimeOffsetTest.cs | 143 ++++++++++++++++++ 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/UdtTest/UdtDateTimeOffsetTest.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsValueSetter.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsValueSetter.cs index f2c33824a2..dc797ced8e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsValueSetter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsValueSetter.cs @@ -697,10 +697,34 @@ internal void SetDateTimeOffset(DateTimeOffset value) short offset = (short)value.Offset.TotalMinutes; #if NETCOREAPP - Span result = stackalloc byte[9]; + // In TDS protocol: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/786f5b8a-f87d-4980-9070-b9b7274c681d + // + // date is represented as one 3 - byte unsigned integer that represents the number of days since January 1, year 1. + // + // time(n) is represented as one unsigned integer that represents the number of 10^-n, + // (10 to the power of negative n), second increments since 12 AM within a day. + // The length, in bytes, of that integer depends on the scale n as follows: + // 3 bytes if 0 <= n < = 2. + // 4 bytes if 3 <= n < = 4. + // 5 bytes if 5 <= n < = 7. + // For example: + // DateTimeOffset dateTimeOffset = new DateTimeOffset(2024, 1, 1, 23, 59, 59, TimeSpan.Zero); // using scale of 0 + // time = 23:59:59, scale is 1, is represented as 863990 in 3 bytes or { 246, 46, 13, 0, 0, 0, 0, 0 } in bytes array + + Span result = stackalloc byte[8]; + + // https://learn.microsoft.com/en-us/dotnet/api/system.buffers.binary.binaryprimitives.writeint64bigendian?view=net-8.0 + // WriteInt64LittleEndian requires 8 bytes to write the value. BinaryPrimitives.WriteInt64LittleEndian(result, time); - BinaryPrimitives.WriteInt32LittleEndian(result.Slice(5), days); - _stateObj.WriteByteSpan(result.Slice(0, 8)); + // The DateTimeOffset length is variable depending on the scale, 1 to 7, used. + // If length = 8, 8 - 5 = 3 bytes is used for time. + // If length = 10, 10 - 5 = 5 bytes is used for time. + _stateObj.WriteByteSpan(result.Slice(0, length - 5)); // this writes the time value to the state object using dynamic length based on the scale. + + // Date is represented as 3 bytes. So, 3 bytes are written to the state object. + BinaryPrimitives.WriteInt32LittleEndian(result, days); + _stateObj.WriteByteSpan(result.Slice(0, 3)); #else _stateObj.WriteByteArray(BitConverter.GetBytes(time), length - 5, 0); // time _stateObj.WriteByteArray(BitConverter.GetBytes(days), 3, 0); // date diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj index 1ed87d83cb..19c257001d 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj @@ -204,6 +204,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/UdtTest/UdtDateTimeOffsetTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/UdtTest/UdtDateTimeOffsetTest.cs new file mode 100644 index 0000000000..a09d00895c --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/UdtTest/UdtDateTimeOffsetTest.cs @@ -0,0 +1,143 @@ +// 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 Microsoft.Data.SqlClient.Server; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + public class DateTimeOffsetList : SqlDataRecord + { + public DateTimeOffsetList(DateTimeOffset dateTimeOffset) + : base(new SqlMetaData("dateTimeOffset", SqlDbType.DateTimeOffset, 0, 1)) // this is using scale 1 + { + this.SetValues(dateTimeOffset); + } + } + + public class DateTimeOffsetVariableScale : SqlDataRecord + { + public DateTimeOffsetVariableScale(DateTimeOffset dateTimeOffset, int scale) + : base(new SqlMetaData("dateTimeOffset", SqlDbType.DateTimeOffset, 0, (byte)scale)) // this is using variable scale + { + this.SetValues(dateTimeOffset); + } + } + + public class UdtDateTimeOffsetTest + { + private readonly string _connectionString = null; + private readonly string _udtTableType = DataTestUtility.GetUniqueNameForSqlServer("DataTimeOffsetTableType"); + + public UdtDateTimeOffsetTest() + { + _connectionString = DataTestUtility.TCPConnectionString; + } + + // This unit test is for the reported issue #2423 using a specific scale of 1 + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsNotAzureServer), nameof(DataTestUtility.IsNotAzureSynapse))] + public void SelectFromSqlParameterShouldSucceed() + { + using SqlConnection connection = new(_connectionString); + connection.Open(); + SetupUserDefinedTableType(connection, _udtTableType); + + try + { + DateTimeOffset dateTimeOffset = new DateTimeOffset(2024, 1, 1, 23, 59, 59, 500, TimeSpan.Zero); + var param = new SqlParameter + { + ParameterName = "@params", + SqlDbType = SqlDbType.Structured, + TypeName = $"dbo.{_udtTableType}", + Value = new DateTimeOffsetList[] { new DateTimeOffsetList(dateTimeOffset) } + }; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "SELECT * FROM @params"; + cmd.Parameters.Add(param); + var result = cmd.ExecuteScalar(); + Assert.Equal(dateTimeOffset, result); + } + } + finally + { + DataTestUtility.DropUserDefinedType(connection, _udtTableType); + } + } + + // This unit test is to ensure that time in DateTimeOffset with all scales are working as expected + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsNotAzureServer), nameof(DataTestUtility.IsNotAzureSynapse))] + public void DateTimeOffsetAllScalesTestShouldSucceed() + { + string tvpTypeName = DataTestUtility.GetUniqueNameForSqlServer("tvpType"); + + using SqlConnection connection = new(_connectionString); + connection.Open(); + + try + { + // Use different scale for each test: 0 to 7 + int fromScale = 0; + int toScale = 7; + + for (int scale = fromScale; scale <= toScale; scale++) + { + DateTimeOffset dateTimeOffset = new DateTimeOffset(2024, 1, 1, 23, 59, 59, TimeSpan.Zero); + + // Add sub-second offset corresponding to the scale being tested + TimeSpan subSeconds = TimeSpan.FromTicks((long)(TimeSpan.TicksPerSecond / Math.Pow(10, scale))); + dateTimeOffset = dateTimeOffset.Add(subSeconds); + + DataTestUtility.DropUserDefinedType(connection, tvpTypeName); + SetupDateTimeOffsetTableType(connection, tvpTypeName, scale); + + var param = new SqlParameter + { + ParameterName = "@params", + SqlDbType = SqlDbType.Structured, + Scale = (byte)scale, + TypeName = $"dbo.{tvpTypeName}", + Value = new DateTimeOffsetVariableScale[] { new DateTimeOffsetVariableScale(dateTimeOffset, scale) } + }; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "SELECT * FROM @params"; + cmd.Parameters.Add(param); + var result = cmd.ExecuteScalar(); + Assert.Equal(dateTimeOffset, result); + } + } + } + finally + { + DataTestUtility.DropUserDefinedType(connection, tvpTypeName); + } + } + + private static void SetupUserDefinedTableType(SqlConnection connection, string tableTypeName) + { + using (SqlCommand cmd = connection.CreateCommand()) + { + cmd.CommandType = CommandType.Text; + cmd.CommandText = $"CREATE TYPE {tableTypeName} AS TABLE ([Value] DATETIMEOFFSET(1) NOT NULL) "; + cmd.ExecuteNonQuery(); + } + } + + private static void SetupDateTimeOffsetTableType(SqlConnection connection, string tableTypeName, int scale) + { + using (SqlCommand cmd = connection.CreateCommand()) + { + cmd.CommandType = CommandType.Text; + cmd.CommandText = $"CREATE TYPE {tableTypeName} AS TABLE ([Value] DATETIMEOFFSET({scale}) NOT NULL) "; + cmd.ExecuteNonQuery(); + } + } + } +}