Skip to content

Commit

Permalink
Add support for CONVERT_TZ via EF.Functions.ConvertTimeZone(). (#…
Browse files Browse the repository at this point in the history
…1860)

(cherry picked from commit 1f88ec7)
  • Loading branch information
lauxjpn committed Mar 15, 2024
1 parent 7bf885a commit d06fc11
Show file tree
Hide file tree
Showing 3 changed files with 322 additions and 0 deletions.
152 changes: 152 additions & 0 deletions src/EFCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -22,6 +23,40 @@ public class MySqlDbFunctionsExtensionsMethodTranslator : IMethodCallTranslator
{
private readonly MySqlSqlExpressionFactory _sqlExpressionFactory;

private static readonly HashSet<MethodInfo> _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),
Expand Down Expand Up @@ -148,6 +183,29 @@ public virtual SqlExpression Translate(
IReadOnlyList<SqlExpression> arguments,
IDiagnosticsLogger<DbLoggerCategory.Query> 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]);
Expand Down
112 changes: 112 additions & 0 deletions test/EFCore.MySql.Tests/Query/MySqlTimeZoneTest.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
}
}

0 comments on commit d06fc11

Please sign in to comment.