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 /// </summary> public static class MySqlDbFunctionsExtensions { + #region ConvertTimeZone + + /// <summary> + /// Converts the `DateTime` value <paramref name="dateTime"/> from the time zone given by <paramref name="fromTimeZone"/> to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateTime">The `DateTime` value to convert.</param> + /// <param name="fromTimeZone">The time zone to convert from.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime dateTime, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateOnly` value <paramref name="dateOnly"/> from the time zone given by <paramref name="fromTimeZone"/> to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`.. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateOnly">The `DateOnly` value to convert.</param> + /// <param name="fromTimeZone">The time zone to convert from.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly dateOnly, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateTime?` value <paramref name="dateTime"/> from the time zone given by <paramref name="fromTimeZone"/> to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateTime">The `DateTime?` value to convert.</param> + /// <param name="fromTimeZone">The time zone to convert from.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime? dateTime, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateOnly?` value <paramref name="dateOnly"/> from the time zone given by <paramref name="fromTimeZone"/> to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, fromTimeZone, toTimeZone)`.. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateOnly">The `DateOnly?` value to convert.</param> + /// <param name="fromTimeZone">The time zone to convert from.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly? dateOnly, + string fromTimeZone, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateTime` value <paramref name="dateTime"/> from `@@session.time_zone` to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateTime">The `DateTime` value to convert.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime dateTime, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateTimeOffset` value <paramref name="dateTimeOffset"/> from `+00:00`/UTC to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value as a `DateTime`. + /// Corresponds to `CONVERT_TZ(dateTime, '+00:00', toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateTimeOffset">The `DateTimeOffset` value to convert.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted `DateTime?` value.</returns> + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTimeOffset dateTimeOffset, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateOnly` value <paramref name="dateOnly"/> from `@@session.time_zone` to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateOnly">The `DateOnly` value to convert.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly dateOnly, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateTime?` value <paramref name="dateTime"/> from `@@session.time_zone` to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateTime">The `DateTime?` value to convert.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTime? dateTime, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateTimeOffset?` value <paramref name="dateTimeOffset"/> from `+00:00`/UTC to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value as a `DateTime`. + /// Corresponds to `CONVERT_TZ(dateTime, '+00:00', toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateTimeOffset">The `DateTimeOffset?` value to convert.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted `DateTime?` value.</returns> + public static DateTime? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateTimeOffset? dateTimeOffset, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + /// <summary> + /// Converts the `DateOnly?` value <paramref name="dateOnly"/> from `@@session.time_zone` to the time zone given by <paramref name="toTimeZone"/> and returns the resulting value. + /// Corresponds to `CONVERT_TZ(dateTime, @@session.time_zone, toTimeZone)`. + /// </summary> + /// <param name="_">The DbFunctions instance.</param> + /// <param name="dateOnly">The `DateOnly?` value to convert.</param> + /// <param name="toTimeZone">The time zone to convert to.</param> + /// <returns>The converted value.</returns> + public static DateOnly? ConvertTimeZone( + [CanBeNull] this DbFunctions _, + DateOnly? dateOnly, + string toTimeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(ConvertTimeZone))); + + #endregion ConvertTimeZone + /// <summary> /// 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..bf1d9eb83 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<MethodInfo> _convertTimeZoneMethodInfos = + [ + 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<SqlExpression> arguments, IDiagnosticsLogger<DbLoggerCategory.Query> logger) { + 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. + 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<MySqlTimeZoneTest.MySqlTimeZoneFixture> + { + public MySqlTimeZoneTest(MySqlTimeZoneFixture fixture) + : base(fixture) + { + } + + [ConditionalFact] + public void ConvertTimeZone() + { + using var context = Fixture.CreateContext(); + SetSessionTimeZone(context); + Fixture.ClearSql(); + + var metalContainer = context.Set<Model.Container>() + .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<MySqlTimeZoneFixture.MySqlTimeZoneContext> + { + 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<Model.Container>( + 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; } + } + } + } +}