From d06fc119cb664c342d66fef6987278757f778513 Mon Sep 17 00:00:00 2001 From: Laurents Meyer Date: Tue, 5 Mar 2024 00:12:08 +0100 Subject: [PATCH 1/2] Add support for `CONVERT_TZ` via `EF.Functions.ConvertTimeZone()`. (#1860) (cherry picked from commit 1f88ec736f6b290d7d2cd1a2ff312007768da7a5) --- .../Extensions/MySqlDbFunctionsExtensions.cs | 152 ++++++++++++++++++ ...qlDbFunctionsExtensionsMethodTranslator.cs | 58 +++++++ .../Query/MySqlTimeZoneTest.cs | 112 +++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 test/EFCore.MySql.Tests/Query/MySqlTimeZoneTest.cs diff --git a/src/EFCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs b/src/EFCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs index db8f01368..11126ffdd 100644 --- a/src/EFCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs +++ b/src/EFCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs @@ -14,6 +14,158 @@ namespace Microsoft.EntityFrameworkCore /// public static class MySqlDbFunctionsExtensions { + #region ConvertTimeZone + + /// + /// Converts the `DateTime` value from the time zone given by to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateTime` value to convert. + /// The time zone to convert from. + /// The time zone to convert to. + /// The converted value. + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime dateTime, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateOnly` value from the time zone given by to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`.. + /// + /// The DbFunctions instance. + /// The `DateOnly` value to convert. + /// The time zone to convert from. + /// The time zone to convert to. + /// The converted value. + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly dateOnly, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateTime?` value from the time zone given by to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateTime?` value to convert. + /// The time zone to convert from. + /// The time zone to convert to. + /// The converted value. + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime? dateTime, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateOnly?` value from the time zone given by to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`.. + /// + /// The DbFunctions instance. + /// The `DateOnly?` value to convert. + /// The time zone to convert from. + /// The time zone to convert to. + /// The converted value. + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly? dateOnly, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateTime` value from `@@session.time_zone` to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateTime` value to convert. + /// The time zone to convert to. + /// The converted value. + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime dateTime, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateTimeOffset` value from `+00:00`/UTC to the time zone given by and returns the resulting value as a `DateTime`. + /// Corresponds to `CONVERT_TZ(dateTime, '+00:00', toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateTimeOffset` value to convert. + /// The time zone to convert to. + /// The converted `DateTime?` value. + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTimeOffset dateTimeOffset, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateOnly` value from `@@session.time_zone` to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateOnly` value to convert. + /// The time zone to convert to. + /// The converted value. + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly dateOnly, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateTime?` value from `@@session.time_zone` to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateTime?` value to convert. + /// The time zone to convert to. + /// The converted value. + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime? dateTime, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateTimeOffset?` value from `+00:00`/UTC to the time zone given by and returns the resulting value as a `DateTime`. + /// Corresponds to `CONVERT_TZ(dateTime, '+00:00', toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateTimeOffset?` value to convert. + /// The time zone to convert to. + /// The converted `DateTime?` value. + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTimeOffset? dateTimeOffset, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// + /// Converts the `DateOnly?` value from `@@session.time_zone` to the time zone given by and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// + /// The DbFunctions instance. + /// The `DateOnly?` value to convert. + /// The time zone to convert to. + /// The converted value. + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly? dateOnly, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + #endregion ConvertTimeZone + /// /// Counts the number of year boundaries crossed between the startDate and endDate. /// Corresponds to TIMESTAMPDIFF(YEAR,startDate,endDate). diff --git a/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs b/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs index d50ac820b..f357f5012 100644 --- a/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs +++ b/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs @@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.Storage; using Pomelo.EntityFrameworkCore.MySql.Query.Internal; +using Pomelo.EntityFrameworkCore.MySql.Utilities; namespace Pomelo.EntityFrameworkCore.MySql.Query.ExpressionTranslators.Internal { @@ -22,6 +23,40 @@ public class MySqlDbFunctionsExtensionsMethodTranslator : IMethodCallTranslator { private readonly MySqlSqlExpressionFactory _sqlExpressionFactory; + private static readonly HashSet _convertDateTimeTimeZoneMethodInfos = + [ + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateTime), typeof(string), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateOnly), typeof(string), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateTime?), typeof(string), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateOnly?), typeof(string), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateTime), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateTimeOffset), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateOnly), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateTime?), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateTimeOffset?), typeof(string) }), + typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), + new[] { typeof(DbFunctions), typeof(DateOnly?), typeof(string) }), + ]; + private static readonly Type[] _supportedLikeTypes = { typeof(int), typeof(long), @@ -148,6 +183,29 @@ public virtual SqlExpression Translate( IReadOnlyList arguments, IDiagnosticsLogger logger) { + if (_convertDateTimeTimeZoneMethodInfos.TryGetValue(method, out _)) + { + // Will not just return `NULL` if any of its parameters is `NULL`, but also if `fromTimeZone` or `toTimeZone` is incorrect. + // Will do no conversion at all if `dateTime` is outside the supported range. + return _sqlExpressionFactory.NullableFunction( + "CONVERT_TZ", + arguments.Count == 3 + ? + [ + arguments[1], + // The implicit fromTimeZone is UTC for DateTimeOffset values and the current session time zone otherwise. + method.GetParameters()[1].ParameterType.UnwrapNullableType() == typeof(DateTimeOffset) + ? _sqlExpressionFactory.Constant("+00:00") + : _sqlExpressionFactory.Fragment("@@session.time_zone"), + arguments[2] + ] + : new[] { arguments[1], arguments[2], arguments[3] }, + method.ReturnType.UnwrapNullableType(), + null, + false, + Statics.GetTrueValues(arguments.Count)); + } + if (_likeMethodInfos.Any(m => Equals(method, m))) { var match = _sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]); diff --git a/test/EFCore.MySql.Tests/Query/MySqlTimeZoneTest.cs b/test/EFCore.MySql.Tests/Query/MySqlTimeZoneTest.cs new file mode 100644 index 000000000..13407882d --- /dev/null +++ b/test/EFCore.MySql.Tests/Query/MySqlTimeZoneTest.cs @@ -0,0 +1,112 @@ +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Pomelo.EntityFrameworkCore.MySql.Query +{ + public sealed class MySqlTimeZoneTest : TestWithFixture + { + public MySqlTimeZoneTest(MySqlTimeZoneFixture fixture) + : base(fixture) + { + } + + [ConditionalFact] + public void ConvertTimeZone() + { + using var context = Fixture.CreateContext(); + SetSessionTimeZone(context); + Fixture.ClearSql(); + + var metalContainer = context.Set() + .Where(c => c.DeliveredDateTimeOffset == c.DeliveredDateTimeUtc && + EF.Functions.ConvertTimeZone(c.DeliveredDateTimeOffset, c.DeliveredTimeZone) == c.DeliveredDateTimeLocal) + .Select( + c => new + { + c.DeliveredDateTimeUtc, + c.DeliveredDateTimeLocal, + c.DeliveredDateTimeOffset, + c.DeliveredTimeZone, + DeliveredWithAppliedTimeZone = EF.Functions.ConvertTimeZone(c.DeliveredDateTimeOffset, c.DeliveredTimeZone), + DeliveredConvertedToDifferent = EF.Functions.ConvertTimeZone(c.DeliveredDateTimeLocal, c.DeliveredTimeZone, "+06:00"), + }) + .Single(); + + Assert.Equal(MySqlTimeZoneFixture.OriginalDateTimeUtc, metalContainer.DeliveredDateTimeUtc); + Assert.Equal(MySqlTimeZoneFixture.OriginalDateTime, metalContainer.DeliveredDateTimeLocal); + Assert.Equal(MySqlTimeZoneFixture.OriginalDateTimeOffset, metalContainer.DeliveredDateTimeOffset); + Assert.Equal(MySqlTimeZoneFixture.OriginalDateTimeOffset.UtcDateTime, metalContainer.DeliveredDateTimeOffset.DateTime); + Assert.Equal(TimeSpan.Zero, metalContainer.DeliveredDateTimeOffset.Offset); + Assert.Equal(MySqlTimeZoneFixture.OriginalDateTime, metalContainer.DeliveredWithAppliedTimeZone); + Assert.Equal(MySqlTimeZoneFixture.OriginalDateTimeUtc.AddHours(6), metalContainer.DeliveredConvertedToDifferent); + + Assert.Equal( + """ +SELECT `c`.`DeliveredDateTimeUtc`, `c`.`DeliveredDateTimeLocal`, `c`.`DeliveredDateTimeOffset`, `c`.`DeliveredTimeZone`, CONVERT_TZ(`c`.`DeliveredDateTimeOffset`, '+00:00', `c`.`DeliveredTimeZone`) AS `DeliveredWithAppliedTimeZone`, CONVERT_TZ(`c`.`DeliveredDateTimeLocal`, `c`.`DeliveredTimeZone`, '+06:00') AS `DeliveredConvertedToDifferent` +FROM `Container` AS `c` +WHERE (`c`.`DeliveredDateTimeOffset` = `c`.`DeliveredDateTimeUtc`) AND (CONVERT_TZ(`c`.`DeliveredDateTimeOffset`, '+00:00', `c`.`DeliveredTimeZone`) = `c`.`DeliveredDateTimeLocal`) +LIMIT 2 +""", + Fixture.Sql); + } + + private static void SetSessionTimeZone(MySqlTimeZoneFixture.MySqlTimeZoneContext context) + { + context.Database.OpenConnection(); + var connection = context.Database.GetDbConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SET @@session.time_zone = '-08:00';"; + command.ExecuteNonQuery(); + } + + public class MySqlTimeZoneFixture : MySqlTestFixtureBase + { + public const int OriginalOffset = 2; // UTC+2 + public static readonly DateTime OriginalDateTimeUtc = new DateTime(2023, 12, 31, 23, 0, 0); + public static readonly DateTime OriginalDateTime = OriginalDateTimeUtc.AddHours(OriginalOffset); + public static readonly DateTimeOffset OriginalDateTimeOffset = new DateTimeOffset(OriginalDateTime, TimeSpan.FromHours(OriginalOffset)); + + public void ClearSql() + => base.SqlCommands.Clear(); + + public new string Sql + => base.Sql; + + public class MySqlTimeZoneContext : ContextBase + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity( + entity => + { + entity.HasData( + new Model.Container + { + Id = 1, + Name = "Heavymetal", + DeliveredDateTimeUtc = OriginalDateTimeUtc, + DeliveredDateTimeLocal = OriginalDateTime, + DeliveredDateTimeOffset = OriginalDateTimeOffset, + DeliveredTimeZone = "+02:00", + }); + }); + } + } + } + + private static class Model + { + public class Container + { + public int Id { get ; set; } + public string Name { get ; set; } + public DateTime DeliveredDateTimeUtc { get; set; } + public DateTime DeliveredDateTimeLocal { get; set; } + public DateTimeOffset DeliveredDateTimeOffset { get; set; } + public string DeliveredTimeZone { get; set; } + } + } + } +} From 57e2156f2912f50cfb8dff74b88b82ee3863c2a5 Mon Sep 17 00:00:00 2001 From: Laurents Meyer Date: Tue, 5 Mar 2024 01:02:27 +0100 Subject: [PATCH 2/2] Fix field name. (cherry picked from commit 9b21394dbba9805621d62fe5e3caafde4311709e) --- .../Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs b/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs index f357f5012..bf1d9eb83 100644 --- a/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs +++ b/src/EFCore.MySql/Query/ExpressionTranslators/Internal/MySqlDbFunctionsExtensionsMethodTranslator.cs @@ -23,7 +23,7 @@ public class MySqlDbFunctionsExtensionsMethodTranslator : IMethodCallTranslator { private readonly MySqlSqlExpressionFactory _sqlExpressionFactory; - private static readonly HashSet _convertDateTimeTimeZoneMethodInfos = + private static readonly HashSet _convertTimeZoneMethodInfos = [ typeof(MySqlDbFunctionsExtensions).GetRuntimeMethod( nameof(MySqlDbFunctionsExtensions.ConvertTimeZone), @@ -183,7 +183,7 @@ public virtual SqlExpression Translate( IReadOnlyList arguments, IDiagnosticsLogger logger) { - if (_convertDateTimeTimeZoneMethodInfos.TryGetValue(method, out _)) + if (_convertTimeZoneMethodInfos.TryGetValue(method, out _)) { // Will not just return `NULL` if any of its parameters is `NULL`, but also if `fromTimeZone` or `toTimeZone` is incorrect. // Will do no conversion at all if `dateTime` is outside the supported range.