Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BACKPORT] Add support for CONVERT_TZ via EF.Functions.ConvertTimeZone() #1881

Merged
merged 2 commits into from
Mar 15, 2024
Merged
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
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> _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),
Expand Down Expand Up @@ -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]);
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; }
}
}
}
}