diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMethodTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMethodTranslator.cs index aa9dbebb1b8..44d5e5ceeef 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMethodTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMethodTranslator.cs @@ -31,6 +31,12 @@ public class SqlServerDateTimeMethodTranslator : IMethodCallTranslator { typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddMilliseconds), new[] { typeof(double) })!, "millisecond" } }; + private static readonly Dictionary _methodInfoDateDiffMapping = new() + { + { typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.ToUnixTimeSeconds), Type.EmptyTypes)!, "second" }, + { typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.ToUnixTimeMilliseconds), Type.EmptyTypes)!, "millisecond" } + }; + private static readonly MethodInfo AtTimeZoneDateTimeOffsetMethodInfo = typeof(SqlServerDbFunctionsExtensions) .GetRuntimeMethod( nameof(SqlServerDbFunctionsExtensions.AtTimeZone), new[] { typeof(DbFunctions), typeof(DateTimeOffset), typeof(string) })!; @@ -39,6 +45,8 @@ public class SqlServerDateTimeMethodTranslator : IMethodCallTranslator .GetRuntimeMethod( nameof(SqlServerDbFunctionsExtensions.AtTimeZone), new[] { typeof(DbFunctions), typeof(DateTime), typeof(string) })!; + private static readonly SqlConstantExpression UnixEpoch = new (Expression.Constant(DateTimeOffset.UnixEpoch), null); + private readonly ISqlExpressionFactory _sqlExpressionFactory; private readonly IRelationalTypeMappingSource _typeMappingSource; @@ -133,6 +141,17 @@ public SqlServerDateTimeMethodTranslator( resultTypeMapping); } + if (_methodInfoDateDiffMapping.TryGetValue(method, out var timePart)) + { + return _sqlExpressionFactory.ApplyDefaultTypeMapping( + _sqlExpressionFactory.Function( + "DATEDIFF_BIG", + new[] { _sqlExpressionFactory.Fragment(timePart), UnixEpoch, instance! }, + nullable: true, + argumentsPropagateNullability: new[] { false, false, true }, + typeof(long))); + } + return null; } } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteDateTimeAddTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteDateTimeMethodTranslator.cs similarity index 76% rename from src/EFCore.Sqlite.Core/Query/Internal/SqliteDateTimeAddTranslator.cs rename to src/EFCore.Sqlite.Core/Query/Internal/SqliteDateTimeMethodTranslator.cs index c827a3812a6..64aa9582c7d 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteDateTimeAddTranslator.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteDateTimeMethodTranslator.cs @@ -11,7 +11,7 @@ namespace Microsoft.EntityFrameworkCore.Sqlite.Query.Internal; /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// -public class SqliteDateTimeAddTranslator : IMethodCallTranslator +public class SqliteDateTimeMethodTranslator : IMethodCallTranslator { private static readonly MethodInfo AddMilliseconds = typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddMilliseconds), new[] { typeof(double) })!; @@ -19,6 +19,12 @@ private static readonly MethodInfo AddMilliseconds private static readonly MethodInfo AddTicks = typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddTicks), new[] { typeof(long) })!; + private static readonly MethodInfo ToUnixTimeSeconds + = typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.ToUnixTimeSeconds), Type.EmptyTypes)!; + + private static readonly MethodInfo ToUnixTimeMilliseconds + = typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.ToUnixTimeMilliseconds), Type.EmptyTypes)!; + private readonly Dictionary _methodInfoToUnitSuffix = new() { { typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddYears), new[] { typeof(int) })!, " years" }, @@ -40,7 +46,7 @@ private static readonly MethodInfo AddTicks /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - public SqliteDateTimeAddTranslator(SqliteSqlExpressionFactory sqlExpressionFactory) + public SqliteDateTimeMethodTranslator(SqliteSqlExpressionFactory sqlExpressionFactory) { _sqlExpressionFactory = sqlExpressionFactory; } @@ -60,7 +66,9 @@ public SqliteDateTimeAddTranslator(SqliteSqlExpressionFactory sqlExpressionFacto ? TranslateDateTime(instance, method, arguments) : method.DeclaringType == typeof(DateOnly) ? TranslateDateOnly(instance, method, arguments) - : null; + : method.DeclaringType == typeof(DateTimeOffset) + ? TranslateDateTimeOffset(instance, method, arguments) + : null; private SqlExpression? TranslateDateTime( SqlExpression? instance, @@ -145,4 +153,40 @@ public SqliteDateTimeAddTranslator(SqliteSqlExpressionFactory sqlExpressionFacto return null; } + + private SqlExpression? TranslateDateTimeOffset( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments) + { + if (ToUnixTimeSeconds.Equals(method)) + { + return _sqlExpressionFactory.Function( + "unixepoch", + new[] + { + instance! + }, + argumentsPropagateNullability: new[] { true, true }, + nullable: true, + returnType: method.ReturnType); + } + else if (ToUnixTimeMilliseconds.Equals(method)) + { + return _sqlExpressionFactory.Convert( + _sqlExpressionFactory.Multiply( + _sqlExpressionFactory.Subtract( + _sqlExpressionFactory.Function( + "julianday", + new[] { instance! }, + nullable: true, + argumentsPropagateNullability: new[] { true }, + typeof(double)), + _sqlExpressionFactory.Constant(2440587.5)), // NB: Result of julianday('1970-01-01 00:00:00') + _sqlExpressionFactory.Constant(TimeSpan.TicksPerDay)), + typeof(long)); + } + + return null; + } } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs index 13458bd77df..c6c15ff2351 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs @@ -27,7 +27,7 @@ public SqliteMethodCallTranslatorProvider(RelationalMethodCallTranslatorProvider { new SqliteByteArrayMethodTranslator(sqlExpressionFactory), new SqliteCharMethodTranslator(sqlExpressionFactory), - new SqliteDateTimeAddTranslator(sqlExpressionFactory), + new SqliteDateTimeMethodTranslator(sqlExpressionFactory), new SqliteGlobMethodTranslator(sqlExpressionFactory), new SqliteHexMethodTranslator(sqlExpressionFactory), new SqliteMathTranslator(sqlExpressionFactory), diff --git a/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs index 3e842503d4b..0810b8140f2 100644 --- a/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs @@ -8210,6 +8210,36 @@ public virtual Task Using_indexer_on_byte_array_and_string_in_projection(bool as Assert.Equal(e.String, a.String); }); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task DateTimeOffset_to_unix_time_milliseconds(bool async) + { + long unixEpochMilliseconds = DateTimeOffset.UnixEpoch.ToUnixTimeMilliseconds(); + + return AssertQuery( + async, + ss => ss.Set() + .Include(g => g.Squad.Missions) + .Where(s => s.Squad.Missions + .Where(m => unixEpochMilliseconds == m.Mission.Timeline.ToUnixTimeMilliseconds()) + .FirstOrDefault() == null)); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task DateTimeOffset_to_unix_time_seconds(bool async) + { + long unixEpochSeconds = DateTimeOffset.UnixEpoch.ToUnixTimeSeconds(); + + return AssertQuery( + async, + ss => ss.Set() + .Include(g => g.Squad.Missions) + .Where(s => s.Squad.Missions + .Where(m => unixEpochSeconds == m.Mission.Timeline.ToUnixTimeSeconds()) + .FirstOrDefault() == null)); + } + protected GearsOfWarContext CreateContext() => Fixture.CreateContext(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs index b1317c75843..46cf72f8201 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs @@ -9978,6 +9978,44 @@ FROM [Squads] AS [s] """); } + public override async Task DateTimeOffset_to_unix_time_milliseconds(bool async) + { + await base.DateTimeOffset_to_unix_time_milliseconds(async); + + AssertSql( + @"@__unixEpochMilliseconds_0='0' + +SELECT [g].[Nickname], [g].[SquadId], [g].[AssignedCityName], [g].[CityOfBirthName], [g].[Discriminator], [g].[FullName], [g].[HasSoulPatch], [g].[LeaderNickname], [g].[LeaderSquadId], [g].[Rank], [s].[Id], [s].[Banner], [s].[Banner5], [s].[InternalNumber], [s].[Name], [s1].[SquadId], [s1].[MissionId] +FROM [Gears] AS [g] +INNER JOIN [Squads] AS [s] ON [g].[SquadId] = [s].[Id] +LEFT JOIN [SquadMissions] AS [s1] ON [s].[Id] = [s1].[SquadId] +WHERE NOT (EXISTS ( + SELECT 1 + FROM [SquadMissions] AS [s0] + INNER JOIN [Missions] AS [m] ON [s0].[MissionId] = [m].[Id] + WHERE [s].[Id] = [s0].[SquadId] AND @__unixEpochMilliseconds_0 = DATEDIFF_BIG(millisecond, '1970-01-01T00:00:00.0000000+00:00', [m].[Timeline]))) +ORDER BY [g].[Nickname], [g].[SquadId], [s].[Id], [s1].[SquadId]"); + } + + public override async Task DateTimeOffset_to_unix_time_seconds(bool async) + { + await base.DateTimeOffset_to_unix_time_seconds(async); + + AssertSql( + @"@__unixEpochSeconds_0='0' + +SELECT [g].[Nickname], [g].[SquadId], [g].[AssignedCityName], [g].[CityOfBirthName], [g].[Discriminator], [g].[FullName], [g].[HasSoulPatch], [g].[LeaderNickname], [g].[LeaderSquadId], [g].[Rank], [s].[Id], [s].[Banner], [s].[Banner5], [s].[InternalNumber], [s].[Name], [s1].[SquadId], [s1].[MissionId] +FROM [Gears] AS [g] +INNER JOIN [Squads] AS [s] ON [g].[SquadId] = [s].[Id] +LEFT JOIN [SquadMissions] AS [s1] ON [s].[Id] = [s1].[SquadId] +WHERE NOT (EXISTS ( + SELECT 1 + FROM [SquadMissions] AS [s0] + INNER JOIN [Missions] AS [m] ON [s0].[MissionId] = [m].[Id] + WHERE [s].[Id] = [s0].[SquadId] AND @__unixEpochSeconds_0 = DATEDIFF_BIG(second, '1970-01-01T00:00:00.0000000+00:00', [m].[Timeline]))) +ORDER BY [g].[Nickname], [g].[SquadId], [s].[Id], [s1].[SquadId]"); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs index e94b8a9c352..5114976c77f 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs @@ -9396,6 +9396,44 @@ public override async Task Using_indexer_on_byte_array_and_string_in_projection( """); } + public override async Task DateTimeOffset_to_unix_time_milliseconds(bool async) + { + await base.DateTimeOffset_to_unix_time_milliseconds(async); + + AssertSql( + @"@__unixEpochMilliseconds_0='0' + +SELECT ""g"".""Nickname"", ""g"".""SquadId"", ""g"".""AssignedCityName"", ""g"".""CityOfBirthName"", ""g"".""Discriminator"", ""g"".""FullName"", ""g"".""HasSoulPatch"", ""g"".""LeaderNickname"", ""g"".""LeaderSquadId"", ""g"".""Rank"", ""s"".""Id"", ""s"".""Banner"", ""s"".""Banner5"", ""s"".""InternalNumber"", ""s"".""Name"", ""s1"".""SquadId"", ""s1"".""MissionId"" +FROM ""Gears"" AS ""g"" +INNER JOIN ""Squads"" AS ""s"" ON ""g"".""SquadId"" = ""s"".""Id"" +LEFT JOIN ""SquadMissions"" AS ""s1"" ON ""s"".""Id"" = ""s1"".""SquadId"" +WHERE NOT (EXISTS ( + SELECT 1 + FROM ""SquadMissions"" AS ""s0"" + INNER JOIN ""Missions"" AS ""m"" ON ""s0"".""MissionId"" = ""m"".""Id"" + WHERE ""s"".""Id"" = ""s0"".""SquadId"" AND @__unixEpochMilliseconds_0 = CAST(((julianday(""m"".""Timeline"") - 2440587.5) * 864000000000.0) AS INTEGER))) +ORDER BY ""g"".""Nickname"", ""g"".""SquadId"", ""s"".""Id"", ""s1"".""SquadId"""); + } + + public override async Task DateTimeOffset_to_unix_time_seconds(bool async) + { + await base.DateTimeOffset_to_unix_time_seconds(async); + + AssertSql( + @"@__unixEpochSeconds_0='0' + +SELECT ""g"".""Nickname"", ""g"".""SquadId"", ""g"".""AssignedCityName"", ""g"".""CityOfBirthName"", ""g"".""Discriminator"", ""g"".""FullName"", ""g"".""HasSoulPatch"", ""g"".""LeaderNickname"", ""g"".""LeaderSquadId"", ""g"".""Rank"", ""s"".""Id"", ""s"".""Banner"", ""s"".""Banner5"", ""s"".""InternalNumber"", ""s"".""Name"", ""s1"".""SquadId"", ""s1"".""MissionId"" +FROM ""Gears"" AS ""g"" +INNER JOIN ""Squads"" AS ""s"" ON ""g"".""SquadId"" = ""s"".""Id"" +LEFT JOIN ""SquadMissions"" AS ""s1"" ON ""s"".""Id"" = ""s1"".""SquadId"" +WHERE NOT (EXISTS ( + SELECT 1 + FROM ""SquadMissions"" AS ""s0"" + INNER JOIN ""Missions"" AS ""m"" ON ""s0"".""MissionId"" = ""m"".""Id"" + WHERE ""s"".""Id"" = ""s0"".""SquadId"" AND @__unixEpochSeconds_0 = unixepoch(""m"".""Timeline""))) +ORDER BY ""g"".""Nickname"", ""g"".""SquadId"", ""s"".""Id"", ""s1"".""SquadId"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); }